Project

Functional specifications

Functional specifications — stack, database schema, authentication flows, OAuth 2.1 / OIDC, organizations, billing, error codes.

Self-hosted OAuth 2.1 / OIDC Identity Provider built on BetterAuth v1.7+.
Single source of truth for users, applications, subscriptions, roles, and MFA.
All downstream projects authenticate against this service via standard OAuth 2.1 Authorization Code + PKCE flow.

Plugin decision: uses oauthProvider from @better-auth/oauth-provider (separate package, OAuth 2.1 + OIDC compatible, successor to the built-in oidcProvider which is deprecated).


1. Technology Stack

LayerChoiceRationale
RuntimeNode.js 22 (ESM)Modern JavaScript runtime; ESM-first
FrameworkFastify 5High-throughput, low-overhead HTTP framework
Auth frameworkbetter-auth v1.7+Plugin-first, BetterAuth base package
OAuth 2.1 / OIDC@better-auth/oauth-provider — oauthProvider()Separate package, successor to deprecated built-in oidcProvider
JWT signingjwt() plugin from better-auth/pluginsRequired by oauthProvider for asymmetric token signing
Passkey / YubiKey@better-auth/passkeyFIDO2/WebAuthn, YubiKey-compatible
TOTP (Auth app)twoFactor() from better-auth/pluginsGoogle Authenticator, Authy, Bitwarden
Admin / global rolesadmin() from better-auth/pluginsRole management (superadmin/admin/user)
DB ORMDrizzle ORM + PostgreSQLType-safe schema, first-class migrations
Frontend (bundled)Vue 3 + Vite + Tailwind v4SPA embedded in the service
TestingVitest + SupertestUnit tests + HTTP integration tests
ContainerisationDocker (Node Alpine multi-stage)Build frontend → compile TS → Alpine runtime

2. Core Concepts

2.1 Users

A user has a global role in the auth-service:

RoleDescription
superadminFull access. Created automatically at bootstrap. The last superadmin cannot be deleted or demoted.
adminManages users and applications.
userAccess to explicitly authorized applications only.

A user has a single global role, and may hold additional roles per application (see section 2.4).

2.2 Applications

An application is a client project consuming auth-service as its OAuth 2.1 / OIDC IdP.

Fields (see applications in src/db/schema.ts; the OAuth client itself lives in oauth_client):

  • id — UUID
  • name — Display name (e.g. "Admin Console")
  • slug — URL-safe identifier (e.g. admin-console), unique
  • description — Optional description
  • redirectUris — Allowed callback URLs (PKCE flow)
  • isActive — Global enable/disable
  • allowedScopes — OIDC scopes allowed for the application; the native scopes are openid, profile, email, phone, offline_access, roles, permissions, features, wallet:self:read and org (src/compat/scopes.ts)
  • skipConsent — Skip consent screen for trusted first-party apps
  • isMfaRequired, allowRegister, billingMode (none | delegated), isPublic, isSystem, url, allowedResources, icon, enabledSocialProviders, metadata
  • createdAt, updatedAt

The OAuth clientId is the application slug. The client secret is stored hashed on the oauth_client row and is shown once at creation and on rotation.

2.3 User ↔ Application Access

A user can only access an application if explicitly authorized (admins and superadmins bypass this check and have access to all apps).

Fields of the userApplication relation:

  • userId, applicationId
  • isActive — Can suspend access without deleting the relation
  • subscriptionPlanId — References the active plan for this user in this app (optional; null = free/no plan)
  • createdAt

2.4 RBAC — Per-Application Roles and Permissions

Each application defines its own roles, each role holding a set of permissions in dot notation.

Permission Model

Permissions are represented as resource.action or just resource:

tenants          → read access to the tenants namespace
tenants.write    → write access to the tenants namespace
endpoints        → read
endpoints.write  → write
tools            → read
admin.users      → admin read on users
admin.users.write

Available actions: read (default when not specified) and write.

Structure

AppRole
  id, applicationId, name (e.g. "viewer", "operator", "admin")

AppPermission
  id, applicationId, resource (dot-notation string), action ("read"|"write")

AppRolePermission (join table)
  roleId, permissionId

UserAppRole (user's role within an application)
  userId, applicationId, roleId

A user can have one or more roles per application (permissions are the union of all assigned roles).

OIDC Token Claims Injection

When a user authenticates via the OAuth 2.1 flow (scopes roles, permissions, or features requested), the following custom claims are injected via the customIdTokenClaims and customUserInfoClaims hooks of oauthProvider:

{
  "sub": "uuid-user",
  "email": "user@example.com",
  "name": "Alice",
  "roles": ["operator"],
  "permissions": ["tenants", "tenants.write", "endpoints", "tools"],
  "features": {
    "maxAgents": 5,
    "analytics": true,
    "exportCsv": false,
    "apiCallsPerMonth": 10000
  }
}

These claims are populated by querying the userAppRoles, appRolePermissions, and userSubscriptions tables for the OAuth client's matching application.

2.5 Subscriptions & Features

A subscription plan defines a named set of features for an application. Features are stored as an arbitrary JSON object — the schema is defined per-application.

SubscriptionPlan
  id, applicationId
  name (e.g. "free", "pro", "enterprise")
  description: text | null
  stripeProductId: text | null   // provisioned by auth-service only
  features: jsonb        // { maxAgents: 5, analytics: true, exportCsv: false } — plain values only
  isDefault: boolean     // auto-assigned to new users if true
  createdAt, updatedAt

UserSubscription (active subscription per user per app — also tracked in userApplication.subscriptionPlanId)
  id
  userId, applicationId, planId
  expiresAt: timestamp | null    // null = lifetime
  isActive: boolean
  createdAt, updatedAt

Plans are also linked to paid and metered prices (subscription_plan_prices, see the Stripe billing programme plan); metered features are not stored in features.

Features Claim in Tokens

When the scope features is requested, the features JSON (SubscriptionPlan.features) of the user's active plan is included in the id_token and /oauth2/userinfo response. This allows client apps to configure access gates without a round-trip to the auth-service after login.

If the user has no subscription, features is an empty object {}.

2.6 Consumption Tracking

Usage-based consumption events (e.g. API calls, storage used, messages sent) are recorded per user per application. This enables server-side enforcement of plan limits.

ConsumptionEntry
  id
  userId, applicationId
  key: string            // e.g. "apiCalls", "storageMb", "messagesSent"
  value: number          // delta (positive = consume, negative = credit)
  recordedAt: timestamp

ConsumptionAggregate (materialized per userId + applicationId + key, refreshed on write)
  userId, applicationId, key
  total: number
  updatedAt

The consumption endpoint accepts an OAuth 2.1 client_credentials bearer token (bound to a single application through azp) or an admin/superadmin session; user-bound tokens are refused (CONS_004).


3. Authentication — Supported Flows

3.1 Main Flow: Authorization Code + PKCE

Used by all client frontends (Vue.js SPAs):

1. Frontend → GET /oauth2/authorize?client_id=...&redirect_uri=...&code_challenge=...&scope=openid profile email roles permissions features
2. auth-service → redirect to /login (auth-service embedded UI)
3. User: email + password (+ MFA if enabled)
4. auth-service → redirect callback to redirect_uri?code=...
5. Frontend → POST /oauth2/token { code, code_verifier }
6. auth-service → { access_token, id_token, refresh_token }
   id_token payload includes: sub, email, name, roles, permissions, features
7. Frontend → GET /oauth2/userinfo (Bearer access_token) → full profile + roles + permissions + features

The features claim is only populated when the scope features is included in the authorization request.

3.2 Email + Password

  • Registration: email, name, password (min 8 chars)
  • Sign-in: email + password
  • Email verification (optional, configurable)
  • Password reset via email

3.3 Refresh Token

  • Silent renew via refresh token grant (offline_access scope)
  • Refresh tokens are hashed in the database (oauth_refresh_token table managed by oauthProvider)

4. MFA (Multi-Factor Authentication)

4.1 Principle

  • MFA enabled optionally by the user from their profile
  • Once enabled, applies to all applications the user has access to
  • Admins can force MFA per user (user.is_mfa_required) or per application (applications.is_mfa_required)
  • A user with a forced flag who has not yet enabled a second factor can still sign in to the auth-service UI to complete the setup, but OAuth/OIDC token issuance for downstream apps requiring MFA is blocked until setup is complete (see section 4.5)

4.2 TOTP — Authenticator App (implemented)

Plugin: twoFactor({ issuer: config.appName }) from better-auth/plugins

  • Compatible with Google Authenticator, 1Password, Authy, Bitwarden, YubiKey OATH-TOTP slots (via Yubico Authenticator / YubiKey Manager)
  • Activation: QR code rendered locally with the qrcode library from the otpauth:// URI; first code verification before the secret is committed
  • 10 backup codes generated at activation, displayed once, downloadable as a .txt file; the user must explicitly confirm they have saved them
  • Backup codes can be regenerated at any time from the profile (invalidates the previous batch)
  • Can be disabled from the user profile after re-confirming the password

4.3 Passkey / YubiKey FIDO2 (server plugin enabled, no UI)

Plugin: @better-auth/passkey (registered unconditionally in src/auth.ts)

  • Protocol: FIDO2 / WebAuthn (compatible with all YubiKey 5+, third-party FIDO2 keys)
  • Status: the server plugin and its passkey table exist, but no SPA screen registers or uses passkeys. The Profile UI shows a "Coming soon" badge (frontend/src/views/ProfileView.vue).
  • BetterAuth's passkey plugin currently only supports passkeys as the primary authenticator (passwordless). To use a FIDO2 hardware key as a true second factor today, configure it as an OATH-TOTP slot and register it via TOTP (section 4.2).

4.4 MFA Flow during Login (auth-service UI)

POST /api/auth/sign-in/email
  → If TOTP enabled: response { twoFactorRedirect: true, twoFactorMethods: ['totp'] }
     and a temporary 2FA cookie is set
  → Frontend keeps the user on /login and swaps to the MfaChallengeForm
  → User enters the 6-digit code (or toggles "Use a backup code")
  → POST /api/auth/two-factor/verify-totp  OR  /api/auth/two-factor/verify-backup-code
  → Real session cookie issued → OAuth 2.1 flow resumes via `redirectTo`

4.5 MFA enforcement at OAuth token issuance

Per-app and per-user MFA flags are combined in src/services/mfa.ts:

userMustSetupMfa(user, appRequiresMfa) === !user.twoFactorEnabled
                                          && (appRequiresMfa || user.isMfaRequired)

When this returns true at token issuance, the shared authorization guard used by customIdTokenClaims and customAccessTokenClaims (src/auth.ts) throws a BetterAuth APIError("FORBIDDEN") with the message "MFA is required for this account. Please enable two-factor authentication in your profile before continuing." The service does not emit the AUTH_004 registry code on this path.

4.6 Endpoints (BetterAuth-provided)

EndpointMethodPurpose
/api/auth/two-factor/enablePOSTReturns { totpURI, backupCodes } (requires password)
/api/auth/two-factor/disablePOSTDisables TOTP (requires password)
/api/auth/two-factor/verify-totpPOSTConsumes the 2FA cookie and issues the session
/api/auth/two-factor/verify-backup-codePOSTSame, with a single-use backup code
/api/auth/two-factor/generate-backup-codesPOSTRotates the backup codes (requires password)

4.7 Frontend components

  • frontend/src/components/profile/MfaSetupModal.vue — 4-step wizard (password → QR scan → verify → backup codes)
  • frontend/src/components/profile/MfaDisableModal.vue — destructive flow with password confirmation
  • frontend/src/components/profile/MfaBackupCodesModal.vue — backup code regeneration
  • frontend/src/components/auth/MfaChallengeForm.vue — login-time challenge with TOTP / backup-code toggle
  • All strings live under the mfa.* namespace in frontend/src/i18n/{en,fr}.ts

5. Superadmin Bootstrap

At service startup (bootstrap() in src/bootstrap.ts), after ensuring the auth-service's own application exists, if no superadmin user exists in the DB:

  1. Read ADMIN_EMAIL and ADMIN_PASSWORD from environment variables (ADMIN_PASSWORD must be at least 12 characters, src/config.ts)
  2. In production, refuse a known default/placeholder password (error logged, superadmin not created)
  3. Create the user with role: "superadmin" via auth.api.createUser(), with a verified email
  4. Log a confirmation message (password never logged)
  5. The superadmin has access to all applications even if absent from userApplication

If an existing superadmin is found, the bootstrap only makes sure their email is marked verified.

If the variables are not set → warning in logs, normal startup. The superadmin role is never assignable through the admin API (src/routes/admin/users.ts), so it is only provisioned by this bootstrap.


6. User Interface (Embedded Vue.js SPA)

6.1 Public Pages

RouteComponentDescription
/LandingViewPublic showcase when SHOWCASE=true; otherwise redirects to /profile (and so to /auth when signed out)
/authLoginViewAdministration sign-in: email + password, link to forgot password
/loginLoginViewEntry of OAuth authorization requests and device approval (signed query parameters); without any, redirects to / (SHOWCASE=true) or /auth
/forgot-passwordForgotPasswordViewPassword reset request by email (generic answer, no enumeration)
/reset-passwordResetPasswordViewNew password; token from the link below (path form also accepted)
/oauth2/consentConsentViewOIDC authorization screen (when skipConsent: false)
/deviceDeviceViewRFC 8628 device authorization approval (opt-in)
/forbiddenForbiddenView403 page
/email-verifiedEmailVerifiedViewPost-verification landing page (public, opens no session)
/accept-invitation, /complete-registrationServerPageRedirectViewForce a full page load so the server-rendered invitation pages are served

Password-reset link shapes (measured from BetterAuth's requestPasswordReset): the e-mail carries <baseURL>/api/auth/reset-password/<token>?callbackURL=<redirectTo>; that callback validates the token and redirects to <redirectTo>?token=<token> (or ?error=INVALID_TOKEN). The SPA asks for redirectTo = <origin>/reset-password, so the token normally arrives in the query string; /reset-password/:token is registered as well for direct links.

Not yet implemented (documented target, no component in the tree): /register (RegisterView), /verify-mfa (MfaVerifyView). Registration and MFA verification are currently served by the server-rendered templates in templates/default/, not by the SPA.

The authoritative list of registered SPA routes is frontend/src/router/index.ts.

6.2 Protected Pages — User Space

RouteComponentDescription
/profileProfileViewTabs: Account, Security (MFA), Organizations, Applications and Credit (wallet); also lists the user's sessions and billing

Profile sub-routes (/profile/mfa, /profile/sessions, /profile/subscription) do not exist: the same content is organised in tabs of the single /profile route. /oauth2/consent and /device are also protected by a session.

6.3 Protected Pages — Admin / Superadmin Space

Admin routes (requiresAdmin) are served at the top level of the SPA, not under an /admin prefix. Applications are created and edited in a modal, not on a dedicated route; roles, permissions, users, plans and consumption are tabs/panels of the application detail view.

RouteComponentDescription
/dashboardDashboardViewActivity charts, online counter, live admin events
/usersUsersViewPaginated list + search, quick actions
/users/:idUserDetailViewProfile, global role, app access, per-app roles, force MFA
/organizationsOrganizationsViewOrganizations, members and invitations
/applicationsApplicationsViewList of registered OAuth apps
/applications/:idApplicationDetailViewEdit, clientId/secret, roles/permissions, users, plans, consumption
/walletsWalletsViewCredit wallets, pricing and usage ledger
/accountingAccountingViewAccounting journal
/configurationConfigurationViewInstance configuration view

6.4 Design System

The UI is built with Tailwind CSS v4 (frontend/src/style.css): a zinc surface-* palette, a blue primary-* palette, Inter as sans-serif and JetBrains Mono as monospace, loaded from Google Fonts in frontend/index.html. Icons come from lucide-vue-next.

Theme

Dark and light themes share the same surface-* token names: :root defines the light palette by inverting the scale, and the dark class on <html> (:root.dark, @custom-variant dark) restores the dark one. The toggle (frontend/src/composables/useTheme.ts) stores "dark" or "light" under the theme key of localStorage; without a stored value, the prefers-color-scheme media query decides.

The authoritative design tokens are in frontend/src/style.css.


7. REST API (beyond BetterAuth endpoints)

The following endpoints complement the standard BetterAuth routes (/api/auth/*). This section is not exhaustive (user self-service /api/user/*, wallet, application billing /api/app/billing/*, registration and invitation routes, organizations, statistics and others are listed in the API reference); the authoritative list is the route registration in src/server.ts.

7.1 Applications (admin)

GET    /api/admin/applications                     List all apps
POST   /api/admin/applications                     Create an app
GET    /api/admin/applications/:id                 Detail
PATCH  /api/admin/applications/:id                 Update (any subset of the create fields except slug and isPublic, e.g. name, redirectUris, isActive, skipConsent, billingMode)
DELETE /api/admin/applications/:id                 Delete
POST   /api/admin/applications/:id/rotate-secret   Rotate clientSecret

7.2 Roles and Permissions per App (admin)

GET    /api/admin/applications/:id/roles                     List roles
POST   /api/admin/applications/:id/roles                     Create a role
PATCH  /api/admin/applications/:id/roles/:roleId             Update
DELETE /api/admin/applications/:id/roles/:roleId             Delete

GET    /api/admin/applications/:id/permissions               List permissions
POST   /api/admin/applications/:id/permissions               Create (resource + action)
DELETE /api/admin/applications/:id/permissions/:permId       Delete

7.3 User Access per App (admin)

GET    /api/admin/applications/:id/users                     Users with access
POST   /api/admin/applications/:id/users                     Grant access { userId, roleId?, subscriptionPlanId?, reason? }
POST   /api/admin/applications/:id/users/bulk                Bulk grant { organizationId | userIds, roleId?, subscriptionPlanId? }
PATCH  /api/admin/applications/:id/users/:userId             { isActive, roleId, subscriptionPlanId, expiresAt, reason }
                                                             reason required when subscriptionPlanId is set; null plan revokes
DELETE /api/admin/applications/:id/users/:userId             Revoke access

7.4 User Management (admin)

GET    /api/admin/users                 Paginated list + search
POST   /api/admin/users                 Create a user (sends a verification email, best-effort)
POST   /api/admin/users/import          Bulk import
GET    /api/admin/users/:id             Profile + apps + MFA status + subscription
PATCH  /api/admin/users/:id             { name, role, isMfaRequired } (role: "user" | "admin")
DELETE /api/admin/users/:id             Delete (the last superadmin is protected: USR_002)
POST   /api/admin/users/:id/disable     Disable account
POST   /api/admin/users/:id/enable      Re-enable account
POST   /api/admin/users/:id/set-password  { newPassword } — strictly lower rank only;
                                          also revokes the target's sessions and OAuth tokens
POST   /api/admin/users/:id/send-verification   Revert to unverified and re-send the verification email
POST   /api/admin/users/:id/verify-email        Mark the address verified without the link

7.5 Subscription Plans (admin)

GET    /api/admin/applications/:id/plans             List plans
POST   /api/admin/applications/:id/plans             Create a plan { name, features: object, isDefault }
PATCH  /api/admin/applications/:id/plans/:planId     Update
DELETE /api/admin/applications/:id/plans/:planId     Delete (only if no active subscriptions)

POST   /api/admin/applications/:id/plans/:planId/prices            Create a fixed or metered price (Stripe Price, plus a Meter when metered)
PATCH  /api/admin/applications/:id/plans/:planId/prices/:priceId   Update (name, unit, cap of a metered row; amount, currency, interval and feature key are immutable)
DELETE /api/admin/applications/:id/plans/:planId/prices/:priceId   Delete (archives the Stripe Price)

POST   /api/admin/applications/:id/users/:userId/subscription/cancel   Stop the Stripe subscription at period end
POST   /api/admin/applications/:id/users/:userId/subscription/resume   Resume a subscription stopped at period end

POST   /api/admin/applications/:id/users/:userId/subscription   Assign plan { planId, expiresAt?, reason? } → { ok: true }
                                                                (409 SUB_002 when an active subscription already exists)
DELETE /api/admin/applications/:id/users/:userId/subscription   Remove subscription → 204

Every effective plan change (admin, self-service, Stripe, default assignment) writes a user_subscription_events audit row and publishes subscription.changed on the live-event channel (coalesced to at most one frame per second). Self-service changes go through PATCH /api/user/subscription/:applicationId and require Stripe to be configured (403 SUB_005 otherwise). A free target applies at once. A paid target never grants anything before Stripe agrees: a live paid subscription changes in place (prorated by Stripe, the plan follows its update event), any other case returns a Stripe Checkout URL (checkoutUrl) that grants the plan when it completes.

The billing database is reconciled with Stripe by pnpm billing:reconcile (CLI, no HTTP endpoint): --check is a read-only audit, the default repairs missing/relinked catalogue objects, subscription links and local mirrors, --recreate-missing recreates a subscription Stripe confirms deleted (active rows only), and --cancel-superseded cancels a replaced subscription only when the replacement is confirmed active/trialing. Orphan subscriptions are reported, never cancelled, and Stripe objects are never deleted. The webhook is fail-closed: a missing or invalid signature answers 400 APP_001 before any write, unconfigured Stripe answers 503 SRV_002, and a duplicate delivery is acknowledged without re-applying state. Invoice links are cached only when they are https://.

7.6 Consumption Tracking

Authentication: OAuth 2.1 client_credentials bearer token bound to the target application (backend service), or an admin/superadmin session. A user-bound token is refused with CONS_004; a machine token for another application is refused with AUTH_011.

POST   /api/consumption
Body:  { applicationId: string, userId: string, key: string, value: number, idempotencyKey?: string }
Response: { success: true, aggregate: { key: string, total: number } }
          (a metered feature of the user's active plan answers
           { success: true, metered: true, aggregate } and may fail with BIL_003)

GET    /api/consumption/:userId/:applicationId             All aggregates for user+app → { aggregates }
GET    /api/consumption/:userId/:applicationId/:key        Single aggregate → { aggregate }
DELETE /api/consumption/:userId/:applicationId/:key        Reset a counter (admin only) → 204

Error responses follow the global error format (see section 12).

Validation rules:

  • key: alphanumeric + dots, max 64 chars, e.g. "apiCalls", "storage.mb"
  • value: finite number, may be negative (credit)
  • userId + applicationId: must exist in userApplication

7.7 Health Check (public)

GET    /health      → { status: "ok", version: string }

7.8 Real-time admin channel (SSE)

GET    /api/admin/events    → text/event-stream (admin or superadmin)

Server-Sent Events stream used by the admin dashboard. The stream is a signal, not a data feed: a frame names an event type and carries at most the instant it happened, and the client re-fetches through the REST endpoints it already calls.

event: login.recorded
data: 2026-10-07T15:42:58.123Z
  • Authorization is the same requireAdmin preHandler as every other /api/admin/* route: 401 AUTH_001 without a session, 403 AUTH_011 for an authenticated non-admin (a standard user's /profile page can never subscribe), 200 for admin and superadmin.
  • No payload is ever transported — no user id, no e-mail, no counts. The client learns that something changed and re-reads the authoritative state.
  • A comment frame (: keep-alive) is written on an idle stream so an intermediary does not close it. x-accel-buffering: no disables proxy buffering; the same-origin path keeps it inside the production CSP connect-src 'self'.
  • Best-effort delivery. A lost event is not an error: the dashboard's 30 s poll of /api/admin/stats/active-users remains the safety net.
  • The event bus (src/services/event-bus.ts) is in-process by default and fans out over Redis pub/sub when REDIS_URL is set, mirroring the optional-Redis pattern of the rate-limit store. One instance is declared in docker-compose.yml today, so the fan-out exists to avoid a silent loss the day the service is replicated.

Event vocabulary: login.recorded (WP1), then session.created, session.revoked, user.changed, application.changed and organization.changed (WP3), then wallet.changed (wallet lot 7), then subscription.changed (subscription management). Each type is a name and nothing else; a type that would need a payload to be useful is a type that is wrong, and the fix for it is a better type rather than a wider frame.

TypeEmitted from
login.recordedrecordLogin() (src/services/login-history.ts) — one call site covers both login paths.
session.createddatabaseHooks.session.create.after (src/auth.ts) — the hook every session creation passes through.
session.revokeddatabaseHooks.session.delete.after (src/auth.ts) — the hook every session removal passes through, single or bulk, ours or BetterAuth's.
user.changeddatabaseHooks.user.{create,update,delete}.after plus the direct Drizzle writes that bypass the adapter (src/bootstrap.ts, src/routes/user.ts, src/routes/admin/users.ts, src/services/organization-invitations.ts, src/services/registration-invitations.ts).
application.changedThe writes in src/routes/admin/applications.ts and src/routes/admin/application-machine-scopes.ts, plus the application seeded or updated at startup (src/bootstrap.ts).
organization.changedorganizationHooks.* (src/auth.ts) for the plugin's own adapter writes, plus the direct organization/member writes in src/routes/admin/organizations.ts.
wallet.changednotifyWalletChanged() (src/services/wallet/events.ts), after every committed ledger operation and unlimited-access change; throttled to one frame per second.
subscription.changednotifySubscriptionChanged() (src/services/subscriptions.ts) on every effective plan change except the automatic default path. Coalesced to at most one frame per second so a bulk grant does not storm the channel.

A type whose emission is spread over several points is listed as such rather than presented as a single one: both the applications and the user tables have more than one writer.

Widening the vocabulary does not widen the client: the composable (frontend/src/composables/useAdminEvents.ts) subscribes only to login.recorded, wallet.changed and subscription.changed; the other types are ignored by the SPA.

Client side. The admin dashboard, the application detail, wallets and accounting views subscribe through frontend/src/composables/useAdminEvents.ts. On login.recorded the dashboard re-reads the logins series (GET /api/admin/stats/logins) and the online counter (GET /api/admin/stats/active-users) instead of waiting for the 30 s poll. The refreshes are coalesced inside a window (DEFAULT_COALESCE_MS = 1 s), so a burst of logins costs one round of requests rather than one per event; the window stays an order of magnitude below the poll so a single event still lands promptly. The composable is inert under VITE_USE_MOCK, closes the subscription on unmount, and leaves reconnection to EventSource — a refused stream (403 for a non-admin) is treated as silence, with no client-side authorization gate and no retry loop. The 30 s poll is unchanged and remains the safety net.


8. Database Schema

8.1 BetterAuth-Managed Tables

The following tables are created and managed automatically by BetterAuth plugins. Do not create them manually.

TablePluginNotes
usercoreExtended with role, is_mfa_required via additionalFields
sessioncore
accountcoreFor password + passkey credentials
verificationcoreEmail verification tokens
two_factortwoFactor()TOTP secret + backup codes
passkey@better-auth/passkeyFIDO2 credential per device
organization, member, invitationorganization()Organizations, memberships and invitations
jwksjwt()Signing keys (rotation enabled)
oauth_client@better-auth/oauth-providerPreviously oauthApplication in oidcProvider
oauth_access_token@better-auth/oauth-providerOpaque access tokens (when issued)
oauth_refresh_token@better-auth/oauth-providerHashed refresh tokens (new in oauthProvider)
oauth_consent@better-auth/oauth-providerPer-user per-client consent records
oauth_resource, oauth_client_resource, oauth_client_assertion@better-auth/oauth-provider 1.7RFC 8707 protected resources and their per-client links; client assertions
device_codedevice authorizationUsed when DEVICE_AUTHORIZATION_ENABLED is set (the table is part of the baseline either way)

The Drizzle definitions of these tables are hand-maintained in src/db/auth-schema.ts.

8.2 Custom Tables

-- Reading note: the blocks below are simplified excerpts. The authoritative
-- definition is `src/db/schema.ts` and the consolidated baseline
-- `drizzle/0000_initial_schema.sql`. Columns holding a user id are `TEXT`
-- (BetterAuth user ids) and have no foreign key to "user".
-- Tables not reproduced here: login_history, registration_invitations,
-- stripe_events, stripe_invoices, subscription_plan_prices, billing_usage_events,
-- billing_usage_periods, wallet_pricing_settings.

-- Registered applications (logical entity linking to oauthClient)
CREATE TABLE applications (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name                     TEXT NOT NULL,
  slug                     TEXT NOT NULL,          -- unique index
  description              TEXT,
  is_active                BOOLEAN NOT NULL DEFAULT true,
  skip_consent             BOOLEAN NOT NULL DEFAULT false,
  is_mfa_required          BOOLEAN NOT NULL DEFAULT false,
  allow_register           BOOLEAN NOT NULL DEFAULT false,
  billing_mode             TEXT NOT NULL DEFAULT 'none',  -- 'none' | 'delegated'
  allowed_scopes           TEXT[] NOT NULL DEFAULT '{openid,profile,email}',
  redirect_uris            TEXT[] NOT NULL DEFAULT '{}',
  is_public                BOOLEAN NOT NULL DEFAULT false,
  is_system                BOOLEAN NOT NULL DEFAULT false,
  url                      TEXT,
  allowed_resources        TEXT[] NOT NULL DEFAULT '{}',  -- RFC 8707 resources
  icon                     TEXT,
  enabled_social_providers TEXT[],                 -- null = inherit global providers
  metadata                 JSONB NOT NULL DEFAULT '{}',
  created_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at               TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- User ↔ application access
CREATE TABLE user_applications (
  id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id              TEXT NOT NULL,
  application_id       UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  is_active            BOOLEAN NOT NULL DEFAULT true,
  subscription_plan_id UUID REFERENCES subscription_plans(id) ON DELETE SET NULL,
  created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, application_id)                 -- unique index
);

-- Roles defined per application
CREATE TABLE app_roles (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  name           TEXT NOT NULL,
  description    TEXT,
  is_default     BOOLEAN NOT NULL DEFAULT false,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (application_id, name)                    -- unique index
);

-- Permissions defined per application (dot-notation)
CREATE TABLE app_permissions (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  resource       TEXT NOT NULL,                 -- e.g. "tenants", "endpoints.write"
  action         TEXT NOT NULL DEFAULT 'read',  -- "read" | "write"
  created_at     TIMESTAMPTZ DEFAULT now(),
  UNIQUE (application_id, resource, action)
);

-- Role ↔ permission join
CREATE TABLE app_role_permissions (
  role_id       UUID NOT NULL REFERENCES app_roles(id) ON DELETE CASCADE,
  permission_id UUID NOT NULL REFERENCES app_permissions(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, permission_id)
);

-- User role within an application
CREATE TABLE user_app_roles (
  user_id        TEXT NOT NULL,
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  role_id        UUID NOT NULL REFERENCES app_roles(id) ON DELETE CASCADE,
  created_at     TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (user_id, application_id, role_id)
);

-- Subscription plans per application
CREATE TABLE subscription_plans (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  name           TEXT NOT NULL,                -- e.g. "free", "pro", "enterprise"
  description    TEXT,
  stripe_product_id TEXT,                      -- provisioned by auth-service only
  features       JSONB NOT NULL DEFAULT '{}', -- plain feature flags/limits
  is_default     BOOLEAN NOT NULL DEFAULT false,
  created_at     TIMESTAMPTZ DEFAULT now(),
  updated_at     TIMESTAMPTZ DEFAULT now(),
  UNIQUE (application_id, name)
);

-- Active subscription per user per app (mirror of user_applications.subscription_plan_id).
-- The stripe_* columns link a Stripe-driven subscription to its object; they are
-- null for assignments made by an admin or the default-plan rule.
CREATE TABLE user_subscriptions (
  id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id              TEXT NOT NULL,
  application_id       UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  plan_id              UUID NOT NULL REFERENCES subscription_plans(id) ON DELETE RESTRICT,
  expires_at           TIMESTAMPTZ,             -- null = lifetime
  is_active            BOOLEAN NOT NULL DEFAULT true,
  stripe_subscription_id TEXT,                  -- unique when present (reconcile key)
  stripe_status        TEXT,                    -- raw Stripe status (active, past_due, …)
  current_period_start TIMESTAMPTZ,
  current_period_end   TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
  canceled_at          TIMESTAMPTZ,
  created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, application_id),
  UNIQUE (stripe_subscription_id)
);

-- Audit trail of subscription plan changes (no money moves, so it is not a
-- wallet ledger entry). One row per effective change; a no-op is not recorded.
CREATE TABLE user_subscription_events (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        TEXT NOT NULL,
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  actor_user_id  TEXT,                          -- null for automatic paths
  source         TEXT NOT NULL CHECK (source IN ('admin', 'self', 'default', 'stripe')),
  action         TEXT NOT NULL CHECK (action IN ('assigned', 'changed', 'removed')),
  from_plan_id   UUID REFERENCES subscription_plans(id) ON DELETE SET NULL,
  to_plan_id     UUID REFERENCES subscription_plans(id) ON DELETE SET NULL,
  reason         TEXT,
  created_at     TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_user_subscription_events_user_app ON user_subscription_events (user_id, application_id, created_at);
CREATE INDEX idx_user_subscription_events_app ON user_subscription_events (application_id, created_at);

-- Raw consumption events
CREATE TABLE consumption_entries (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        TEXT NOT NULL,
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  key            TEXT NOT NULL,   -- e.g. "apiCalls", "storage.mb"
  value          NUMERIC NOT NULL,
  recorded_at    TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_consumption_entries_user_app ON consumption_entries (user_id, application_id, key);

-- Materialized aggregates (updated on every write)
CREATE TABLE consumption_aggregates (
  user_id        TEXT NOT NULL,
  application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
  key            TEXT NOT NULL,
  total          NUMERIC NOT NULL DEFAULT 0,
  updated_at     TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (user_id, application_id, key)
);

-- Prepaid credit wallet, shared by every application.
-- Amounts are integer micro-euros (1 EUR = 1 000 000). Credits never expire.
-- The balance goes negative only through usage and payment reversals (events that
-- already happened); a negative balance blocks spending until the next top-up.
CREATE TABLE wallet_accounts (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_type   TEXT NOT NULL CHECK (owner_type IN ('user', 'org')),
  owner_id     TEXT NOT NULL,   -- user or organization id (no FK: polymorphic)
  balance      BIGINT NOT NULL DEFAULT 0,
  is_unlimited BOOLEAN NOT NULL DEFAULT false,  -- usage recorded, balance untouched
  created_at   TIMESTAMPTZ DEFAULT now(),
  updated_at   TIMESTAMPTZ DEFAULT now(),
  UNIQUE (owner_type, owner_id)
);

-- Append-only ledger. balance = SUM(delta) for every account.
-- amount: signed value of the operation; delta: its effect on the balance
-- (equal to amount, or 0 for usage on an unlimited account).
CREATE TABLE wallet_transactions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id      UUID NOT NULL REFERENCES wallet_accounts(id) ON DELETE RESTRICT,
  type            TEXT NOT NULL CHECK (type IN ('topup', 'grant', 'refund', 'usage', 'adjust', 'reversal')),
  amount          BIGINT NOT NULL CHECK (amount <> 0),
  delta           BIGINT NOT NULL,
  balance_after   BIGINT NOT NULL,
  idempotency_key TEXT NOT NULL,
  application_id  UUID REFERENCES applications(id) ON DELETE SET NULL,
  metadata        JSONB NOT NULL DEFAULT '{}',  -- raw quantities; never secrets
  created_at      TIMESTAMPTZ DEFAULT now(),
  UNIQUE (account_id, idempotency_key)
  -- topup/grant/refund: amount > 0; usage/reversal: amount < 0; adjust: either sign
  -- a manual debit (adjust, amount < 0) never leaves balance_after below zero
  -- delta = amount, except usage on an unlimited account (delta = 0)
);

-- Audit trail of administrative wallet changes that move no money
CREATE TABLE wallet_account_events (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  account_id    UUID NOT NULL REFERENCES wallet_accounts(id) ON DELETE RESTRICT,
  actor_user_id TEXT NOT NULL,
  action        TEXT NOT NULL CHECK (action IN ('unlimited_enabled', 'unlimited_disabled')),
  details       JSONB NOT NULL DEFAULT '{}',  -- e.g. { "reason": "..." }
  created_at    TIMESTAMPTZ DEFAULT now()
);

-- Stripe Customer linked to a billing owner (user today, organization later;
-- no FK: polymorphic). Created best-effort when a user is created and on the
-- first paid flow. See the Stripe billing programme plan.
CREATE TABLE billing_customers (
  id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_type         TEXT NOT NULL CHECK (owner_type IN ('user', 'org')),
  owner_id           TEXT NOT NULL,
  stripe_customer_id TEXT NOT NULL,
  created_at         TIMESTAMPTZ DEFAULT now(),
  updated_at         TIMESTAMPTZ DEFAULT now(),
  UNIQUE (owner_type, owner_id),
  UNIQUE (stripe_customer_id)
);

9. Environment Variables

Minimal subset only; the complete list, with defaults, is the environment variables reference.

# Server
PORT=3001
HOST=0.0.0.0

# BetterAuth
BETTER_AUTH_SECRET=         # 32 random bytes (openssl rand -base64 32)
BETTER_AUTH_URL=            # Public URL of the auth-service, e.g. https://auth.example.com

# Database
DATABASE_URL=               # postgres://user:pass@host:5432/auth_service

# Bootstrap superadmin (auto-created at first startup if absent)
ADMIN_EMAIL=
ADMIN_PASSWORD=

# CORS — allowed origins (client apps)
CORS_ORIGINS=               # e.g. https://app1.example.com,https://app2.example.com

# Email (for verification and password reset)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=

# Optional: Node environment
NODE_ENV=production

10. Extensibility — Adding BetterAuth Plugins

The BetterAuth configuration in src/auth.ts is a single betterAuth({ ... }) call whose plugins array accepts additional plugins. The sketch below is a simplified excerpt (the real file also configures email, hooks, rate limits, the organization plugin and the opt-in plugins described in the roadmap); the authoritative version is src/auth.ts:

// src/auth.ts (simplified)
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { twoFactor, admin, jwt } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { oauthProvider } from "@better-auth/oauth-provider";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg", schema }),
  secret: config.betterAuth.secret,
  baseURL: config.betterAuth.url,
  disabledPaths: ["/token"], // /oauth2/token is the correct path
  emailAndPassword: { enabled: true, minPasswordLength: 8 },
  plugins: [
    jwt(), // required by oauthProvider for asymmetric signing
    twoFactor({ issuer: config.appName }), // TOTP + backup codes
    passkey(), // FIDO2 plugin (no UI yet)
    admin({ adminRoles: ["admin", "superadmin"], defaultRole: "user" }),
    oauthProvider({
      loginPage: "/login",
      consentPage: "/oauth2/consent",
      scopes: supportedScopes(config.compat), // see src/compat/scopes.ts
      // Inject roles, permissions, features and org_id into the id_token
      customIdTokenClaims: async ({ user, scopes, metadata }) => { /* ... */ },
      customAccessTokenClaims: async (args) => { /* ... */ },
      // Same data in /oauth2/userinfo
      customUserInfoClaims: async ({ user, scopes, jwt }) => { /* ... */ },
    }),
    // ← any BetterAuth plugin can be added here
  ],
});

Plugins added here automatically affect:

  • Exposed REST endpoints
  • Database schema: the Drizzle definitions of BetterAuth tables are maintained by hand in src/db/auth-schema.ts and must be aligned with the plugin schema (see AGENTS.md §8). Until the first production release the baseline drizzle/0000_initial_schema.sql is regenerated rather than extended with delta migrations.
  • OIDC claims (if the plugin exposes user data)

11. Acceptance Criteria

Design-time checklist, kept unchecked: it states what the service is expected to satisfy, not a verified status. Evidence for the implemented parts lives in the integration suite (src/tests/*.integration.test.ts) and the Playwright end-to-end tests (tests/e2e).

Core Auth

  • GET /health → { status: "ok", version }
  • GET /.well-known/openid-configuration returns issuer + JWKS URL + authorize/token endpoints
  • GET /.well-known/oauth-authorization-server returns RFC8414-compliant metadata
  • GET /api/auth/jwks returns public key in JWKS format
  • Email/password login → redirect_uri with valid OAuth 2.1 code
  • Token exchange: code → access_token + id_token with sub, email, name, roles, permissions, features
  • Downstream backend: Auth Service token accepted by a JWKS-capable middleware (JWT verify via JWKS)

MFA

  • TOTP activation: QR code generated, OTP verified, backup codes provided
  • A FIDO2 key registered as an OATH-TOTP slot works as a second factor (native WebAuthn/passkey registration has no UI yet, see section 4.3)
  • MFA applies to all projects once enabled for a user

Subscriptions & Features

  • User with pro plan: features claim in id_token matches plan's feature JSON
  • User with no plan: features claim is {}
  • Admin can create, update, delete subscription plans
  • Admin can assign/revoke a plan for a user on a given application

Consumption

  • POST /api/consumption authenticated via client_credentials records a delta
  • GET /api/consumption/:userId/:appId returns correct aggregates
  • Negative value decrements the aggregate (credit)
  • Invalid key format or missing userId/applicationId returns structured error with code

Administration

  • Bootstrap superadmin: if ADMIN_EMAIL set and DB empty → user created and logged
  • Admin UI: CRUD applications, CRUD roles + permissions, user access management, plan management
  • User without access to an app → permissions: [], features: {} in token → 403 on the app side

Infrastructure

  • docker compose up starts the service and GET /health answers
  • TypeScript compilation with strict: true produces no errors (pnpm build:server)
  • Test suite passes: pnpm test (unit) and pnpm test:integration

12. Development Standards

12.1 TypeScript

  • strict: true in tsconfig.json — non-negotiable
  • any is forbidden. Exceptions require an explicit inline comment:
    // eslint-disable-next-line @typescript-eslint/no-explicit-any — BetterAuth hook type limitation
    const claims = result as Record<string, unknown>;
    
  • Prefer unknown over any for untrusted inputs
  • Use Zod for all external input validation (API request bodies, environment variables)
  • All async functions must have return type annotations

12.2 Code Language

  • All code, comments, variable names, and interfaces in English
  • Exception: user-facing UI strings are in the frontend locale files (frontend/src/i18n/en.ts, frontend/src/i18n/fr.ts)

12.3 Error Handling and i18n Codes

Every error response from the API must include a machine-readable code for frontend i18n and client-side handling:

// Error response shape
interface ApiError {
  code: string; // e.g. "AUTH_001" — see section 13
  message: string; // English developer message
  details?: unknown; // optional validation details
}

HTTP error responses:

{
  "error": {
    "code": "APP_002",
    "message": "Application not found"
  }
}

BetterAuth errors bubble up with their own codes; custom routes must wrap them in the same shape.

12.4 Testing

  • Framework: Vitest (unit tests) + Supertest (HTTP integration tests)
  • Where to test:
    • Every route handler: at least one happy path + one error path
    • Every service function: unit tests for business logic (RBAC resolution, claims building, consumption write)
    • BetterAuth plugin hooks: integration tests against a test database
  • Naming convention: src/**/*.test.ts for unit tests, src/tests/*.integration.test.ts for integration tests
  • Test database: separate PostgreSQL DB, seeded before each suite, cleaned after
  • Coverage target: ≥ 80 % lines on src/routes/ and src/services/ (declared as a threshold in vitest.config.ts; not yet met repository-wide, see the roadmap)
  • Run with: pnpm test (unit), pnpm test:integration (integration, PostgreSQL) and pnpm test:e2e (Playwright)

12.5 Linting and Formatting

  • ESLint with typescript-eslint recommended plus no-explicit-any and consistent-type-imports as errors and no-console as a warning (eslint.config.js)
  • Prettier for formatting
  • Pre-commit hook via husky + lint-staged (ESLint and Prettier on staged *.ts, Prettier on staged JSON/YAML)

13. Error Code Registry

Format: {DOMAIN}_{3-digit number}

AUTH — Authentication & Session

CodeHTTPDescription
AUTH_001401Missing or invalid authentication token
AUTH_009400Password too weak
AUTH_011403Insufficient permissions
AUTH_012400Invalid, expired or already used registration invitation
AUTH_013409An account already exists for this email
AUTH_014403This invitation was issued for a different email address
AUTH_015403Verify your email address before accepting this invitation
AUTH_016404Session not found

APP — Application Management

CodeHTTPDescription
APP_001400Invalid application data
APP_002404Application not found
APP_003409Application slug already exists
APP_005403User does not have access to this application
APP_006403Social provider not enabled for this application
APP_007403System applications cannot be deleted

PERM — Roles & Permissions

CodeHTTPDescription
PERM_001400Invalid permission format
PERM_002404Role not found
PERM_003404Permission not found
PERM_004409Role name already exists in this application
PERM_005409Permission already defined
PERM_006400Cannot delete a role that is assigned to users

SUB — Subscriptions

CodeHTTPDescription
SUB_001404Subscription plan not found
SUB_002409User already has an active subscription
SUB_003400Plan has active subscribers, cannot delete
SUB_005403Self-service plan changes require Stripe to be configured
SUB_007409A paid subscription already exists for this application: change its plan instead
SUB_008404This user has no Stripe subscription for this application

CONS — Consumption

CodeHTTPDescription
CONS_001400Invalid key format
CONS_002400Value must be a finite number
CONS_003404User and application combination not found
CONS_004403Caller not authorized (requires client_credentials)
CONS_005400Invalid consumption identifier

BIL — Billing (Stripe)

CodeHTTPDescription
BIL_001503Billing is not configured
BIL_002400Invalid billing configuration
BIL_003403Metered usage limit reached
BIL_004502The payment provider rejected the request

WAL — Wallet

CodeHTTPDescription
WAL_001404Wallet account not found
WAL_002402Insufficient credit balance
WAL_003409Idempotency key already used with different parameters
WAL_004400Invalid wallet amount
WAL_005400Invalid pagination cursor
WAL_006503Online payment is not configured
WAL_007503Usage pricing is not configured

USR — User Management (admin)

CodeHTTPDescription
USR_001404User not found
USR_002400Cannot delete the last superadmin
USR_003400Invalid user data

ORG — Organizations (admin)

CodeHTTPDescription
ORG_001404Organization not found
ORG_002409Organization slug already exists
ORG_003400Invalid organization data
ORG_004404Member not found in organization
ORG_005409User is already a member of this organization

Additional notes

  • applications.enabledSocialProviders: null = inherit globally enabled providers; [] = disable all social providers for the app; non-empty array = allow-list of provider IDs. The credential provider is always allowed.
  • APP_NAME (env) — display name shown in the UI sidebar, login & consent pages, and used as the TOTP issuer in authenticator apps. Defaults to CIRCLE Auth.
  • APP_LOGO_URL (env, optional) — absolute URL of a logo image displayed in the sidebar, login & consent pages and used as the runtime favicon override. When unset, the UI falls back to the bundled CIRCLE mark (CircleMark, an inline SVG drawn in currentColor, so it follows the theme) and the document icon is the instance icon set committed under frontend/public (favicon.svg, which switches between its dark and light colourways via prefers-color-scheme, plus the PNG/ICO and apple-touch-icon derivatives). The override is applied client-side by useAppBranding().applyFavicon(), which removes every link[rel~="icon"] and appends the operator's URL, so an operator-provided image is rendered exactly as supplied (no plate added behind it).
  • APP_LOGO_DARK_URL (env, optional) — logo for the dark theme; rejected at startup unless APP_LOGO_URL is also set. /api/app-config exposes it as logoDarkUrl. When present, the UI shows APP_LOGO_URL on the light theme and this one on the dark theme as supplied. When absent, AppLogo adapts the single logo: a near-black or near-white transparent logo is inverted when it would vanish on the active theme, any other transparent logo gets an avatar-style circle in the opposite tone, and a logo with an opaque background is left untouched.
  • PATCH /api/admin/organizations/:id — partial update for an existing organization. Body: any subset of { name, slug, logo, metadata }. Returns 409 (ORG_002) on slug conflict and 404 (ORG_001) when the organization does not exist.

SRV — Server / Internal

CodeHTTPDescription
SRV_001500Internal server error
SRV_002503Service temporarily unavailable
SRV_003500Database error
SRV_004502LLM gateway unavailable

MAIL — Outbound email

CodeHTTPDescription
MAIL_002502Mail transport failure
MAIL_003429Too many email requests, please retry later

RATE — Rate limiting

CodeHTTPDescription
RATE_001429Too many authentication attempts, please retry later
Copyright © 2026