Functional specifications
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
| Layer | Choice | Rationale |
|---|---|---|
| Runtime | Node.js 22 (ESM) | Modern JavaScript runtime; ESM-first |
| Framework | Fastify 5 | High-throughput, low-overhead HTTP framework |
| Auth framework | better-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 signing | jwt() plugin from better-auth/plugins | Required by oauthProvider for asymmetric token signing |
| Passkey / YubiKey | @better-auth/passkey | FIDO2/WebAuthn, YubiKey-compatible |
| TOTP (Auth app) | twoFactor() from better-auth/plugins | Google Authenticator, Authy, Bitwarden |
| Admin / global roles | admin() from better-auth/plugins | Role management (superadmin/admin/user) |
| DB ORM | Drizzle ORM + PostgreSQL | Type-safe schema, first-class migrations |
| Frontend (bundled) | Vue 3 + Vite + Tailwind v4 | SPA embedded in the service |
| Testing | Vitest + Supertest | Unit tests + HTTP integration tests |
| Containerisation | Docker (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:
| Role | Description |
|---|---|
superadmin | Full access. Created automatically at bootstrap. The last superadmin cannot be deleted or demoted. |
admin | Manages users and applications. |
user | Access 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— UUIDname— Display name (e.g. "Admin Console")slug— URL-safe identifier (e.g.admin-console), uniquedescription— Optional descriptionredirectUris— Allowed callback URLs (PKCE flow)isActive— Global enable/disableallowedScopes— OIDC scopes allowed for the application; the native scopes areopenid,profile,email,phone,offline_access,roles,permissions,features,wallet:self:readandorg(src/compat/scopes.ts)skipConsent— Skip consent screen for trusted first-party appsisMfaRequired,allowRegister,billingMode(none|delegated),isPublic,isSystem,url,allowedResources,icon,enabledSocialProviders,metadatacreatedAt,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,applicationIdisActive— Can suspend access without deleting the relationsubscriptionPlanId— 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_accessscope) - Refresh tokens are hashed in the database (
oauth_refresh_tokentable managed byoauthProvider)
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
qrcodelibrary from theotpauth://URI; first code verification before the secret is committed - 10 backup codes generated at activation, displayed once, downloadable as a
.txtfile; 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
passkeytable 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)
| Endpoint | Method | Purpose |
|---|---|---|
/api/auth/two-factor/enable | POST | Returns { totpURI, backupCodes } (requires password) |
/api/auth/two-factor/disable | POST | Disables TOTP (requires password) |
/api/auth/two-factor/verify-totp | POST | Consumes the 2FA cookie and issues the session |
/api/auth/two-factor/verify-backup-code | POST | Same, with a single-use backup code |
/api/auth/two-factor/generate-backup-codes | POST | Rotates 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 confirmationfrontend/src/components/profile/MfaBackupCodesModal.vue— backup code regenerationfrontend/src/components/auth/MfaChallengeForm.vue— login-time challenge with TOTP / backup-code toggle- All strings live under the
mfa.*namespace infrontend/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:
- Read
ADMIN_EMAILandADMIN_PASSWORDfrom environment variables (ADMIN_PASSWORDmust be at least 12 characters,src/config.ts) - In production, refuse a known default/placeholder password (error logged, superadmin not created)
- Create the user with
role: "superadmin"viaauth.api.createUser(), with a verified email - Log a confirmation message (password never logged)
- 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
| Route | Component | Description |
|---|---|---|
/ | LandingView | Public showcase when SHOWCASE=true; otherwise redirects to /profile (and so to /auth when signed out) |
/auth | LoginView | Administration sign-in: email + password, link to forgot password |
/login | LoginView | Entry of OAuth authorization requests and device approval (signed query parameters); without any, redirects to / (SHOWCASE=true) or /auth |
/forgot-password | ForgotPasswordView | Password reset request by email (generic answer, no enumeration) |
/reset-password | ResetPasswordView | New password; token from the link below (path form also accepted) |
/oauth2/consent | ConsentView | OIDC authorization screen (when skipConsent: false) |
/device | DeviceView | RFC 8628 device authorization approval (opt-in) |
/forbidden | ForbiddenView | 403 page |
/email-verified | EmailVerifiedView | Post-verification landing page (public, opens no session) |
/accept-invitation, /complete-registration | ServerPageRedirectView | Force 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
| Route | Component | Description |
|---|---|---|
/profile | ProfileView | Tabs: 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.
| Route | Component | Description |
|---|---|---|
/dashboard | DashboardView | Activity charts, online counter, live admin events |
/users | UsersView | Paginated list + search, quick actions |
/users/:id | UserDetailView | Profile, global role, app access, per-app roles, force MFA |
/organizations | OrganizationsView | Organizations, members and invitations |
/applications | ApplicationsView | List of registered OAuth apps |
/applications/:id | ApplicationDetailView | Edit, clientId/secret, roles/permissions, users, plans, consumption |
/wallets | WalletsView | Credit wallets, pricing and usage ledger |
/accounting | AccountingView | Accounting journal |
/configuration | ConfigurationView | Instance 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 inuserApplication
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
requireAdminpreHandler as every other/api/admin/*route:401 AUTH_001without a session,403 AUTH_011for an authenticated non-admin (a standard user's/profilepage can never subscribe),200foradminandsuperadmin. - 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: nodisables proxy buffering; the same-origin path keeps it inside the production CSPconnect-src 'self'. - Best-effort delivery. A lost event is not an error: the dashboard's 30 s
poll of
/api/admin/stats/active-usersremains the safety net. - The event bus (
src/services/event-bus.ts) is in-process by default and fans out over Redis pub/sub whenREDIS_URLis set, mirroring the optional-Redis pattern of the rate-limit store. One instance is declared indocker-compose.ymltoday, 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.
| Type | Emitted from |
|---|---|
login.recorded | recordLogin() (src/services/login-history.ts) — one call site covers both login paths. |
session.created | databaseHooks.session.create.after (src/auth.ts) — the hook every session creation passes through. |
session.revoked | databaseHooks.session.delete.after (src/auth.ts) — the hook every session removal passes through, single or bulk, ours or BetterAuth's. |
user.changed | databaseHooks.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.changed | The 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.changed | organizationHooks.* (src/auth.ts) for the plugin's own adapter writes, plus the direct organization/member writes in src/routes/admin/organizations.ts. |
wallet.changed | notifyWalletChanged() (src/services/wallet/events.ts), after every committed ledger operation and unlimited-access change; throttled to one frame per second. |
subscription.changed | notifySubscriptionChanged() (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.
| Table | Plugin | Notes |
|---|---|---|
user | core | Extended with role, is_mfa_required via additionalFields |
session | core | |
account | core | For password + passkey credentials |
verification | core | Email verification tokens |
two_factor | twoFactor() | TOTP secret + backup codes |
passkey | @better-auth/passkey | FIDO2 credential per device |
organization, member, invitation | organization() | Organizations, memberships and invitations |
jwks | jwt() | Signing keys (rotation enabled) |
oauth_client | @better-auth/oauth-provider | Previously oauthApplication in oidcProvider |
oauth_access_token | @better-auth/oauth-provider | Opaque access tokens (when issued) |
oauth_refresh_token | @better-auth/oauth-provider | Hashed refresh tokens (new in oauthProvider) |
oauth_consent | @better-auth/oauth-provider | Per-user per-client consent records |
oauth_resource, oauth_client_resource, oauth_client_assertion | @better-auth/oauth-provider 1.7 | RFC 8707 protected resources and their per-client links; client assertions |
device_code | device authorization | Used 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.tsand must be aligned with the plugin schema (see AGENTS.md §8). Until the first production release the baselinedrizzle/0000_initial_schema.sqlis 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-configurationreturns issuer + JWKS URL + authorize/token endpoints -
GET /.well-known/oauth-authorization-serverreturns RFC8414-compliant metadata -
GET /api/auth/jwksreturns public key in JWKS format - Email/password login → redirect_uri with valid OAuth 2.1 code
- Token exchange: code →
access_token+id_tokenwithsub,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
proplan:featuresclaim in id_token matches plan's feature JSON - User with no plan:
featuresclaim is{} - Admin can create, update, delete subscription plans
- Admin can assign/revoke a plan for a user on a given application
Consumption
-
POST /api/consumptionauthenticated viaclient_credentialsrecords a delta -
GET /api/consumption/:userId/:appIdreturns correct aggregates - Negative value decrements the aggregate (credit)
- Invalid
keyformat or missinguserId/applicationIdreturns structured error with code
Administration
- Bootstrap superadmin: if
ADMIN_EMAILset 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 upstarts the service andGET /healthanswers - TypeScript compilation with
strict: trueproduces no errors (pnpm build:server) - Test suite passes:
pnpm test(unit) andpnpm test:integration
12. Development Standards
12.1 TypeScript
strict: trueintsconfig.json— non-negotiableanyis 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
unknownoveranyfor 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.tsfor unit tests,src/tests/*.integration.test.tsfor integration tests - Test database: separate PostgreSQL DB, seeded before each suite, cleaned after
- Coverage target: ≥ 80 % lines on
src/routes/andsrc/services/(declared as a threshold invitest.config.ts; not yet met repository-wide, see the roadmap) - Run with:
pnpm test(unit),pnpm test:integration(integration, PostgreSQL) andpnpm test:e2e(Playwright)
12.5 Linting and Formatting
- ESLint with
typescript-eslintrecommendedplusno-explicit-anyandconsistent-type-importsas errors andno-consoleas 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
| Code | HTTP | Description |
|---|---|---|
| AUTH_001 | 401 | Missing or invalid authentication token |
| AUTH_009 | 400 | Password too weak |
| AUTH_011 | 403 | Insufficient permissions |
| AUTH_012 | 400 | Invalid, expired or already used registration invitation |
| AUTH_013 | 409 | An account already exists for this email |
| AUTH_014 | 403 | This invitation was issued for a different email address |
| AUTH_015 | 403 | Verify your email address before accepting this invitation |
| AUTH_016 | 404 | Session not found |
APP — Application Management
| Code | HTTP | Description |
|---|---|---|
| APP_001 | 400 | Invalid application data |
| APP_002 | 404 | Application not found |
| APP_003 | 409 | Application slug already exists |
| APP_005 | 403 | User does not have access to this application |
| APP_006 | 403 | Social provider not enabled for this application |
| APP_007 | 403 | System applications cannot be deleted |
PERM — Roles & Permissions
| Code | HTTP | Description |
|---|---|---|
| PERM_001 | 400 | Invalid permission format |
| PERM_002 | 404 | Role not found |
| PERM_003 | 404 | Permission not found |
| PERM_004 | 409 | Role name already exists in this application |
| PERM_005 | 409 | Permission already defined |
| PERM_006 | 400 | Cannot delete a role that is assigned to users |
SUB — Subscriptions
| Code | HTTP | Description |
|---|---|---|
| SUB_001 | 404 | Subscription plan not found |
| SUB_002 | 409 | User already has an active subscription |
| SUB_003 | 400 | Plan has active subscribers, cannot delete |
| SUB_005 | 403 | Self-service plan changes require Stripe to be configured |
| SUB_007 | 409 | A paid subscription already exists for this application: change its plan instead |
| SUB_008 | 404 | This user has no Stripe subscription for this application |
CONS — Consumption
| Code | HTTP | Description |
|---|---|---|
| CONS_001 | 400 | Invalid key format |
| CONS_002 | 400 | Value must be a finite number |
| CONS_003 | 404 | User and application combination not found |
| CONS_004 | 403 | Caller not authorized (requires client_credentials) |
| CONS_005 | 400 | Invalid consumption identifier |
BIL — Billing (Stripe)
| Code | HTTP | Description |
|---|---|---|
| BIL_001 | 503 | Billing is not configured |
| BIL_002 | 400 | Invalid billing configuration |
| BIL_003 | 403 | Metered usage limit reached |
| BIL_004 | 502 | The payment provider rejected the request |
WAL — Wallet
| Code | HTTP | Description |
|---|---|---|
| WAL_001 | 404 | Wallet account not found |
| WAL_002 | 402 | Insufficient credit balance |
| WAL_003 | 409 | Idempotency key already used with different parameters |
| WAL_004 | 400 | Invalid wallet amount |
| WAL_005 | 400 | Invalid pagination cursor |
| WAL_006 | 503 | Online payment is not configured |
| WAL_007 | 503 | Usage pricing is not configured |
USR — User Management (admin)
| Code | HTTP | Description |
|---|---|---|
| USR_001 | 404 | User not found |
| USR_002 | 400 | Cannot delete the last superadmin |
| USR_003 | 400 | Invalid user data |
ORG — Organizations (admin)
| Code | HTTP | Description |
|---|---|---|
| ORG_001 | 404 | Organization not found |
| ORG_002 | 409 | Organization slug already exists |
| ORG_003 | 400 | Invalid organization data |
| ORG_004 | 404 | Member not found in organization |
| ORG_005 | 409 | User 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. Thecredentialprovider 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 toCIRCLE 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 incurrentColor, so it follows the theme) and the document icon is the instance icon set committed underfrontend/public(favicon.svg, which switches between its dark and light colourways viaprefers-color-scheme, plus the PNG/ICO andapple-touch-iconderivatives). The override is applied client-side byuseAppBranding().applyFavicon(), which removes everylink[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 unlessAPP_LOGO_URLis also set./api/app-configexposes it aslogoDarkUrl. When present, the UI showsAPP_LOGO_URLon the light theme and this one on the dark theme as supplied. When absent,AppLogoadapts 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 }. Returns409(ORG_002) on slug conflict and404(ORG_001) when the organization does not exist.
SRV — Server / Internal
| Code | HTTP | Description |
|---|---|---|
| SRV_001 | 500 | Internal server error |
| SRV_002 | 503 | Service temporarily unavailable |
| SRV_003 | 500 | Database error |
| SRV_004 | 502 | LLM gateway unavailable |
MAIL — Outbound email
| Code | HTTP | Description |
|---|---|---|
| MAIL_002 | 502 | Mail transport failure |
| MAIL_003 | 429 | Too many email requests, please retry later |
RATE — Rate limiting
| Code | HTTP | Description |
|---|---|---|
| RATE_001 | 429 | Too many authentication attempts, please retry later |