Skip to main content

Add-on Apps Platform

Overview

"Apps" are add-on features sold individually on top of the core platform. Three are available today.

idNameDescriptionRoute
appointmentAppointmentQueue and appointment booking/apps/appointment
loyaltyLoyaltyPoint cards and reward redemption/apps/loyalty
bulletinBulletin BoardCommunity 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

  1. Render the menu — After login, cms-web calls GET /api/apps to build the sidebar.
    • This endpoint uses JwtLoginAuth rather than the global guard because the token does not yet carry a lineOaId at that point. A stricter guard would return 401, and the web interceptor would log the user out.
    • The service reads the line_oa_app table by organizationId, merges it with appRegistry, and returns each app with the fields id, name, description, icon, route, and enabled.
  2. Toggle an app — A platform admin calls PUT /api/apps/:appId with a body carrying a boolean enabled field.
    • The request passes through modulegate.PlatformOnly(d). Callers identified as customer — 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.
  3. Guard every app request — Any request whose path matches /apps/ followed by an app name passes through AppEnabledGuard.
    • 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 organizationId in CLS becomes 0, no row is found, and the app is treated as disabled.
  4. 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.

MethodRouteHandlerGuard / Policy
GET/api/appsct.listAppsJwtLoginAuth (public group) + readAll friend-track
PUT/api/apps/:appIdct.toggleAppauthed 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:

  • appRegistry in service.go is the hardcoded app registry, with field ordering matching the original object literal.
  • AppsService.ListApps(ctx) and AppsService.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 controlPlatformOnly (fail closed) on the toggle endpoint, AppEnabledGuard on every app route, and JwtLoginAuth on the list endpoint.
  • Tablesline_oa_app (per-organization app enablement rows), organization, and user
  • CLSorganizationId and selfId
  • Design specdocs/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.