admin-api¶
Purpose¶
admin-api is the backend-for-frontend (BFF) for the internal admin SPA. It holds the platform JWT server-side, owns email+password sign-in for admin operators (with optional MFA via TOTP and/or WebAuthn passkeys), fans out read-only dashboard data from clinical-api / ai-review / human-review, and records every admin action to its own AdminAuditLog table.
The browser never sees the platform JWT. All SPA → BFF traffic is same-origin under /api/*, authenticated by an httpOnly admin_session cookie that maps to a Redis-backed session record. The session record holds the JWT; the BFF attaches it as Bearer on the outbound fan-out calls.
Sign-in flow¶
- Password step — SPA POSTs
/api/auth/loginwith{ email, password }. If the user has no enrolled MFA factor and the platformrequireMfaflag is off, the server creates the session, sets the cookie, and returns{ user }. Done. - MFA step (if enrolled) — when the user has any enrolled factor, the password step instead returns
{ mfaToken, factors }(no session yet). The SPA then completes one of: POST /api/auth/mfa/totpwith{ mfaToken, code }— verifies a 6-digit code from the user's authenticator app.POST /api/auth/mfa/passkey/options→POST /api/auth/mfa/passkey/verify— WebAuthn challenge/assertion pair (Touch ID / Face ID / Windows Hello / hardware key).- Platform enforcement — when
requireMfaistrue, a user with no enrolled factor is refused at step 1 with 403mfa_required_but_not_enrolled. Already-enrolled users are unaffected.
mfaToken is single-use, 5-minute-TTL, in-memory per process. Multi-replica prod deployments will need to lift it into Redis (see services/admin-api/src/mfa/mfa-token.service.ts).
Key endpoints¶
POST /api/auth/login — email+password step (see above)
POST /api/auth/mfa/totp — TOTP step (consumes mfaToken)
POST /api/auth/mfa/passkey/options — WebAuthn assertion challenge for the user behind mfaToken
POST /api/auth/mfa/passkey/verify — WebAuthn assertion verification (consumes mfaToken)
POST /api/auth/logout — destroys the Redis session, clears the cookie, 204
POST /api/auth/change-password — change the signed-in user's password (current + new)
GET /api/me — returns the session-bound identity (id, email, displayName, role)
MFA enrolment (session-gated)¶
GET /api/me/mfa — returns { totp: { enrolled, confirmedAt }, passkeys[], hasAtLeastOneFactor }
POST /api/me/mfa/totp/setup — mint a pending TOTP secret; returns { secret, otpauthUrl, qrDataUrl }
POST /api/me/mfa/totp/confirm — { code } confirms the first code and flips confirmedAt
DELETE /api/me/mfa/totp — disable TOTP for the signed-in user
POST /api/me/mfa/passkey/register/options — WebAuthn registration challenge
POST /api/me/mfa/passkey/register/verify — { response, deviceName? } stores the credential
DELETE /api/me/mfa/passkey/:id — remove an enrolled passkey
GET /api/admin/mfa-settings — returns the singleton { requireMfa, updatedAt, updatedBy }
PATCH /api/admin/mfa-settings — { requireMfa } (admin role only)
Dashboard¶
GET /api/dashboard/health — admin-api + per-backing-service reachability + queue depths
GET /api/dashboard/volume?range=today|7d|30d&org= — case volume metrics from clinical-api. Response (under data):
cases: { today, thisWeek, thisMonth, inRange }—inRangefollows the dropdown.casesPerDay: Array<{ date, total, byOrg: Record<orgId, count> }>— sized to the selected range (1 / 7 / 30 days). Each bucket carries per-org counts for the stacked dashboard chart.casesPerDayOrgs: Array<{ orgId, name }>— orgs that appear in anycasesPerDaybucket.perOrg,perProduct— top-N breakdowns within the selected range.aiInferences: { total, breakdown: Array<{ result, count }> }— sourced fromclinical-api.DermReview.resultJson(i.e. count of completed DERM calls in range and a group-by oflesions[].suspectedDiagnosis). Not from theDiagnosisprojection.aiFailures: { total, recent: Array<{ id, caseId, failedAt, reason, kind: 'http' | 'derm' }> }—kind='http'coversDermReview.status='failed'(network / pre-flight / non-2xx);kind='derm'covers 2xx bodies whereanalysisResult !== PROCESSING_ANSWER_TYPE_SUCCESSFUL_PROCESSING(e.g. "Case suitability checks failed").
GET /api/dashboard/ai-review — AI inference throughput + queue depth from the ai-review service (separate from aiInferences above; this is the live worker view).
GET /api/dashboard/human-review — reviewer queue + decision latency.
GET /api/dashboard/orgs/:orgId — per-org drill-down (parallel fan-out across all three sources).
POST /internal/admin-users/resolve — service-to-service only (InternalTokenGuard); called by services/auth during Google OIDC callback to resolve / create the AdminUser row
GET /api/platform-users?org=&type=&status=&search=&limit=&offset= — paginated list of platform users (practitioners + patients + generic 'user' types). org required. type accepts clinician | patient | user.
GET /api/platform-users/:id — fetch one platform user with memberships + practitioner profile decorated
POST /api/platform-users — create a platform user (calls user-management which provisions a Cognito user). user_type accepts clinician | patient | user; user records carry no profile and get capabilities solely via role membership.
PATCH /api/platform-users/:id — update display name + practitioner credentials (clinicians only)
DELETE /api/platform-users/:id — deactivate (cascades to Cognito disableUser)
POST /api/platform-users/:id/reactivate — reactivate
POST /api/platform-users/:id/reset-password — trigger Cognito AdminResetUserPasswordCommand
POST /api/platform-users/:id/memberships — add the user to an organisation (body: organisation_id, role_id)
PATCH /api/platform-users/:id/memberships/:membershipId — change role within an org (body: role_id)
DELETE /api/platform-users/:id/memberships/:membershipId — remove the user from an org (400 if it would leave them with zero memberships)
GET /api/platform-users/clinical-patients?org=&search=&limit=&offset= — paginated list of clinical-api Patient rows for the org, decrypted server-side. Powers the Users page's "Clinical patients" section. Search is filtered client-side because the encrypted columns aren't deterministically searchable.
POST /api/platform-users/promote-clinical-patient — body { clinical_patient_id, organisation_id, email, display_name, role_id }. Creates a user_type='patient' platform user with a patientAccount row linking back to the clinical record. Emits platform_user.promoted_from_patient audit event.
GET /api/patient-merge-candidates?org=:orgId — list pending PatientMergeCandidate rows with provisional + ranked candidate PII decrypted. Powers the /users/duplicates review page.
POST /api/patient-merge-candidates/:id/merge?org=:orgId — body { surviving_patient_id, reason? }. Absorbs the provisional patient into the chosen surviving one, moves identifiers (re-encrypting with the surviving DEK), writes a PatientMergeHistory snapshot with the actor + 12-month retention window.
POST /api/patient-merge-candidates/:id/dismiss?org=:orgId — body { reason? }. Marks "not a duplicate", promotes the provisional patient to active status, sets the candidate row to status='dismissed'.
GET /api/roles?org=:orgId — list roles, optionally scoped to one org. With no org query, returns every role across every org (the Roles landing page's default cross-org view).
GET /api/roles/:id — fetch role detail (includes member_count + permissions)
POST /api/roles — create a role (admin-only)
PATCH /api/roles/:id — update a role (admin-only)
DELETE /api/roles/:id — delete a role; 409 if assigned to any user
GET /api/permissions — list registered permissions (admin-only; user-management seeds ~25 baseline permissions on boot — see services/user-management for the catalogue)
GET /api/products?org=:orgId&include_deleted=false — list products for an org (admin-only)
GET /api/products/:id — fetch product detail (includes grant_count)
POST /api/products — create a product (admin-only)
PATCH /api/products/:id — update a product (admin-only; code is immutable)
DELETE /api/products/:id — soft-delete a product (admin-only; idempotent)
POST /api/platform-users/:id/memberships/:membershipId/products — grant product access (admin-only)
DELETE /api/platform-users/:id/memberships/:membershipId/products/:productId — revoke product access (admin-only)
GET /api/audit-log?actor=&action=&target=&from=&to=&limit=&offset= — paginated audit-log list (admin-only)
GET /api/audit-log/actors — distinct actors who have logged actions (powers the actor dropdown)
GET /api/audit-log/actions — distinct action names (powers the action dropdown)
GET /health — liveness probe
Database tables¶
AdminUser — source-of-truth row for who is allowed to sign in (email-keyed, role: admin|support, status: active|pending|disabled, lastLoginAt).
AdminAuditLog — append-only audit row per admin action. Fields: actorId, action, target (org id, user id, or ALL), metadata, at. Action names include auth.login, auth.logout, dashboard.<path>.read, org.created/updated/archived/restored, admin_user.invited/updated/disabled/enabled/token_regenerated/activated, platform_user.created/updated/deactivated/reactivated/password_reset (Phase 3b), platform_user.membership_added/membership_role_changed/membership_removed (Phase 3d), role.created/updated/deleted (Phase 3e-roles), product.created/updated/deleted plus platform_user.product_granted/product_revoked (Phase 3e-products), and platform_user.promoted_from_patient (patient → platform user promotion).
AdminUserTotp — 1:1 with AdminUser. Holds the base32 TOTP secret + confirmedAt. Secret stays unusable for sign-in until the user confirms a first code (confirmedAt != null).
AdminUserPasskey — 1:N with AdminUser. One row per WebAuthn credential: credentialId (unique), base64url public key, signature counter, optional transports + device label, lastUsedAt.
MfaSettings — singleton row keyed by id="singleton". Holds the platform-wide requireMfa flag plus updatedAt / updatedBy for audit. Seeded with requireMfa = false so the system stays in its pre-MFA shape until an admin opts in.
Caching¶
Each successful dashboard aggregation is cached in Redis under admin-dashboard:<path>:<org|all>:<range> with TTL DASHBOARD_CACHE_TTL_SECONDS (default 30s). The cache is shared across users in the same scope — the data isn't user-specific.
The getOrgDetail endpoint uses Promise.allSettled so a single failing upstream contributes null + its name to degradedFor and partial=true rather than failing the whole request.
Events¶
None emitted, none consumed. The dashboard reads via HTTP, not the event bus.
Dependencies¶
services/auth— issues platform JWTs after Google OIDC succeeds.services/clinical-api,services/ai-review,services/human-review— each exposes anadmin/statsendpoint under its own service prefix (/v1/clinical-api/admin/stats,/v1/ai-review/admin/stats,/v1/human-review/admin/stats) for dashboard aggregation.- Redis — session store + dashboard fan-out cache.
- MySQL — admin-api owns its own database with
AdminUser+AdminAuditLog.
Where to learn more¶
- Design spec
- Implementation plan
- Source:
services/admin-api/(in this repo)