Add-on Apps Platform
Overview
"Apps" are add-on features sold individually on top of the core platform. Three are available today.
| id | Name | Description | Route |
|---|---|---|---|
appointment | Appointment | Queue and appointment booking | /apps/appointment |
loyalty | Loyalty | Point cards and reward redemption | /apps/loyalty |
bulletin | Bulletin Board | Community announcement board | /apps/bulletin |
This module owns two concerns: the app registry (appRegistry, hardcoded in the source) along with each app's enabled state per organization, and the AppEnabledGuard that every app route must pass.
One design decision matters above the rest: enabling or disabling an app is a platform-admin-only operation. If an organization could enable apps for itself, commercial gating would be meaningless — hence the modulegate.PlatformOnly enforcement.
Business Flow
- Render the menu — After login, cms-web calls
GET /api/appsto build the sidebar.- This endpoint uses
JwtLoginAuthrather than the global guard because the token does not yet carry alineOaIdat that point. A stricter guard would return 401, and the web interceptor would log the user out. - The service reads the
line_oa_apptable byorganizationId, merges it withappRegistry, and returns each app with the fieldsid,name,description,icon,route, andenabled.
- This endpoint uses
- Toggle an app — A platform admin calls
PUT /api/apps/:appIdwith a body carrying a booleanenabledfield.- The request passes through
modulegate.PlatformOnly(d). Callers identified ascustomer— or that cannot be identified at all — receive a 403. - Unlike
ModuleGate, which fails open, this guard fails closed, because the risk here is privilege escalation.
- The request passes through
- Guard every app request — Any request whose path matches
/apps/followed by an app name passes throughAppEnabledGuard.- The guard extracts the app id from the path with a regular expression.
- Paths that do not match are allowed through untouched.
- If the path matches but
IsAppEnabled(orgId, appId)returns false, the request is rejected with a 403 stating that the app is not enabled for this organization. - A missing
organizationIdin CLS becomes 0, no row is found, and the app is treated as disabled.
- Use the app — Once enabled, users can reach that app's routes. See the Appointment Booking App, the Loyalty & Rewards App, and the Bulletin Board App.
Key Files & Functions
The code lives in internal/modules/apps/, made up of controller.go, service.go, service_appointment.go, guard.go, and dto.go.
| Method | Route | Handler | Guard / Policy |
|---|---|---|---|
| GET | /api/apps | ct.listApps | JwtLoginAuth (public group) + readAll friend-track |
| PUT | /api/apps/:appId | ct.toggleApp | authed group + modulegate.PlatformOnly(d) + update friend-track |
Note that the policy metadata uses PolicyModuleFriendTrack, matching the original TypeScript rather than being a porting mistake.
Key functions and structures:
appRegistryinservice.gois the hardcoded app registry, with field ordering matching the original object literal.AppsService.ListApps(ctx)andAppsService.IsAppEnabled(ctx, orgID, appID)apps.AppEnabledGuard(d)is exported so the bulletin and loyalty modules, which live in separate packages, can reuse it.- The app path matcher:
appPathRe = regexp.MustCompile(`/apps/(\w+)`)
Connections to Other Services
- Access control —
PlatformOnly(fail closed) on the toggle endpoint,AppEnabledGuardon every app route, andJwtLoginAuthon the list endpoint. - Tables —
line_oa_app(per-organization app enablement rows),organization, anduser - CLS —
organizationIdandselfId - Design spec —
docs/superpowers/specs/2026-07-26-platform-admin-design.md - Related modules — Permission & Module Gate, Organization Module Settings, the Appointment Booking App, the Loyalty & Rewards App, and the Bulletin Board App.