Section 1
What it is
Ummah runs one staff console — ummah-platform-backoffice, a Next.js app over the backend's api/admin/* surface — and one permission system that serves both that console and the merchant portal. There is no separate admin auth stack: the same NestJS auth module mints every JWT, and a single Role table split by scope decides whether a token is staff or merchant.
The mechanism is deliberately simple: roles are rows, permissions are strings, and everything is resolved once, at token time. When a user signs in (POST api/auth/login, with TOTP MFA where enrolled), the backend loads their UserRole links, expands each role — system roles expand to the full registry for their scope, custom roles to their stored list — flattens the result, and stamps it into the JWT. Guards never touch the database again: RolesGuard compares the token's scope and permission set against each handler's @RequireScope(...) and @RequirePermissions(...) metadata.
From sign-in to a guarded handler
FIG 1 · rbac resolution
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart TD
U["Sign-in
POST api/auth/login + TOTP"]:::client
R["Load UserRole → Role rows"]:::plain
S{"Role.isSystem?"}:::plain
ALLP["allPermissions for the scope
from the permission registry"]:::ummah
EXP["Role.permissions as stored
custom fine-grained names"]:::plain
FLAT["flattenRolePermissions
+ TEMPORARY bridge fine → coarse"]:::warn
TOK["JWT minted
scope STAFF or MERCHANT + permissions"]:::ummah
G["JwtAuthGuard → RolesGuard
@RequireScope + @RequirePermissions"]:::ummah
OKN["Handler runs"]:::sub
NO["403 Forbidden"]:::danger
U --> R --> S
S -->|"system role"| ALLP --> FLAT
S -->|"custom role"| EXP --> FLAT
FLAT --> TOK --> G
G -->|"scope matches and ALL listed permissions held"| OKN
G -->|"any check fails"| NO
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
User — UserRole — Role
Roles are rows, not code
User (optional merchantId) links through UserRole to Role { scope, permissions[], merchantId?, isSystem }, with RoleScope = STAFF | MERCHANT. Two system roles are seeded: StaffAdmin (STAFF) and MerchantOwner (MERCHANT). isSystem rows store no permissions — they resolve to allPermissions(scope) at token time, so new registry entries reach admins and owners automatically.
<menu>.<action>
The permission registry
Every permission is a registry entry named <menu>.<action> with action ∈ view | create | edit | delete. STAFF menus include split-profiles (VCED), merchants (VCE) and staff (VCED); MERCHANT menus include sub-merchants (VCE), splits (VE), stores (VCE) and team (VCED). The registry is the single source both for role editors and for system-role expansion.
RolesGuard
The guard stack
JwtAuthGuard authenticates; RolesGuard authorises against @RequireScope('STAFF'|'MERCHANT') and @RequirePermissions(...) — the user must hold all listed permissions, e.g. merchant.read, merchant.write. @CurrentMerchant() resolves req.merchantId (API-key surface) or req.user.merchantId (JWT) and 403s staff tokens, so staff can never accidentally act as a merchant.
ummah-platform-backend/prisma/schema.prisma:227-273 · ummah-platform-backend/prisma/seed.ts:29-59 · ummah-platform-backend/src/modules/auth/permissions.registry.ts:37-179 · ummah-platform-backend/src/modules/auth/roles.guard.ts:11-50 · ummah-platform-backend/src/modules/auth/auth.service.ts:72-158
Section 2
The vocabulary
| Term | Lives in | What it means |
|---|---|---|
Role | prisma/schema.prisma:246 | Named permission bundle: scope RoleScope, permissions String[], optional merchantId (merchant-owned custom roles), isSystem. |
RoleScope | prisma/schema.prisma:50 | STAFF | MERCHANT. Determines which console a role belongs to and which registry half it can draw from. |
isSystem | Role model | Seeded, immutable roles (StaffAdmin, MerchantOwner) that store no permission list and expand to allPermissions(scope) when the JWT is minted. Edit or delete attempts return 422. |
| Permission registry | auth/permissions.registry.ts | The catalogue of every <menu>.<action> permission per scope, plus the helper functions (allPermissions, flattenRolePermissions) used at token time. |
| Legacy bridge | permissions.registry.ts:120-161 | A mapping, marked TEMPORARY in code, from fine-grained registry names to the coarse names decorators still check — e.g. merchants.create → merchant.write. Incomplete; see Section 3. |
@RequireScope | roles.guard.ts | Handler decorator naming the token scope a route demands. Wrong scope = 403 before any permission check. |
@RequirePermissions | roles.guard.ts | Handler decorator listing permissions the token must hold — all of them, not any. |
@CurrentMerchant() | common/decorators/current-merchant.decorator.ts | Parameter decorator resolving the acting merchant id from either auth path; returns 403 for staff tokens so admin JWTs cannot reach merchant-bound handlers. |
PlatformSetting | prisma/schema.prisma:1139 | Platform-wide key/value config editable at api/admin/settings — including the api.disabled_endpoints kill-switch. |
AuditLog | prisma/schema.prisma:816 | Append-only trail of privileged actions, browsable platform-wide and per merchant. RequestLog (schema:847) sits beside it for raw request telemetry. |
| TOTP MFA | auth.controller.ts:49-246 | Authenticator-app second factor: POST api/auth/mfa/setup, POST api/auth/mfa/enable, DELETE api/auth/mfa/disable. Shared by staff and merchant users. |
Section 3
Permission resolution, step by step
Everything the guards will ever know about a user is decided in auth.service.ts when the token is minted. Three rules govern it.
- Scope is derived, not stored on the user. If any of the user's roles has
scope: STAFF, the JWT scope isSTAFF; otherwise it isMERCHANT. A user holding a mix of staff and merchant roles therefore becomes a staff token — and@CurrentMerchant()will then 403 them off merchant-bound routes, so mixed assignments are effectively staff assignments. - System roles are registry mirrors.
StaffAdminandMerchantOwnerresolve toallPermissions(scope)at token time. That is why the seed stores them empty: adding a menu to the registry instantly widens every system-role holder without a migration. - Custom roles are literal. A merchant- or staff-built role contributes exactly its stored
permissions[]strings, thenflattenRolePermissionsmerges all roles and applies the legacy bridge before the set is stamped into the token.
The TEMPORARY legacy bridge — and where it leaks
The registry speaks fine-grained names (sub-merchants.create, merchants.view), but most controllers still guard on an older coarse pair: merchant.read / merchant.write. The bridge in permissions.registry.ts:120-161 translates fine to coarse at token time. It is explicitly marked temporary, and it is incomplete in both directions:
| Fine-grained role grant | Bridged coarse name | Practical effect |
|---|---|---|
merchants.create STAFF | merchant.write | Staff role can invite and mutate merchants — as intended. |
sub-merchants.view MERCHANT | merchant.read | Merchant role can list its sub-merchants — as intended. |
subscriptions.edit MERCHANT | merchant.write | Over-grant. The only merchant-scope route to merchant.write — which also gates sub-merchant invites and split-profile writes. Granting subscription editing silently grants marketplace administration. |
sub-merchants.create / sub-merchants.edit MERCHANT | — no bridge entry — | Dead grant. The named permission exists in the registry and role editor, but maps to nothing the guards check. The holder sees the screens and 403s on the actions. |
Concretely: a marketplace Client builds a custom "Partnerships" role and ticks sub-merchants.create and sub-merchants.edit but not subscriptions.edit. Members of that role open the sub-merchants screen (bridged merchant.read via sub-merchants.view) but every POST /api/clients/:clientId/submerchants/invites and every split write returns 403, because those handlers demand merchant.write — reachable only through the subscriptions permission. The inverse failure is worse: a bookkeeping role granted subscriptions.edit for dunning tweaks can invite sub-merchants and rewrite the Client's split assignments.
ummah-platform-backend/src/modules/auth/auth.service.ts:72-158 · ummah-platform-backend/src/modules/auth/permissions.registry.ts:37-179 (bridge 120-161) · ummah-platform-backend/src/modules/auth/roles.guard.ts:11-50 · ummah-platform-backend/src/modules/submerchant/submerchant.controller.ts:144-285
Section 4
Staff & merchant teams
The same role machinery is administered from two consoles: staff manage staff, merchants manage their own teams — and neither can touch the two seeded system roles.
- Staff management lives at
api/admin/staff(guarded by thestaff.view/create/edit/deletepermissions): create staff users, assign STAFF-scope roles, deactivate. Role CRUD itself isapi/admin/roles. New staff receive set-password onboarding links; the back-office sign-in flow underapp/(auth)shares the platform JWT module rather than shipping its own. - Merchant team management lives at
api/portal/team/members(invite, role-assign) andapi/portal/team/roles(custom MERCHANT-scope roles), guarded by theteam.*permissions. Custom roles are scoped to the merchant viaRole.merchantId. - System roles are immutable. Any attempt to edit or delete
StaffAdminorMerchantOwnerreturns 422 — their meaning is owned by the registry, not by any editor. - MFA is TOTP on both sides:
POST api/auth/mfa/setupissues the secret,POST api/auth/mfa/enableconfirms the first code,DELETE api/auth/mfa/disableremoves it. Merchant owners enrol during onboarding first login; the portal ships dedicatedmfa/mfa-setuppages.
ummah-platform-backend/src/modules/staff/staff.controller.ts:36-80 · ummah-platform-backend/src/modules/roles/roles.controllers.ts:39-169 · ummah-platform-backend/src/modules/team/team.controller.ts:37-90 · ummah-platform-backend/src/modules/auth/auth.controller.ts:49-246 · ummah-platform-backoffice/app/(auth)
Section 5
The console map
The back-office nav is a faithful map of the ops feature set — every screen is a thin client over an api/admin/* module. One group name is verbatim from the sidebar ("Pricing & money", which also gates /fee-schedules behind NEXT_PUBLIC_FEATURE_PRICING); the rest are grouped here by function.
Back-office information architecture
FIG 2 · console map
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
BO["Back-office console
STAFF JWT · api/admin/*"]:::ummah
ENT["Entities
dashboard · merchants/:id · sub-merchants
onboarding-queue · kyc-queue · customers"]:::plain
PAY["Payment ops
transactions/:id · refunds · disputes/:id
subscriptions/:id · payouts · payout-approval · transfers"]:::plain
MON["Pricing & money
split-profiles · fee-profiles · calculator
platform-earnings · fx · fee-schedules behind flag"]:::liable
VIS["Integrity & visibility
reconciliation · reports · events
audit · notifications"]:::plain
PLAT["Platform
staff · settings: defaults · rate-limits
sumsub-levels · profile"]:::plain
BO --> ENT
BO --> PAY
BO --> MON
BO --> VIS
BO --> PLAT
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
fx is a frontend placeholder with no backend module behind it, and fee-schedules is retired-in-place behind a feature flag — both are itemised in Section 11.Most payment-ops screens are admin twins of merchant surfaces — admin-payments, admin-refunds, admin-subscriptions, admin-captures, admin-balances, admin-customers — giving staff the cross-merchant view of the same data each merchant sees for itself. Where flows are documented elsewhere in this series, this page does not repeat them: transactions on Checkout & Payments, disputes on Disputes & Recovery, and the whole pricing console family — split-profiles, fee-profiles (with its fee-charges deduction ledger and per-row Retry), calculator — on Pricings, Splits & Fees.
merchants/:id — the lifecycle cockpit
The merchant detail screen is where staff operate a single merchant end to end. Every control is a small, explicit endpoint on admin-merchants.controller.ts:
| Control | Endpoint | What it does |
|---|---|---|
| Invite / resend | POST api/admin/merchants/invites · POST :merchantId/resend-invite | Creates the invited merchant (split profile mandatory at invite) or re-sends the onboarding code. Full flow on Onboarding & KYC. |
| Suspend / status | POST :id/suspend · PATCH :id/status | Drives MerchantStatus. SUSPENDED blocks the merchant's whole /v1 surface (403 merchant_suspended); INACTIVE allows GET only (403 merchant_inactive on writes) — enforced in ApiKeyGuard. |
| Sumsub tools | POST :merchantId/sumsub/simulate · GET :merchantId/sumsub/events | Sandbox-only KYC outcome simulation, plus the full per-merchant Sumsub webhook history. |
| Adyen tools | POST :merchantId/adyen/retry-provisioning · POST :merchantId/adyen/sign-pci | Re-runs LEM provisioning after a 4xx FAILED; signs the PCI questionnaire. Read-side: GET :id/adyen/verification, GET :merchantId/balance-accounts, GET :id/onboarding-payloads. |
| Notes & audit | notes endpoints · GET api/admin/merchants/:id/audit | Free-text ops notes on the record, plus the merchant-filtered slice of the audit log. |
| Money config | :merchantId/api-keys · /banks · /stores · /sweeps · /reserve · /statements · /balances · /fee-profile | Per-merchant twins of the money surfaces — sweeps and reserves are staff-managed here, merchant-side read-only. |
Onboarding & KYC queues
Two queue screens keep stuck merchants visible: onboarding-queue reads GET api/admin/merchants/onboarding-queue (everyone between invite and ACTIVE, with their current state-machine status), and kyc-queue reads the review queue at api/admin/onboarding/review-queue (merchants awaiting or failing Sumsub review, where the simulate and resend tools earn their keep). The state machine itself — INVITED → OPENED → VERIFIED → IN_PROGRESS → SUMSUB_REVIEWED → ADYEN_PROVISIONING → ACTIVE — is documented on Onboarding & KYC.
ummah-platform-backoffice/app/(app)/ · ummah-platform-backoffice/components/nav/Sidebar.tsx:75-92 · ummah-platform-backend/src/modules/admin/admin-merchants.controller.ts:34-237 · ummah-platform-backend/src/modules/api-keys/api-key.guard.ts:39-84
Section 6
Approval consoles
Two money movements pause for a human: payouts and refunds. Both follow the same shape — a request lands in a queue with a configurable auto-approval cap, ops approve or reject, a worker executes. The caps live in two places: platform defaults (Settings → Defaults) and, for sub-merchants, per-sub caps set by the parent Client (0 or blank = everything needs approval).
Payout approval, end to end
A merchant requests a payout from the portal (POST api/portal/payouts) or API (POST v1/payouts). Above the auto-approval cap it waits in the payout-approval screen; ops decide with POST api/admin/payouts/:id/approve or /reject. Execution belongs to the worker, never the console.
Payout approve → execute → settle
FIG 3 · approval queue
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
participant M as Merchant portal
participant B as Backend
participant O as Ops console
participant W as Payout worker
participant A as Adyen
M->>B: POST api/portal/payouts
B-->>O: queue row + payout.requested email
Note over B,O: requests within the auto-approval cap skip straight to execution
alt approved
O->>B: POST api/admin/payouts/:id/approve
B->>W: enqueue payout job
W->>W: Redis lock payout:ba:{id}:lock NX EX 60
W->>A: bank transfer sized from live balance
A-->>B: balancePlatform.transfer.created / updated
B-->>M: payout.sent / payout.settled webhook + email
else rejected
O->>B: POST api/admin/payouts/:id/reject
B-->>M: request rejected
end
Status advances only on Adyen's balancePlatform.transfer.created/updated webhook legs — the console shows what Adyen confirmed, not what was attempted. Failures emit payout.failed to both email and merchant webhooks. Balance mechanics, sweeps and statements live on Balances & Payouts.
ummah-platform-backend/src/modules/payouts/admin-payouts.controller.ts:48-60 · ummah-platform-worker/src/workers/payout.processor.ts:1-13 · ummah-platform-backend/prisma/schema.prisma:1477 (AdyenTransferLeg)
Refund approval
Refunds requested via POST /v1/refunds, the portal, or the admin surface can pause at RefundStatus PENDING_OPS — emitting the refund.pending_ops merchant webhook and the refund.pending_approval ops email — until staff action in the back-office refunds queue releases them to Adyen. Terminal state then follows the REFUND / REFUND_FAILED / REFUNDED_REVERSED webhooks. Who bears the fee is configuration, not code path: RefundFeeBearer (merchant vs platform) and ClientRefundFeeBearer (sub vs parent Client), with the Client setting per-sub refund caps at PUT api/portal/refunds/submerchants/:subId/cap.
The full refund lifecycle, fee-bearer economics and failure modes are on Refunds & Approvals — this console is its staff-side gate.
ummah-platform-backend/src/modules/refunds/refunds.controller.ts:17-37 · ummah-platform-backend/prisma/schema.prisma:185 (RefundStatus), 197-206 (fee-bearer enums)
Section 7
Money oversight
Three screens watch the platform's own money and the integrity of everyone else's.
Platform earnings — the liable-account view
platform-earnings reads GET api/admin/platform-earnings (add ?refresh=1 or use the "Refresh from Adyen" button to bypass cache): per-currency balance cards for Ummah's liable balance account plus its movement list, filterable by category — platformPayment ("Commission legs", the split commission booked at authorisation), internal (fee-charge transfers), and bank. Direction and status filters are applied server-side per page because Adyen accepts but ignores both query parameters (verified 2026-08-11). The screen is strictly read-only — no pricing inputs live here.
Transfers
The transfers screen is the raw movement ledger — the platform-wide view over Adyen transfer activity recorded from balancePlatform.transfer.created/updated webhooks (AdyenTransferLeg rows), sitting alongside the payout screens for tracing where a specific leg went.
Reconciliation panel
The reconciliation screen reads GET api/admin/reconciliation/summary, /mismatches, /awaiting, /runs and /runs/latest, and can trigger a manual sweep with POST api/admin/reconciliation/run — the same queue as the nightly 02:00 UTC cron ('0 2 * * *'). Each run checks four invariants: splits mismatched (Adyen booked ≠ recorded), split legs stale beyond 24 h, payments stuck 3 days, recoveries stuck 24 h — and records a ReconciliationRun with ReconStatus; an Adyen outage yields DEGRADED plus the reconciliation.alert ops email. The checks themselves are documented on Reconciliation & Integrity.
ummah-platform-backend/src/modules/balances/admin-platform-earnings.controller.ts · ummah-platform-backoffice/app/(app)/platform-earnings/page.tsx:14-45 · ummah-platform-backend/src/modules/reconciliation/reconciliation.controller.ts:21-58 · ummah-platform-worker/src/workers/reconciliation.processor.ts:1-16
Section 8
Visibility surfaces
Four screens answer "what happened?" at different altitudes: reports aggregate, events show the wire, audit shows the humans, notifications show the emails.
reports
A data-driven report registry
Every report is a row in report-registry.ts — title, columns, query, scope — thirteen of them, executed by a single runner that serialises CSV, JSON or PDF. Adding a report is adding a registry entry, not a controller. Scoping is the interesting part: a registry row with merchantId set is merchant-scoped (visible in the portal, the /v1 API and the admin's per-merchant view); merchantId: null marks it platform-wide — back-office only, e.g. the platform P&L. Each report filters on its natural timestamp range.
events
The wire, both directions
api/admin/events backs a three-tab browser: inbound (persisted AdyenWebhookEvent / SumsubWebhookEvent rows, exactly as the ingress service stored them), outbound (merchant webhook deliveries) and endpoints (merchant endpoint health). It is the first stop for "did Adyen tell us?" — the delivery pipeline itself is on Events & Notifications.
audit
Immutable audit trail
api/admin/audit browses the append-only AuditLog — who acted, what they touched, with before/after detail per entry — and every merchant detail screen carries its own filtered slice at GET api/admin/merchants/:id/audit. Privileged mutations write rows as they happen (e.g. submerchant.invited); RequestLog keeps the raw request telemetry beside it.
notifications
The email ledger
The notifications screen (backed by admin-notifications.controller.ts) browses Notification rows — the fifteen template types from onboarding.succeeded to reconciliation.alert, each row being both the send job and its audit record, with SENT/FAILED status after the SQS-driven consumer runs. Pipeline details on Events & Notifications.
ummah-platform-backend/src/modules/reports/report-registry.ts · ummah-platform-backend/src/modules/reports/reports.controllers.ts · ummah-platform-backend/src/modules/events/events.controller.ts:24-160 · ummah-platform-backend/src/modules/audit-log/audit-log.controller.ts · ummah-platform-backend/src/core/notifications/admin-notifications.controller.ts · ummah-platform-backend/prisma/schema.prisma:816, 847, 2168
Section 9
Platform settings
Settings is four screens over two backend modules — and it holds the platform's biggest lever: a key/value store that can switch off API endpoints without a deploy.
- Defaults (
settings/defaults) is a mostly read-only view of platform configuration:pricingModel, refund and payout auto-approval caps, the MIT cap, webhook timeouts, rate limits and feature-flag pills. Exactly one field is editable in place:paymentLinkExpiryDays(1–365). - Rate limits (
settings/rate-limits) configures the platform rate limiter viaapi/admin/rate-limits, backed by the guard incommon/guards— the throttle-side companion to the kill-switch below. - Sumsub levels (
settings/sumsub-levels) manages which Sumsub verification levels invites may use — the same catalogue the invite dialogs validate against. - PlatformSetting KV (
api/admin/settings) is the generic store behind it all. Its sharpest key isapi.disabled_endpoints: any/v1endpoint can be switched off without a deploy, andApiExposureGuardhonours the change live within 30 seconds.GET api/admin/system/inforounds out the ops picture with platform build/runtime info.
ummah-platform-backend/src/modules/settings/admin-settings.controller.ts:28-35 · ummah-platform-backend/src/modules/settings/admin-rate-limits.controller.ts · ummah-platform-backoffice/app/(app)/settings/defaults/page.tsx:19-86 · ummah-platform-backend/prisma/schema.prisma:1139 (PlatformSetting) · ummah-platform-backend/src/modules/api-catalog/api-exposure.guard.ts
Section 10
Implementation notes
The endpoints this page leans on, with their guards. All api/admin/* routes sit behind JwtAuthGuard + RolesGuard with @RequireScope('STAFF'); portal routes demand MERCHANT scope. Named permissions are shown where the code declares them.
| Endpoint | Guard / permission | Purpose |
|---|---|---|
POST api/auth/login · refresh · me · change-password | public / JWT | Session lifecycle shared by both consoles; magic-link, forgot- and reset-password beside them. |
POST api/auth/mfa/setup · POST mfa/enable · DELETE mfa/disable | JWT | TOTP enrolment and removal. |
api/admin/staff | STAFF · staff.view/create/edit/delete | Staff user CRUD and role assignment. |
api/admin/roles | STAFF | STAFF-scope role CRUD; system roles return 422 on mutation. |
api/portal/team/members · api/portal/team/roles | MERCHANT · team.* | Merchant team invites and merchant-scoped custom roles. |
POST api/admin/merchants/invites · POST :merchantId/resend-invite | STAFF · merchant.write | Merchant invitation lifecycle. |
POST :id/suspend · PATCH :id/status | STAFF · merchant.write | Lifecycle control; drives the /v1 gate in ApiKeyGuard. |
POST :merchantId/sumsub/simulate · GET :merchantId/sumsub/events | STAFF | Sandbox KYC simulation; Sumsub event history. |
POST :merchantId/adyen/retry-provisioning · POST :merchantId/adyen/sign-pci | STAFF | Provisioning retry after FAILED; PCI questionnaire signing. |
GET api/admin/merchants/onboarding-queue · api/admin/onboarding/review-queue | STAFF | The two queue screens' read models. |
POST api/admin/payouts/:id/approve · /reject | STAFF | Payout approval console actions; execution in the payout worker. |
api/admin/refunds surface | STAFF | Refund queue twin releasing PENDING_OPS refunds. |
GET api/admin/platform-earnings | STAFF | Liable-account balances and movements; ?refresh=1 bypasses cache. |
api/admin/reconciliation/summary·mismatches·awaiting·runs·runs/latest · POST /run | STAFF | Reconciliation panel reads and manual sweep trigger. |
reports.controllers.ts (portal · admin · v1) | scope per surface | Registry-driven report runner, CSV/JSON/PDF. |
api/admin/events | STAFF | Inbound / outbound / endpoints event browser. |
api/admin/audit · GET api/admin/merchants/:id/audit | STAFF | Platform-wide and per-merchant audit browsing. |
api/admin/settings · api/admin/rate-limits · GET api/admin/system/info | STAFF | PlatformSetting KV (incl. api.disabled_endpoints), rate-limit config, system info. |
Data-model notes: the whole permission system is three tables (User, UserRole, Role) plus a code-side registry — there is no Permission table, so permission renames are code changes and the registry is the migration. Role.merchantId scopes merchant custom roles; Role.isSystem plus the 422 guard keeps the two seeded roles stable for the allPermissions expansion to lean on.
Section 11
Gaps & recommendations
What the code verifies as missing or misleading, ranked by how much it can hurt.
Retire the legacy permission bridge
sub-merchants.create / sub-merchants.edit bridge to nothing, while subscriptions.edit is the only merchant-scope route to merchant.write — so custom roles both under-deliver named grants and over-grant marketplace administration through an unrelated checkbox. Fix: move controllers to fine-grained @RequirePermissions checks (e.g. 'sub-merchants.create' on the invite handlers), then delete the bridge from permissions.registry.ts:120-161.
Make :clientId self-binding a shared guard, not a convention
ClientSubMerchantController.assertSelf fixed the historical trusted-param hole, but it is re-implemented per handler — a new :clientId route that forgets the call reopens the IDOR. Fix: a shared guard/decorator that binds path merchant params to the bearer's merchantId for all MERCHANT-scope routes.
Label the retired fee-schedule machinery
/fee-schedules and the PricingEditor survive behind NEXT_PUBLIC_FEATURE_PRICING (backend 404s without FEATURE_PRICING) and still offer events the live split mechanism never charges — PAYOUT, SUBSCRIPTION, MIN_MONTHLY, FX_MARKUP. An auditor or new operator can mistake it for live pricing. Fix: an explicit "retired — superseded by split profiles" banner in the screen and nav, per the generational story on Pricings, Splits & Fees.
Hide or badge the fx placeholder
The back-office ships an fx screen in the "Pricing & money" nav group, but no FX module exists anywhere in the backend module list — the screen has nothing real to operate. Fix: put it behind a feature flag or a Coming-soon badge until a backend module exists, so the nav stays a truthful feature map.