Inventory of what ships in clean-stack. Everything below is wired, tested, and used in the codebase: clone, configure env, ship business logic.
This is the file-level inventory: dense, path-anchored, meant for developers reading the codebase. For a plain-language guided tour of the same features, see OVERVIEW.md. For the as-built rationale (decisions, alternatives ruled out, security notes), see HISTORY.md. For what's planned, see ../ROADMAP.md.
RGPD Art. 7 demonstrability: records which version each user accepted and when. Bump a version string in the shared SSOT → every user is re-prompted. 7-year compliance trail in audit_log.
SSOT @packages/policies: POLICY_TYPES, POLICY_VERSIONS, POLICY_CHANGELOG. Schema: modules/policies/schemas/policies.ts (policy_acceptance, append-only).
Backend modules/policies/ (@modules/policies): PolicyAcceptanceService (N rows + N events per uow.run TX; getStaleTypes predicate). Routes: POST /me/policies/accept, GET /me/policies. requireCurrentPolicies middleware (shared/middleware/policy.middleware.ts): POLICY_ACCEPTANCE_REQUIRED (409) on stale. It is an allowlist, not a global mount with exclusions: it is declared route by route on the 19 mutating business routes of profile, webhooks, api-token, organization, notifications, billing and uploads, plus PATCH /api/v1/me (Phase H.1). Everything else is ungated, and the reasons differ: the policy routes themselves (gating them would block the only route that clears the gate), RGPD Art. 17/20 (a data-subject right cannot be conditioned on accepting terms), sign-out and session reads (a user must always be able to leave), /consents (no requireAuth at all: the cookie banner records for anonymous visitors, and withdrawal is the same family of right as RGPD), the whole /admin/* surface (operator tooling: gating it would 409-wall an operator out of the console, POST /admin/impersonation/stop included), impersonated sessions (checked inside the middleware, not at the mount), and every read-only route. The front turns that 409 into the redirect to /legal/accept (shared/api/errors/policy-refusal.ts). Acceptance recorded at /verify-email hook, not /sign-up/email (no session there; see HISTORY.md).
Frontend apps/app/src/features/legal/: sign-up checkbox; public /legal/privacy-policy + /legal/terms; acceptance gate /legal/accept (outside _shell). POLICY_URLS: hosting on external CMS is a one-line swap.
Event: user.policy.accepted (compliance retention).
CNIL/RGPD Art. 7 ePrivacy: dual-layer: server-side consent_record (timestamped, append-only, legally durable) linked to device via httpOnly cc_sid cookie. Four categories: necessary (always on), functional, analytics, marketing (default off). TTL 180 days (configurable).
SSOT @packages/cookie-consent: CONSENT_CATEGORIES, CONSENT_COOKIE_NAME="cc_sid", CONSENT_GRANT_TTL_DAYS=180. DB modules/consents/schemas/consent.ts: consent_record (append-only; most-recent wins).
Backend modules/consents/ (@modules/consents): ConsentService: record / withdraw / getActive (with subjectId fallback) / reconcile (links device→account at login via auth.ts hooks.after on newSession, zero client round-trip). Routes: POST /consents, GET /consents, DELETE /consents (public, optionalAuth). Guest sweep: shared/internal-routes/sweep-consents.route.ts (HMAC-gated, CONSENT_RETENTION_DAYS=365).
Frontend apps/app/src/shared/: <CookieBanner> (Reject/Accept same prominence, auto-mounted) · <ConsentSettings> (per-category toggles) · <ConsentGate category> (declarative gate) · useConsent(category): boolean · <AnalyticsScripts> (loads VITE_ANALYTICS_SRC env only if analytics consented: template for gated scripts) · /legal/cookies (per-category cookie inventory).
Events: user.cookie_consent.granted + user.cookie_consent.withdrawn (compliance retention).
EAA Art. 14 accessibility declaration + GDPR Art. 28 sub-processor disclosure: mandatory for EU deploys. Plus contract templates for EU client onboarding.
Frontend apps/app/src/features/legal/: /legal/sub-processors (typed config SUB_PROCESSORS in shared/sub-processors.config.ts) · /legal/accessibility (WCAG 2.1 AA / EN 301 549 statement, known limitations, feedback contacts).
Contract templates docs/legal/: DPA-template.md (12-clause GDPR Art. 28 DPA) · DORA-annex-template.md (11-provision DORA Art. 30 annex, mandatory EU fintech/insurance since Jan 2025) · README.md (fintech vs B2B decision table, production-readiness checklist).
Profile editing (features/account/account.route.tsx: ProfileCard): display name (max 80 chars), email (re-verification via BetterAuth, confirmation to current address), avatar (three-step presign → PUT → confirm, client-side image/* + 5 MB guard, createUploadMutationOptions).
Password baseline (NIST SP 800-63B-4): min 15 chars, no complexity rules (shared/auth/auth.schema.ts). HIBP breach check at sign-up / change / reset (k-anonymity SHA-1 prefix, HIBP_TIMEOUT_MS=3000, fail-open). Contextual ban-list (shared/password-policy.ts): email local-part, display name, app name. All enforced via BetterAuth hooks.before. Port: shared/ports/password-breach.port.ts + shared/services/hibp-password-breach.service.ts.
Field UX: show/hide toggle + per-field hint. Server errors (breach / ban / wrong current password) render inline, not as toast.
- Email + password: required verification + reset (forgot-password → token via app URL).
- Magic link (passwordless email).
- Passkeys (
@better-auth/passkey, WebAuthn): registered and managed from/settings/account. - Two-factor (TOTP, backup codes): enable / disable from
/settings/account. Recovery codes: password-gated,clean-stack-recovery-codes.txtdownload. Backup-code fallback on/two-factor. On-use email viaBackupCodeUsedNotifier. - Active sessions: list & revoke from
/settings/privacy. - Bearer tokens alongside cookies: web = httpOnly cookie; Capacitor = bearer.
- Session cache (5 min signature-only; DB source of truth at expiry → instant revoke).
- Cross-tab sync via
BroadcastChannel(shared/auth/auth-broadcast.ts). Token routes outside auth gate: StrictMode-safeuseRefguard. - Layout gates (
_protected/_guest) inapps/app/src/router/*.tsx:ensureQueryData(sessionQueryOptions)inbeforeLoad.
Pages: sign-in, sign-up, verify-email, forgot-password, reset-password, magic-link, two-factor.
Org-scoped from the very first migration.
- Personal org auto-created on signup (
ensurePersonalOrgFor, self-heal indatabaseHooks). Slugpersonal-${uuid}, never deletable, never leavable. - Team orgs: slug auto-gen, email invitations, role-based members, ownership transfer.
- Auto-cleanup:
afterRemoveMemberdeletes empty non-personal orgs. - Capability-based authorization (
@packages/access-control: single SSOT). Three layers, same predicate: serverrequireOrgPermissionmiddleware · route gateensureOrgPermissioninbeforeLoad· UI<Can requires={…}>+useAuthorization().can(). - Dev-only
<AuthorizationDevTool>: live role-by-capability matrix, tree-shaken in prod.
Pages: /org/new, /settings/organization, /dashboard, /accept-invitation/$invitationId.
DB packages/drizzle/src/schema/email.ts: email_message table (durable outbox).
@packages/emails: in-repo React Email templates, rendered server-side at enqueue. TEMPLATE_IDS override: empty = in-repo template, non-empty = Resend dashboard template.
QueuedEmailService (packages/email/src/email.service.ts): enqueues rows in the ambient transaction of the open uow.run (atomic with the caller), else on the pool. Methods: sendTemplate, sendTemplateBatch, sendRaw, sendRawBatch.
EmailDeliveryWorker (packages/email/src/email-delivery-worker.ts): polls every 2 s, FOR UPDATE SKIP LOCKED, up to 300 rows per tick claimed by lease (FOR UPDATE SKIP LOCKED, database clock, attempts + 1, EMAIL_LEASE_SECONDS), chunked 100/request, 10 req/s (Resend team limit); settles are guarded by the claimed attempts, unstarted chunks are released on stop. Proven by check:email-lease and check:marksent.
Retry: decorrelated jitter (packages/outbox/src/jitter.ts). Ceiling (4th failed send) → status = 'dead', replayed every EMAIL_DEAD_RETRY_HOURS while younger than EMAIL_DEAD_REPLAY_WINDOW_HOURS; a permanent rejection → status = 'failed', terminal. Both emit email.delivery.exhausted, once, on the first transition. Idempotency: before the send, each chunk stores batch_key (sha256 of its row ids) on its rows. The Idempotency-Key sent is the sha256 of batch_key and of the entries sent. A retryable failure gives the whole chunk one shared retry delay, so the replay sends the same rows under the same key (Resend 24 h window). A claim that gets only part of a batch releases it instead of sending it. Per-recipient key: EmailRecipient.idempotencyKey when set, else ${options.idempotencyKey}#${index} (namespaced with #, shared by both the template and the raw batch path). Retention sweep: POST /internal/sweep-email-messages (HMAC-gated, two passes: EMAIL_MESSAGE_RETENTION_DAYS=7 for status = 'sent', cutoff measured from sentAt; EMAIL_MESSAGE_FAILED_RETENTION_DAYS=90 for status 'failed' and 'dead', cutoff measured from createdAt (enqueue time), not from the sent/failed transition, since the retry ceiling is minutes, a failed row's age at purge time is effectively "~90 days since it failed" in practice, but not literally guaranteed: a row enqueued 89 days ago that fails today is purged on the very next sweep). Single-flight: all six /internal/sweep-* routes (this one, sweep-audit-log, sweep-outbox, sweep-consents, sweep-webhook-delivery, sweep-notifications) take a time-boxed lease on sweep_lock before doing any work (sweepLockFor(label, spans) in shared/internal-routes/sweep-lock.ts), so a second cron tick (or an operator) hitting the same route while one is running is refused (skipped: true) instead of splitting the SKIP LOCKED rows between two runs. The lease's conditional UPDATE and its ownership-token fencing (an overrunning run must not delete a legitimate successor's row) only prove themselves against a real Postgres, so they're covered by pnpm --filter api check:sweep-lock (apps/api/scripts/check-sweep-lock.ts) rather than a mocked bun:test: re-run it after any change to sweep-lock.ts. Instrumented: every sweep route builds one per-request SweepSpans façade (packages/drizzle/src/sweep-span.ts, over di.IInstrumentation) and threads it through runRetentionSweep, the shared batched-delete helper purgeBatchWithTimeout (packages/drizzle/src/sweep-purge.ts), the dry-run count helper countEligibleWithTimeout (packages/drizzle/src/sweep-count.ts), and the lease. Batch and dry-run-count spans are budgeted at MAX_INSTRUMENTED_BATCHES (50/run, past which the queries still run untraced); the lease's acquire/release spans are never subject to that budget. See docs/CRON.md § "What a sweep looks like in tracing".
DNS hardening required before prod: SPF, DKIM (3 Resend CNAMEs), DMARC. Gmail/Yahoo/Outlook reject unauthenticated bulk senders since 2024-2025.
Three-step flow: presign → client PUT → confirm. Server is blind during upload. Provider-agnostic S3 SDK (region: "auto", forcePathStyle: true); boot-time fail-hard on localhost endpoint or default creds in prod.
- Owner-scoped keys:
<userId>/<scope>/<uuid>-<filename>. Download + confirm reject keys without the requesting user's prefix. - Confirm mandatory:
HeadObjectvalidates size/contentType, deletes on mismatch. (R2 has no presigned POST policy: hence PUT + confirm.) - Use-cases:
create-upload-url,confirm-upload,create-download-url,delete-upload. Routes:POST /uploads/presign,POST /uploads/confirm,POST /uploads/download,DELETE /uploads(accepts{ key }or{ url }: the server derives the object key from the public URL, so the front never learns the storage key format; used to delete the replaced object on an avatar re-upload, closing the storage leak). All four are gated byrequireCurrentPolicies. - Dev opt-in:
docker compose --profile storage up seaweedfs seaweedfs-init -d(host port8333).
Module: modules/uploads/ (@modules/uploads).
- Export:
POST /me/export, auth-gated, sync (walks user tables, uploads JSON to R2, emails a signed 7-day URL). Rate-limited 1/24h. - Delete:
POST /me/delete, 2FA-required + sole-owner preflight + 7-day soft-delete grace. CronPOST /internal/rgpd/process-pending-deletions(HMAC-signed) wipes PII and anonymizesmemberrows. PreflightGET /me/delete/preflightlists blocking orgs; UI shows Transfer/Leave CTAs. No implicit auto-transfer. - Cancel UX: during the grace window a banner on every shell page offers Cancel, next to the card in
/settings/account.deletedAt+pendingDeletionUntilare the only soft-delete columns in the schema. - Public
/legal/data-rights: what's deleted vs anonymized vs retained per legal basis.
Frontend cards: DataExportCard (→ /settings/privacy), RgpdDeletionCard + preflight list (→ /settings/account danger zone). Source: features/rgpd/.
Module: modules/rgpd/ (@modules/rgpd). The wipe revokes API tokens and erases staff data through IApiTokenRevoker and IStaffDataEraser, which the host binds to api-token and admin inside the same unit of work.
Events: user.deletion.{requested,cancelled}, user.deleted, user.export.{requested,completed} (compliance retention).
- Native
Bun.serve():bun buildprod (~7 ms cold),bun --hotdev. - Ordered shutdown (
apps/api/src/shared/shutdown.ts, steps inindex.ts): SIGTERM flips/readyz, waits the grace, thenhttp(drain, then cut what remains) →workers(4 runners in parallel, capped bySHUTDOWN_WORKERS_TIMEOUT_MS) →database-pool(main + rate-limit pools, skipped whenworkerstimed out) →telemetry-flush, the other steps capped bySHUTDOWN_STEP_TIMEOUT_MS, whole run capped bySHUTDOWN_DEADLINE_MS(validated against the worst case at boot); exit0only when every step finished. Detail: HEALTH-PROBES.md. - Hono RPC end-to-end types via
hcWithType(one client instance). - Pipeline (in order):
requestId→httpLogger(pino) →secureHeaders+cors→sessionMiddleware→auth.handlerfor/api/auth/*→app.onError. - CQRS: Commands → Use Cases; Queries hit Drizzle directly.
- DI via
inwire: type inference, no declared interfaces,AppDeps = typeof di.
- TanStack Router file-based via
virtualRouteConfig:apps/app/routes.tsdeclares the whole tree once, each leaf pointing at its<name>.route.tsx(route + page in one file,autoCodeSplitting: truechunks it as long as the component stays unexported). Layouts/gates as individual files underapps/app/src/router/.apps/app/src/router.tsxis a thincreateRouter({ routeTree })spreadingROUTER_DEFAULTSfrom@packages/web-kit(shared withapps/admin);routeTree.gen.tsis generated by the Vite plugin and committed (versioned, not gitignored) so a fresh clone type-checks without a prior build.defaultPreload: "intent"prefetches on hover. An unknown URL rendersNotFoundPage(defaultNotFoundComponent), a link back to/, insideAppShellwhen the session query holds a user and bare otherwise (also while the session is still loading). - TanStack Query: all server state. Mutations via
mutationOptionsfactories. - Forms:
react-hook-form+@hookform/resolvers/zod+ shadcnForm. MandatorydefaultValues. Schema split: same field validated loosely at capture, strictly at creation. - Theme: blocking
public/theme-init.js(symlink intopackages/web-kit/src/theme/) +@packages/web-kit/theme/use-theme+ View Transitions API circle reveal withprefers-reduced-motionfallback.
- Sticky header: org switcher, theme toggle, user menu.
- Contextual settings tabs filtered by capability (
SETTINGS_TABSdeclaresrequires+requiresOrg). - Command palette (⌘K):
NAVIGATION_ROUTESfiltered by capability.LEGAL_ROUTESsourced fromshared/legal-routes.ts(shared with legal footer).
Full shadcn/ui registry pre-installed. shadcn-pure rule: use real slots, no pt-6 / space-y-4 patches.
- Typography:
TypographyH1toH4,P,Lead,Large,Small,Muted,InlineCode,Blockquote,Listexports. Never raw<h1 className="text-5xl">. - Custom primitives (
@packages/ui/components/ui/, allasChild-compatible):NavLink(plain/pill/underline + active),BrandLink,TextLink,DestructiveActionDialog,ListRow(+ListRowButton),FormTextField(RHF Controller + show/hide on password fields, labels from the app),FormCheckboxField,DescriptionList,TypographyInline,CodeTextarea,CommandHint,BottomBanner,InitialsAvatar,PageFallback(one<main>and one<h1>, the action passed in so the router link stays in the app;ErrorFallbackand both not-found pages render through it). None of them holds copy: every rendered label is a prop the app fills throught(). - Theme tokens in
packages/ui/src/styles/globals.css@theme:classNamefor layout; colors/typography/radius in theme.
Primitives for business domain only (never DDD for billing / auth / gating: see root CLAUDE.md).
Result<T, E>(no throw in domain/application),Option<T>(no null/undefined for absence).Entity,Aggregate,ValueObject(zod-validatedprotected validate()),UUID,DomainEvent,BaseRepository,ScopedRepository,UseCase,QueryHandler,IUnitOfWork.- Events added in aggregate methods via
this.addEvent(...), dispatched after persistence in use cases.
- Postgres on
localhost:5433(dedicated port) viadocker compose up postgres -d. - Schemas: kit tables in
packages/drizzle/src/schema/*.ts, business tables in their owner'smodules/<name>/schemas/*.ts. - Org-scoped tables are NEVER queried without the owner in the
WHERE: aRepoScopethroughScopedRepository, or an explicitorganizationIdin a store (rule inapps/api/CLAUDE.md). TransactionService: the use case, service or route opensuow.run(...); the stores read the ambient transaction throughresolveExecutor(), notxparameter (rule inpackages/drizzle/CLAUDE.md).
- pnpm 12 + Turborepo TUI.
globalDependenciesbust caches onbiome.json/pnpm-workspace.yaml/.env*. - Biome (lint + format) · knip (unused exports) · jscpd (duplication) · Husky + commitlint (Conventional Commits) · lint-staged (Biome on staged files) · pre-push:
pnpm ci:check. - release-please (
release-please-config.json,.github/workflows/release.yml): one release PR kept open ondevelop, merging it tags, publishes the GitHub release and movesmainto the tag; ahotfix/*PR intomaindeploys at once, andsync-develop.ymlmergesmainback intodevelopso the fix ships in the next release. Both run with theaxelhamil-botGitHub App token so the release PR triggers CI. - Testing:
bun test(api) +vitest(packages, app). BDD style, mock at port level.
pino+hono-pino: JSON prod,pino-prettydev.infoprod /debugdev. Status-driven HTTP log level (5xx→error,4xx→warn). Every line carriesrequestId.- Single
app.onError(errorHandler):HTTPException→{ error: { code, message, requestId } }. No per-route try/catch. - Sentry (
@sentry/bunapi +@sentry/reactapp): NoOp withoutSENTRY_DSN. Captures ≥500 errors withrequestId/userId/orgIdtags, RGPD scrubbing shared by api and app in@packages/sentry-scrub(drops Cookie, Authorization, email, ip_address, failed-query params, URL query strings and emails in error text, contexts, extra, breadcrumbs and log lines, console breadcrumbs). Source maps in CI via@sentry/vite-plugin. - OTel + Prometheus
/metricsdeferred until a tracing/scrape backend exists. See./OBSERVABILITY.md.
pgBackRest PITR to R2 on the Coolify reference deploy; on a managed Postgres, the provider's PITR.
infra/docker/postgres-pgbackrest.Dockerfile: Postgres 18 pinned by digest with pgBackRest 2.60.0, built bydeploy.yml(clean-stack-postgres), never deployed by it.infra/pgbackrest/:pgbackrest.conf(stanzaclean-stack, S3 repository,aes-256-cbc, 4 full and 7 differential, async archive with a 5 GiB queue),postgresql.conf(archive_mode = on,archive_timeout = 60), secrets from the environment only;drill.sh+docker-compose.drill.yaml: local backup then PITR restore on a throwaway SeaweedFS behind TLS, row count checked..github/workflows/pgbackrest-drill.yml: monthly restore of the latest state and a point 24 h back with a read-only key, archiving off,select 1anddrizzle.__drizzle_migrationschecked, gated byPGBACKREST_DRILL_ENABLED.infra/vps/pgbackrest-run.sh+ timers frombootstrap.sh --pgbackrest: full Sunday, differential Monday to Saturday at 00:00 UTC,checkat 05:00 UTC, on every listed container oncepg_isreadyanswers, one run at a time underflock, heartbeat pings.docs/DISASTER-RECOVERY.md: RPO/RTO, 3-2-1, keys, schedule, restore to the side, PITR and in place, drills, managed alternative, optional weeklypg_dump.
VPS with Coolify v4, its API on the tailnet only, Cloudflare in front. Runbook: ./DEPLOY-COOLIFY.md.
infra/coolify/services.ts: desiredapi,app,adminapps per environment (port, health check, stop grace 45 s onapi, literal env, tasksrgpd-sweep47 2 * * *,sweep17 3 * * *,flush-notification-emailsevery minute), imagesghcr.io/<owner>/clean-stack-<app>.infra/coolify/apply.ts: dry run by default,--applywrites, never deletes, reports missing secrets by key presence and never prints a value, treats a value hidden by a token withoutread:sensitiveas unknown, rewrites every literal on--apply.infra/coolify/deploy.ts+gates.ts: deployapi,app,adminby sha256 digest in that order, wait for an idlesweepandrgpd-sweep, writeGIT_SHA, restore the previous image andGIT_SHAof a failed app, stop at the first skip or failure inside a 45 min budget, report where each app was left; then/readyzand both/healthmust answer 200..github/workflows/deploy.yml: after a green CI on a push todevelop(staging) ormain(production), gated byCOOLIFY_DEPLOY == 'on';rollback.ymlredeploys previous digests without building.infra/coolify/*.test.ts: client, plan, deploy order, failures and restores, gates, the workflow guards parsed from the YAML, and the drift between the api env schema and the declared api env.infra/vps/:bootstrap.shfor a Debian 13 host (unattended upgrades, Docker, Tailscale SSH, firewall unit),host.nft(inbound only loopback, established,tailscale0, the Coolify bridge and Cloudflare on 80 and 443, the same in forward against published ports), guarded byhost.test.ts(rules,nft -cin a user namespace, timers throughsystemd-analyze).
Zero plumbing post-clone: declare an event in @packages/events, call addEvent() in the aggregate, run via uow.run(): audit log row + webhook fanout + in-process onEvent(...) handlers fire automatically.
- Outbox
outbox_event: UUID v7 PK (the event id, returned byemitEvent),dead_atand per handlerdeliveriesjsonb, partial indexes on live pending, dead retry and dispatched rows;pg_notifysent byenqueueinside the same transaction. - Dispatcher and relay (
packages/outbox/src/outbox-dispatcher.ts,outbox-relay.ts): in-process Bun worker,pg.ClientLISTEN with backoff reconnect and keepalive, wake at the next due retry, 30s poll fallback. Claim by lease in one statement (FOR UPDATE SKIP LOCKED, multi-instance safe, no lock held while handlers run), named handlers (audit-log,webhook-fanout,notification-fanout, thenonEvent(type, handlerName, ...)) each in its own short transaction with a time budget, settle guarded byattempts, jittered retry of the failed handlers only, dead afterOUTBOX_MAX_ATTEMPTSwith oneoutbox.event_deadand a replay everyOUTBOX_DEAD_RETRY_HOURS. Proven bycheck:outbox-lease. - Audit log (
audit_log): append-only (SOC2 / ISO 27001).operational(90d) vscompliance(7y) retention. Optional tamper-evidence hash chain (AUDIT_TAMPER_EVIDENCE). Operator UI on the back office audit screen (apps/admin, filters, pagination, chain verify): gatedrequireStaffPermission("audit.read"). - Outbound webhooks: HMAC-SHA256 signed (Stripe-style), AEAD-encrypted secrets (XChaCha20-Poly1305 + HKDF per org), decorrelated jitter retry (1m/5m/30m/2h/12h), claimed by lease (
WEBHOOK_LEASE_SECONDS, database clock, settle guarded by the claimed attempt, release on stop), dead-letter on the 4th failed send then replayed everyWEBHOOK_DEAD_RETRY_HOURS(terminal when the endpoint is missing or disabled or the destination is refused), manual replay. Proven bycheck:webhook-lease. See §Outbound webhooks for the full front-end surface. - Catalog
@packages/events: 100 events (35 public / 65 internal) with Zod payloads +RETENTION_MAP. BetterAuth bridge alone covers 25 events; other services add the rest. - Request correlation:
X-Request-Idthreaded intooutbox_event.metadataandaudit_log.request_idviaAsyncLocalStorage.
See ./EVENTS.md for the DX guide (add an event, build a handler, multi-tenant safety, BetterAuth bridge, HMAC verification).
Deploy-safe perimeter: rate-limit, strict CSP, and stateless CSRF, wired before any business feature.
Rate-limit (apps/api/src/shared/middleware/rate-limit.middleware.ts): global policy + 8 per-auth-route burst policies. IETF RateLimit / Retry-After headers. Fail-closed on auth: store outage → 503; global fail-open. TRUSTED_PROXIES=private (Coolify, Fly) or CIDR. Store: memory (dev) → Postgres (multi-replica).
CSP (Caddy + Vite, not a Hono middleware): per-request nonce via Caddy {http.request.uuid} forwarded to Vite via html.cspNonce. 'strict-dynamic': no 'unsafe-inline', no host allowlist. POST /csp-report (IP-rate-limited, Cross-Origin-Resource-Policy: cross-origin).
CSRF (apps/api/src/shared/middleware/csrf.middleware.ts): origin-allowlist on unsafe methods, stateless (no token, no cookie). Allowlist = CORS_ORIGIN. Bearer-skip for Capacitor / PATs / HMAC internal calls.
Hardened headers (Caddy): HSTS (1 year, includeSubDomains), frame-ancestors 'none', nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (camera/mic/geo off).
Abuse prevention: disposable-email block (~90k static list + DNS MX, fail-open, DISPOSABLE_EMAIL_BLOCK_ENABLED) + HIBP-hit telemetry event (security.password.breached: request already rejected, this makes it observable in the audit trail).
Behavioural signals and captcha (C.1 S5b and S6), all off by default, thresholds to calibrate on real traffic: country and ASN come from IIpGeoService (HeaderIpGeoService, GEO_COUNTRY_HEADER, GEO_ASN_HEADER, read only when named and only from a trusted proxy). SecuritySignalsService: impossible travel on sign-in (IMPOSSIBLE_TRAVEL_WINDOW_MINUTES, session.country, security.impossible_travel.detected, never blocks) and deny-list on sign-in and sign-up (SECURITY_DENY_COUNTRIES, SECURITY_DENY_ASNS, 403 SECURITY_GEO_FORBIDDEN, security.geo.denied). Accounts-per-visitor cap (ACCOUNTS_PER_VISITOR_MAX, ACCOUNTS_PER_VISITOR_WINDOW_SEC): a requireRateLimit policy on /sign-up/email only, counting attempts; accounts created through a magic link, OAuth or SSO are not counted. Captcha: ICaptchaService (SiteverifyCaptchaService for Turnstile or hCaptcha, NoopCaptchaService when CAPTCHA_SECRET_KEY is unset), asked by requireRateLimit above 80 % of the sign-in and sign-up windows, CAPTCHA_REQUIRED without a valid token, fails open on a provider outage. Widget left to the cloner: INTEGRATIONS.md.
Prod boot guard: api process.exit(1) on missing CORS_ORIGIN (empty allowlist = CSRF no-op).
Events (operational retention): 7 events covering rate-limit, CSP violation, CSRF rejection, disposable-email block, HIBP breach telemetry, impossible travel, and geo denial.
Per-organization subscriptions, zero billing backoffice. Stripe Checkout for upgrades, Stripe Billing Portal for management.
State: subscription table (webhook-synced): never organization.metadata (untyped, diverges under out-of-order webhooks). Hybrid catalog (modules/billing/src/domain/entitlements.ts): prices + copy in Stripe Products; entitlements + maxMembers in typed ENTITLEMENTS map; metadata.tier is the join key.
Three gate axes (transferable pattern for any premium feature):
- Role:
billing:["read","manage"]in@packages/access-control. - Seats:
ENTITLEMENTS[tier].maxMembersenforced inbeforeAddMember/beforeAcceptInvitation/beforeCreateInvitation(organization-seats.ts,402 ORGANIZATION_SEAT_PAYMENT_REQUIRED). Unlimited =null. - Tier/feature:
requireFeature/requirePlan(back,402);useEntitlements()/<FeatureGate>/<PlanGate>(front). Free tier: unlimited orgs, 3 members.
Module modules/billing/: BillingCatalogService (free-only when STRIPE_SECRET_KEY unset), EntitlementsService, BillingPortalService (resolves the org's Stripe customer, BILLING_NOT_FOUND without a subscription active, trialing, past_due or unpaid, the same read as hasSubscription, so a past due customer can change their card while tier resolution stays on active and trialing) behind IBillingPortalGateway (StripeBillingPortalGateway), DrizzleSubscriptionReadStore, StripeCatalogSource. Routes: GET /billing/plans, GET /billing/subscription, POST /billing/checkout, POST /billing/portal. Frontend apps/app/src/features/billing/: /pricing · /settings/billing · useEntitlements() · <FeatureGate> · <PlanGate>.
Quota gating (B.2, dormant): ENTITLEMENTS[tier].quotas + assertQuota/requireQuota middleware + quota-reservation.ts + quota_usage table + useQuota/<QuotaGate>. See docs/QUOTA-GATING.md. The quotas module ships no route (never mounted): see docs/SURFACE.md for why it can't appear in the route inventory below.
Env: STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET.
Full operator surface for webhook endpoint management and delivery inspection, plus a public developer event reference.
Back-end hardening:
- SSRF guard: URL validated at create/update AND re-validated at delivery (anti-DNS-rebinding). Blocks loopback, RFC1918, link-local, CGNAT, cloud-metadata IPs. Rejects with
WEBHOOK_URL_FORBIDDEN. - Dual-secret rotation (
POST /settings/webhooks/:id/rotate-secret): grace windowWEBHOOK_SECRET_GRACE_HOURS(default 24h). During grace both secrets sign;x-webhook-signaturecarries multiplev1=values. - Delivery timeline:
webhook_delivery_attempttable: request/response headers + bodies (capped atWEBHOOK_RESPONSE_CAPTURE_BYTES, default 4096),durationMs. Exposed viaGET /settings/webhooks/:id/deliveries/:deliveryId. - Auto-disable failing endpoints: after
WEBHOOK_AUTO_DISABLE_AFTER_DAYS(default 5) with ≥WEBHOOK_AUTO_DISABLE_MIN_FAILURES(default 2) consecutive failures. Re-enable is manual. Emitswebhook.endpoint.disabled. - Wildcard subscriptions:
"*","<group>.*", or exact event names. Internal events never fan out. - Test event (
POST /settings/webhooks/:id/test): also auto-fired on endpoint creation.
Module: modules/webhooks/ (@modules/webhooks), with its SSRF guard and the AEAD of secrets at rest; the private ranges it refuses are @packages/http/private-ranges.
Frontend apps/app/src/features/webhooks/: /settings/webhooks: endpoint list (enabled / paused / auto-disabled badges), create/edit Sheet with EventTypePicker (namespace groups + wildcards, same SSOT as the public catalog), cursor-paginated delivery list, per-delivery timeline drawer, one-shot secret reveal, rotate + test actions.
Public event catalog apps/app/src/features/developers/: /developers/events (no auth): all public events (28 since the C.4 visibility allowlist) with group, retention, expandable Zod JSON schema, Node.js signature-verification snippet.
UX hub consolidating privacy, compliance, and session surfaces into /settings/privacy. Zero back-end changes, zero migrations, zero new events.
Source apps/app/src/features/privacy/. Page composition: <PolicyAcceptanceCard> + <ConsentSettings> + <DataSourcesCard> (shared/sub-processors.config.ts) + <DataExportCard> + <SessionsCard>.
Relocations: DataExportCard + SessionsCard moved from account.page.tsx (since renamed account.route.tsx); RgpdDeletionCard stays on account.route.tsx (danger zone); features/danger/ deleted (merged into features/organization/components/).
Platform operator tooling: ban/unban users, change platform role, force password reset, revoke sessions, impersonate with justification. Every action emits a compliance-retained admin.* event.
Staff model: roles manager, support, auditor and the permission catalog live in @packages/access-control. staff_member and staff_permission_override (grant or deny, with expiry) hold the data; user.role is only a mirror the auth plugin needs, read by no gate, and the plugin's own /api/auth/admin/* routes answer 404. PLATFORM_ADMIN_IDS is a permanent break-glass: a listed id resolves to manager, and its first request inserts a row (ON CONFLICT DO NOTHING), the real insert alone announcing admin.staff.added. A row that came from the env stops granting access once the id leaves the list. An impersonated session has no staff access. requireStaff and requireStaffPermission (shared/middleware/staff.middleware.ts) recheck on every request. GET /admin/me, GET /admin/staff, PUT /admin/staff/:id/role (adds or changes), DELETE /admin/staff/:id: the last manager and one's own access cannot change. Staff role changes only go through these routes: PUT /admin/users/:id/role is gone, and the back office shows no account role. users.moderate stops at staff: acting on an active or listed staff member also takes staff.manage, and the last manager cannot be banned, under the same lock as the guard, which does not count a banned, deleted or leaving manager. A revocation or a role change that loses users.impersonate deletes the sessions that person impersonates through, in the same unit of work; the request check still refuses a session whose impersonator lost the permission another way (env removal, ban), except on the stop route. An account wipe revokes the staff row; the last manager cannot request deletion unless listed in the env, which stays the recovery. Proven by check:admin-staff. Override writes come with the team screen.
Impersonation ends: admin.impersonation.stopped carries endReason (stopped, expired, revoked) and is emitted once per ended session, in the unit of work of the delete: the session delete.before hook for every session the auth library deletes (stop and sign-out as stopped, anything else as revoked), POST /internal/sweep-admin-impersonations for expiry, and every session deletion of moderation, staff changes and account wipes as revoked. The one hour TTL lives in shared/auth/impersonation-ttl.ts, shared by the start route and the auth plugin.
Atomic moderation: AdminModerationService writes user and session directly in one uow.run with the event: ban (also ends the sessions the banned user impersonates through), unban, session revocation, proven by check:admin-atomicity, including a real sign-in refused after the ban. impersonateUser and stopImpersonating only exist in the auth library and run last inside the unit, after the emit. A password reset commits its revocation and event first, then asks for the mail: the library swallows mail errors and its hook would join the ambient transaction, so deliverPasswordReset reads a request-scoped holder set at the end of sendResetPassword and returns ADMIN_PASSWORD_RESET_UNAVAILABLE for a retry.
Step-up: POST /admin/step-up {code} (limited by AUTH_TWO_FACTOR_POLICY) stores admin_step_up(session_id, verified_at), cascade on session delete, valid ADMIN_STEP_UP_TTL_SECONDS (default 300), exposed as stepUpValidUntil by GET /admin/me. requireStepUp and assertStepUp answer 403 ADMIN_REAUTH_REQUIRED on permanent ban, staff role change, revocation and permission overrides, impersonation start and SSO enforcement off, and fail closed without 2FA or under impersonation. admin_totp_counter keeps the last accepted TOTP step per user: ITotpVerifier.consume advances it at two-factor sign-in, RGPD deletion and step-up, so a code used on one cannot be replayed on another; a failure to record at sign-in is captured and does not block. A timed ban is capped at 90 days, anything longer is a permanent ban with step-up. Without 2FA a sensitive action answers ADMIN_STEP_UP_MFA_FORBIDDEN, even with PLATFORM_ADMIN_REQUIRE_MFA=false. sessionMiddleware refuses a currently banned user, closing the sign-in versus ban race. The step-up limit is per IP and per user, 5 per 15 min. StepUpGate (apps/admin) opens StepUpDialog (packages/ui) from watchStepUpRefusals (packages/web-kit), then the user redoes the action. Proven by check:admin-step-up.
Permission overrides: GET, POST and DELETE /admin/staff/:id/overrides (staff.manage, step-up, four-eyes), at most one live override per person and permission, optional expiry, admin.staff.override_{set,revoked} in the same unit of work. A write that removes users.impersonate ends the impersonations that person runs, and a manager denied staff.manage no longer counts in the last-manager guard.
Four-eyes: off by default (ADMIN_APPROVAL_ENABLED, a solo operator has nobody to approve). ADMIN_APPROVAL_ACTIONS lists user.ban_permanent, staff.role_change, staff.revoke, staff.permission_override, an unknown value refuses the boot; ADMIN_APPROVAL_TTL_HOURS (default 72). A listed route, past its existing permission and step-up and the same read-only refusals as the direct call (staff target, last manager, listed id, self-change, non-staff revoke), answers 202 {approvalId} and stores admin_approval (CHECK decided_by <> requested_by; one pending row per action and subject; the same person repeating the same change answers the open id with no second event, anything else is refused with 409 ADMIN_APPROVAL_ALREADY_PENDING_CONFLICT carrying that id). Any ban of a staff member and a timed ban over a permanent one take the step-up and the ban gate like a permanent ban; /unban stays outside. A role change stores the role it starts from and fails at execution if it moved. POST /admin/approvals/:id/decide {decision, note} takes approvals.decide, the action's own permission at decision time and a step-up, and refuses the subject of the request. A decider without power over the target leaves the row pending; a requester who lost the action's permission closes it as failed; one uow.run claims the pending, unexpired row, revalidates params with the route's DTO and runs the same service method, so last-manager and staff-target guards apply at execution. A refused action rolls back, a second unit marks it failed with the code. POST /admin/approvals/:id/cancel withdraws one's own, GET /admin/approvals and /:id serve approvers, expiry runs on POST /internal/sweep-admin-approvals, the bell and SSE reach approvers and requester. Deleting a user cancels their open requests and erases the free text of requests about them; ids stay on the row, as in the audit log, with no foreign key. Impersonation stays outside. Nobody gives away a permission they do not hold (ADMIN_STAFF_GRANT_NOT_HELD_FORBIDDEN), requester and decider alike; lifting a denial counts as giving. Proven by check:admin-approval.
Operations (apps/admin/src/features/operations/, modules/admin/src/infrastructure/http/ops.routes.ts, OpsService, StripeReplayService): GET /admin/ops/dead-letters, /sweeps and /stripe-events take ops.read; POST /admin/ops/outbox/requeue {ids} and POST /admin/ops/stripe-events/:id/replay take ops.manage and a step-up. A requeue sets next_attempt_at = now() on dead rows so the existing claim picks them up, leaves attempts alone, skips a row due within OUTBOX_LEASE_SECONDS (its lease lives in that column, overwriting it would deliver twice) and emits one admin.ops.outbox_requeued per action. Dead emails and dead-letter webhooks are read-only counts. sweep_run holds one row per label, upserted by runSweepRequest (withSweepHeartbeat): a dry run or a lease refusal keeps the last real run, a failed write is captured and never fails the sweep; rgpd-sweep and flush-notification-emails call the same wrapper directly, each with its own reading of the answer. stripe_event is written from the plugin's onEvent (no payload, swept after STRIPE_EVENT_RETENTION_DAYS). A replay claims the row by a conditional update (replay_claimed_at, a second replay gets ADMIN_STRIPE_REPLAY_CONFLICT), retrieves the event, swaps a subscription or invoice.payment_failed snapshot for the current object so an old event never rolls state back, re-signs it and passes it through the plugin's own webhook handler in process, outside any unit of work, then writes the row and admin.ops.stripe_event_replayed (with its outcome) in one transaction. The plugin swallows errors in its subscription handlers, so onEvent compares the stored subscription with the event and, on a mismatch, with the current state at Stripe: only a row that differs from both is recorded failed, otherwise received. A missing row is failed only on created or deleted, for a configured price and a known customer: the plugin refuses the others on purpose, and an updated ahead of its created leaves the alarm to that created. Only the types the platform processes replay, the list says which per row, others answer 409 ADMIN_STRIPE_REPLAY_BLOCKED. Events emitted from a Stripe event take an id derived from event.id, so a replay announces nothing twice; a paid invoice announces no failure. Unconfigured Stripe answers 503 ADMIN_STRIPE_UNAVAILABLE. Proven by check:admin-ops.
Plan override (features/orgs/components/plan-override-section.tsx, PlanOverrideService, plan_override): POST and DELETE /admin/orgs/:id/plan-override take billing.override and a step-up, no four-eyes. An end date and a reason are required. EntitlementsService.getEntitlements is the single read: an override that is neither revoked nor past expires_at (database clock, no sweep) replaces the tier, so the seat cap, requireFeature, requirePlan and quotas follow and the plan falls back the moment it ends. A failed read fails closed to free like the subscription read. status keeps the subscription's, or says override when none backs it. A partial unique index holds one open row per organization; a set closes the open row, expired or not, then inserts, and a colleague's concurrent set answers 409 ADMIN_PLAN_OVERRIDE_ALREADY_SET_CONFLICT. The org 360 shows the plan the gates apply with the override and its end. Proven by check:admin-accounts.
Staff notes (shared/components/notes-section.tsx, StaffNoteService, staff_note): append-only, on the account and organization 360. GET /admin/users/:id/notes takes users.read, GET /admin/orgs/:id/notes takes orgs.read, POST on both takes notes.write (manager, support). The row holds one foreign key per subject type: an organization's notes cascade from its row on every path that deletes one, including the auth library's route, which opens no transaction. The RGPD wipe anonymizes the user row instead of deleting it, so it deletes that user's notes in its own transaction, and writing a note locks the subject row and refuses an erased account, so a note can neither outlive nor follow an erasure. Staff only, never in the data export. Proven by check:admin-accounts and check:wipe-rollback.
Inbox (features/home/, InboxService, GET /admin/inbox): the back office home. Live pending approvals, oldest first (20 and a total), behind approvals.decide; dead events, emails and webhooks and the jobs whose last run failed, behind ops.read. A closed section answers null, so the screen never shows it. Every row links to the screen that acts on it. Refreshed by staff notifications and on reconnect through the query scopes of admin-query-scopes.ts: a dead queue or a failed job announces nothing of its own, so these two rows move with the next staff notification, reconnect or mount.
Saved views (shared/components/saved-views-bar.tsx, SavedViewService, saved_view, @packages/access-control/staff-views): private named filters on accounts, organizations and audit. GET and POST /admin/views, DELETE /admin/views/:id. The filter schema of each screen is shared with the screen itself and strict, so a filter from another screen is refused at the boundary; a screen the permissions do not open takes no view. Saving under an existing name replaces. Ownership lives in the WHERE: another's view reads as ADMIN_VIEW_NOT_FOUND. Erasure takes the views in the wipe transaction. Events admin.view.{saved,deleted} carry the name of the screen, never the filters. Proven by check:admin-workspace.
Palette (shared/components/admin-palette.tsx, skeleton in @packages/ui/components/ui/command-palette): Ctrl K or Cmd K opens sections the permissions allow, saved views, and a debounced search of accounts and organizations from 2 characters. The app palette sits on the same skeleton.
Backend modules/admin/src/:
AdminQueryService: read-only platform user and org queries.AdminActionService: ban, unban, role-change, force-password-reset, revoke-sessions. Instantiated inroutes.ts(not inwire: real import cycle; deps fromdi).DrizzleAdminUserStore/DrizzleAdminOrgStore: org-scoped reads with pagination and search.admin-impersonation.routes.ts:POST /admin/impersonation/:id/start(reason required, ticketRef optional) +POST /admin/impersonation/stop.relay-set-cookie.ts: proxies BetterAuthSet-Cookieheaders to the app client.shared/middleware/impersonation-blocklist.ts: BetterAuthbeforeHookblocking BetterAuth-owned auth paths: password/email change, MFA, passkey, social account link/unlink (/link-social,/unlink-account), session revocations (/revoke-session,/revoke-sessions,/revoke-other-sessions), and the/adminprefix.shared/middleware/deny-impersonated.middleware.ts: per-mutation Hono middleware blocking 11 custom routes (RGPD export/delete, all webhook mutations, billing portal, andPOST /me/policies/accept: an admin must not countersign legal terms on behalf of the user); returns 403IMPERSONATION_ACTION_FORBIDDEN.
Frontend (apps/app, all UI copy in English):
shared/components/impersonation-banner.tsx: non-dismissable, live countdown, mounts in_shell; ending the impersonation returns to the account in the back office (VITE_ADMIN_URL), or to/dashboardwithout one.shared/auth/is-impersonating.ts: derives impersonation state from the session payload.router/should-redirect-to-legal-accept.ts: returnsfalsewhenisImpersonating(session)is true, so the legal acceptance gate does not trap the admin on a page whose only exit is to consent.shared/api/mutations/stop-impersonation.ts: mutation factory used by the banner.
Transparency email: application/event-handlers/notify-impersonated-user.ts: onEvent(ADMIN_IMPERSONATION_STARTED) handler, tolerant (failure captured, does not abort).
Events (7 new, all compliance retention): admin.impersonation.{started,stopped}, admin.user.{banned,unbanned,role_changed,password_reset,sessions_revoked} → catalog 55 → 62 total.
Machine-to-machine access with scoped, expirable tokens. Tokens are shown once at creation and never stored in plaintext.
Format: clean_ prefix (configurable via API_TOKEN_PREFIX) + 44 base58 body characters + 6-character CRC32 checksum. The checksum rejects malformed tokens before any database call. modules/api-token/src/domain/api-token-crypto.ts: generateToken, parseToken, hmacToken.
Storage: HMAC-SHA256(pepper, raw_token) in a unique-indexed column (api_token.tokenHmac). The pepper (API_TOKEN_PEPPER) is a server-side secret: a database dump alone yields no usable tokens. pepperVersion column enables rotation without downtime via API_TOKEN_PEPPER_PREVIOUS: tokens found by the old pepper are transparently rehashed on next use.
Schema modules/api-token/schemas/api-token.ts: api_token(id, userId FK, organizationId FK nullable, name, tokenHmac unique, pepperVersion, tokenStart, scopes jsonb, lastUsedAt, expiresAt, revokedAt, revokedReason, createdAt, updatedAt).
Backend modules/api-token/:
ApiTokenService:create,list,revoke.listtakes aTokenOwner({ kind: "personal" }or{ kind: "orgAndPersonal" }): a caller with an active organization sees that organization's tokens and their own org-less personal tokens together; with no active organization, only the personal ones. One write per token (no per-request DB write:lastUsedAtupdated via bucket:WHERE last_used_at < now() - interval '15 min').DrizzleApiTokenRepositoryimplementsScopedRepository<ApiToken, RepoScope>(wrong-owner →Option.none()/NOT_FOUND, never 403).revokeTokensOnMembershipLostevent handler: cascades revocation onorg.member.removed.- Routes
modules/api-token/src/infrastructure/http/api-token.routes.ts:GET/POST /settings/tokens,DELETE /settings/tokens/:id(session-auth;denyImpersonatedon writes). - Scanning route
modules/api-token/src/infrastructure/http/scanning.routes.ts:POST /api/token-scanning/github(ECDSA P-256 signature verification against GitHub's live public-key endpoint; revokes + emails owner on match).
Public API sub-app apps/api/src/public-api/: a separate Hono instance mounted at /api/v1, outside AppType. sessionMiddleware skips /api/v1/* entirely; the sub-app mounts requireApiToken on all routes. Token-reachable routes: GET /api/v1/me, GET /api/v1/organizations, PATCH /api/v1/me. Why a separate sub-app rather than a flag on existing routes: any route that mounts both requireAuth and requireApiToken eventually drifts: a new route gets one but not the other. The sub-app makes the contract structural: what is in /api/v1 is reachable by token, everything else is session-only. The global rate-limit policy is also bypassed at /api/v1/* and replaced with per-token + per-IP axes. The integrator-facing reference for this surface (routes, token format, scopes, error codes, rate-limit headers) is docs/PUBLIC-API.md; this entry documents why the sub-app exists, that one documents how to call it.
Middleware apps/api/src/shared/middleware/api-token.middleware.ts: requireApiToken(deps, { scopes }). Validates checksum, resolves HMAC (with previous-pepper fallback + transparent rehash), checks expiry, checks ban, sets c.var.{user, tokenScopes, orgId, apiTokenId}.
Scopes API_SCOPES = ["read:profile", "write:profile", "read:organizations"]: typed const. Per-token subset; no wildcard *. Anti-escalation is structural: token management lives outside /api/v1, so a token can never mint a token.
Access-control @packages/access-control: apiToken: ["create", "read", "revoke"] in the org capability statement.
Frontend apps/app/src/features/api-tokens/:
api-tokens.{route,page}.tsx:/settings/tokens(within Settings tabs).forms/token-form.tsx: name + scope checkboxes + optional expiry. Created token shown once via<SecretRevealDialog>.api/api-tokens.{queries,mutations}.ts: list + create + revoke.
Event visibility packages/events/src/visibility-map.ts: 100-event catalog with explicit public / internal classification (35 public / 65 internal). Three consumers: WebhookFanoutSubscriber (only fans out public events), /developers/events catalog page (only lists public events), and webhook subscription picker (only offers public events).
Events (3, operational retention): api_token.created (public), api_token.revoked (public), api_token.used (internal, sampled via bucket).
Email packages/emails/src/components/api-token-leaked.tsx: template sent to the token owner when GitHub Secret Scanning reports a match.
Env apps/api/.env.example: API_TOKEN_PEPPER (required prod, min 32 chars), API_TOKEN_PEPPER_PREVIOUS (rotation), API_TOKEN_PEPPER_VERSION (default 1), API_TOKEN_PREFIX (default clean_), API_TOKEN_MAX_EXPIRY_DAYS (default 365), API_TOKEN_LAST_USED_BUCKET_MIN (default 15).
Persistent inbox behind a bell, real-time over SSE, three-level preferences. No new event type: D.3 consumes the catalog.
Catalog projection packages/events/src/notification-map.ts: third projection after visibility-map (webhooks) and retention-map (purge). 27 of the 100 events are notifiable; an absent event produces nothing. Each entry declares audience, category, and optionally forced / groupBy / dedupWindow. forcedLevelOf(category) reports whether a category is all / some / none forced: security is fully forced, billing only partly, which a per-category boolean could not express.
Audience by capability, never by role tuple: "self" | "actor" | "org:all" | { can: OrgPermissions }. ORG_ROLES.filter(authorizeRole) resolves at boot, leaving WHERE member.role = ANY($1).
Schema modules/notifications/schemas/notification.ts: notification(id, userId FK, organizationId FK nullable, category, eventType, groupKey, dedupKey, payload jsonb, readAt, emailPendingAt, emailSentAt, createdAt): emailPendingAt is a due instant, not an occurrence one: the fan-out resolves the recipient's frequency preference and stamps the end of the chosen window there, which is what makes hourly / daily real without the flush knowing about preferences at all + notification_preference(scope 'user'|'org', scopeId, category, channel, enabled, frequency, locked). Five indexes, three partial: unread count, email-pending, and the dedup unique index (userId, dedupKey) WHERE dedup_key IS NOT NULL. organizationId nullable is a documented exception to org-scoping rule #3: user.password_changed belongs to no org.
Fan-out modules/notifications/src/infrastructure/services/notification-fanout-subscriber.ts: the built-in notification-fanout handler beside audit-log and webhook-fanout, run by the outbox relay in its own short transaction and retried until it succeeds; every row carries a dedup_key (event:<eventId> or the dedupWindow bucket) inserted with ON CONFLICT DO NOTHING, so a redelivery adds nothing. One INSERT ... SELECT over the recipient set, never N inserts in a loop. The recipient set is either a single user or SELECT user_id FROM member WHERE ...; everything downstream is shared.
Payload allowlist packages/events/src/notification-map.ts: every notifiable event declares payloadFields, and GET /notifications returns publicNotificationPayload(eventType, payload) rather than the stored jsonb. The row keeps the whole event payload; what crosses to the browser is chosen. Required and empty-is-valid on purpose: without the list, any field added to an event payload later (an invitation token, a storage key) would ship to the recipient's browser by default.
Preference cascade, applied in that same statement via one LEFT JOIN notification_preference per scope per channel:
org row with locked=true > user row > org row (unlocked default) > enabled
forced: true short-circuits all four and emits no joins at all. The in-app decision is the WHERE; the email decision is a CASE filling emailPendingAt, so a user who keeps in-app but drops email simply gets a row with no pending mail. The same CASE also resolves the recipient's frequency through the same cascade and stamps the due instant (the end of the chosen window) rather than the occurrence one, which is the whole of the digest-window feature (digestDueAt, modules/notifications/src/application/services/digest-schedule.ts; anchors on the next full hour / next NOTIFICATION_DIGEST_HOUR_UTC, default 8). forced events emit no frequency branch at all, so a security alert is never deferred. Verified against Postgres via pnpm --filter api check:fanout (apps/api/scripts/check-fanout-preferences.ts, 8 cases) and pnpm --filter api check:digest (apps/api/scripts/check-digest-window.ts, 34 cases spanning the three cadences, the empty window, grouping, and the flush's single-flight advisory lock): a mocked transaction evaluates no WHERE, so unit tests structurally cannot cover this, and the repo has no DB integration harness. Re-run both after touching the fan-out.
Real-time modules/notifications/src/infrastructure/services/notification-stream-hub.ts + GET /notifications/stream: Postgres triggers pg_notify('notification_changed', user_id) on insert and on the read_at transition (so a read on one device converges the badge on every other), one LISTEN connection per instance (never per client), streamSSE with a 25 s heartbeat, capped at MAX_STREAMS_PER_USER. The stream carries a signal, never data: the client's only reaction is invalidateQueries(["notifications"]), which makes reconnection self-healing and removes Last-Event-ID, replay, and merge logic entirely.
Routes modules/notifications/src/infrastructure/http/notifications.routes.ts: GET /notifications, GET /unread-count, POST /read, POST /read-all, GET|PUT /preferences, GET|PUT /org-preferences (the org pair gated by requireOrgPermission({ organization: ["update"] })), GET /stream. Writes carry denyImpersonated.
Crons on the /internal/* rail: flush-notification-emails (1 min, batch capped at 5000) and sweep-notifications (read rows only: an unread notification outlives retention).
Frontend: everything cross-cutting is in apps/app/src/shared/notifications/, because shared/ may not import features/ (the bell mounts in app-shell) and the preference matrix has two route-owning consumers that may not import each other:
notification-bell.tsx/notification-item.tsx: bell, badge, dropdown inbox. Row labels reuseEVENT_DESCRIPTIONS; grouped rows read "and N more". History is a singleuseInfiniteQueryover the cursor (not one query per cursor), with a "load more" control appending pages rather than replacing them.use-notification-stream.ts:fetch+ReadableStream, notEventSource(which cannot carry anAuthorizationheader, breaking any bearer-authenticated client). Exponential backoff with jitter,AbortControlleron unmount.handleStreamChunkis pure and returns its trailing partial frame, because a stream splits SSE frames wherever it likes.notification-broadcast.ts: mark-as-read propagates cross-tab overcreateBroadcastChanneland applies to the cache without refetching.preference-matrix.tsx/build-preference-matrix.ts: the grid is rebuilt for all four categories from the explicitly-stored rows alone; an absent cell renders as enabled/immediate, which is what the fan-out applies. A fully-forced category renders disabled with its reason, never hidden.- Polling is a fallback only:
refetchInterval: connected ? false : 30_000plusrefetchIntervalInBackground: false, so a forgotten tab whose stream died stops polling.
apps/app/src/features/notifications/ keeps only /settings/notifications (route + page); the org defaults card lives in features/organization/components/org-notification-defaults-card.tsx behind <Can requires={{ organization: ["update"] }}>: a route under orgScopeLayout would collide, since it flattens children under settings/.
Events: notification creation deliberately emits nothing (it would loop with its own subscriber). Every other persistent state change here does emit: preference mutations (notification.preference.updated, notification.org_preference.updated) and the read transition (notification.read, subject == actor, so userId alone carries §7).
Dev seed apps/api/scripts/seed-dev-user.ts (pnpm --filter api db:seed): creates a verified account through auth.api.signUpEmail, so it crosses the real sign-up hooks. SEED_EMAIL must use a domain with a real MX record (default dev@example.com); the disposable-email guard rejects .test. SEED_PASSWORD must contain neither the email local part, the user name, nor the app name: shared/password-policy.ts rejects all three. The script records the initial policy acceptance itself: verifying the email in SQL bypasses the /verify-email hook that normally does it, and without it every sign-in lands on /legal/accept.
@better-auth/sso (OIDC + SAML 2.0) and @better-auth/scim (SCIM 2.0, RFC 7644) enabled in apps/api/src/auth.ts. Gated behind the business-tier sso feature entitlement: assertSsoEntitlementFor in hooks.before on /sso/register (keyed off body.organizationId), and requireFeature("sso") on issuing a directory token (POST /settings/organization/scim-connection); reading its status and disconnecting stay open after a downgrade, and provisioning refuses new users and members once the plan lost sso (SCIM_PLAN_REQUIRED, 403) while deactivations and removals still apply. Both unlock the same paid capability, so both refuse the same way. /settings/sso is covered by organization:["update"]; the directory connection by its own scim:["manage"] statement (owner only), since its token provisions the organization's membership.
Backend apps/api/src/auth.ts + apps/api/src/shared/auth/{sso-paths,sso-enforcement,saml-config,request-snapshots,scim-membership}.ts + apps/api/src/shared/services/scim-connection.service.ts + apps/api/src/auth-queries.ts:
- Per-org OIDC/SAML provider registration (
/sso/register,/sso/update-provider,/sso/delete-provider), domain ownership verification (DNS TXT). SAML registration sends the SP entity ID as top-levelissuerand the IdP's assamlConfig.idpMetadata.entityID; the ACS is/sso/saml2/sp/acs/:providerId. - SCIM managed connections (
@better-auth/scim1.7): one active connection per organization, its provisioning domain is the organization id.GET/POST/DELETE /settings/organization/scim-connection(ScimConnectionService) read its status, issue or rotate the token (writes serialised per organization by an advisory lock, shown once, the plugin stores an HMAC keyed bySCIM_CREDENTIAL_HASH_SECRET, a rotation revokes every other credential, one-year expiry, users scopes only), and decommission it. The plugin needs native transactions, hencedrizzleAdapter(..., { transaction: true }); the adapter getsdbwrapped (asUnitOfWork,shared/auth/unit-of-work-adapter.ts) so every transaction it opens is arunOutermoston its own connection, even inside an app run, and hooks called inside a plugin transaction (the Personal org bootstrap at a first SSO sign-in, the seat count, bridge events) read it throughresolveExecutor()instead of a second connection that cannot see its rows. An event emit that fails after a token rotation is reported to telemetry and the token is still returned, since it is the only working copy. - JIT provisioning: first SSO sign-in creates the
memberrow directly (organizationProvisioning.defaultRole: "member"): no invitation step. - Domain-based enforcement: an org can force SSO-only sign-in for its verified domain (
isSsoEnforcedForpredicate,apps/api/src/shared/auth/sso-enforcement.ts) viaPOST /settings/organization/sso-enforcement(organization:["update"], platform admins can also flip it per D3's ruling). Enforcement rejects/sign-in/email,/sign-up/email,/sign-in/magic-link(checked inhooks.before, before the rate-limit branch) and the passkey path (checked indatabaseHooks.session.create.before, since passkey sign-in carries no email for the path-based check) with403 { message: "SSO_REQUIRED", providerId }. The session-hook leg discriminates on the request: BetterAuth hands that hook the endpoint context, and only the four SSO callback paths (plus admin impersonation) are exempt. It deliberately does not ask whether the user has an SSO account: once someone has signed in through the IdP they own one forever, so a user-linkage test would wave their later passkey sign-ins through and defeat deprovisioning. - SCIM membership projection (
scim-membership.ts): the plugin knows no organizations and calls the projection inside its transaction after every change. An active user gets amemberrow if the plan has a seat, counted on the plugin transaction behind a per-organization lock (otherwise a SCIM 402SCIM_SEAT_LIMIT_REACHEDthat rolls the whole provisioning back), an inactive one loses it, an owner is never removed. No organization hook fires, so the after-hook emitsorg.member.joined/org.member.removeditself, with the provisioned user as subject and the connection creator as actor. The identity resolver refuses any address outside the organization's verified SSO domains (SCIM 403SCIM_DOMAIN_NOT_VERIFIED, so no organization can reserve someone else's email), links an existing account only when its email is verified (profile preserved), and otherwise creates, which the plugin refuses with a 409 when the address is taken. - SCIM
UsersCRUD at/scim/v2/Users:POST/GET/PUT/PATCH/DELETE, bearer-token authenticated (managed credential, no session).DELETEis an org departure, not an account deletion: it removes only thememberrow for the owning org: the globaluserrow (and any other org membership) survives untouched, and it does not route through the RGPD grace-period wipe. - SAML is hardened on every write path:
normalizeSamlConfig(shared/auth/saml-config.ts) forcessignatureAlgorithm: "sha256"andwantAssertionsSigned: trueand rejects the wholesha1/md5family, on/sso/registerand/sso/update-provider; it is spread-based so a partial update can neither weaken the security fields nor clobber identity fields it didn't send. - Before→after handoffs go through
RequestSnapshots<T>(shared/auth/request-snapshots.ts), never a bareMap: a freshness TTL bounds how long a stranded entry stays pickable and anaccepts()predicate lets the consumer's own identity (the organization id) confirm ownership. Ahooks.beforewrite whosehooks.afternever fires (a 404ing SCIMDELETE, for one) would otherwise poison an unrelated later request and forge its audit actor. - Rate limiting:
/api/auth/scim/*carriesSCIM_POLICY(60/min + 1000/h, fail-closed, security events on);/api/auth/send-verification-emailcarries the same fail-closed 3/15min policy as its public "email a stranger" siblings. - 14 events (
sso.provider.{registered,updated,deleted},sso.domain.verified,sso.enforcement.changed,sso.login.{success,failure},scim.connection.{created,token_rotated,deleted},scim.user.{created,updated,deactivated,deprovisioned}): full source list indocs/EVENTS.md. Catalog now 85 / 35 public / 50 internal.
Frontend apps/app/src/features/sso/: /settings/sso page: provider registration forms (OIDC + SAML, forms/{oidc,saml}-provider-form.tsx), <DomainVerificationCard>, <ScimConnectionCard>, <SsoEnforcementCard>. apps/app/src/shared/auth/auth-client.ts adds ssoClient({ domainVerification: { enabled: true } }); the SCIM card talks to /settings/organization/scim-connection through the RPC client (the plugin ships no client since 1.7).
Sign-in entry apps/app/src/features/auth/sign-in.route.tsx: a collapsible "Sign in with SSO" control (email → authClient.signIn.sso({ email, callbackURL }), which better-auth's redirect fetch-plugin turns into a full-page redirect to the IdP). useSignIn (hooks/use-sign-in.ts) redirects straight into the SSO flow: using the providerId the SSO_REQUIRED rejection already carries, no re-derivation from the email: instead of showing an error when a password sign-in hits an SSO-enforced org: the user did nothing wrong, their organization changed the rules.
Local IdP for development: Keycloak under the opt-in sso Docker profile (docker compose --profile sso up keycloak -d), never runs on a bare docker compose up. Setup, the OIDC/SAML client gotchas, and the smoke-test commands are in docs/SSO-LOCAL.md.
Two exact locales, ["en", "fr"], DEFAULT_LOCALE = "en". Locale is not in the URL: it resolves from the locale cookie, then user.locale, then falls back to English, so the 34 route files and every navigation call site were untouched by either phase. Rationale for i18next over Lingui/Paraglide, and for .ts catalogs over .json, in docs/HISTORY.md.
SSOT @packages/i18n: packages/i18n/src/catalogs/{en,fr}/*.ts: common, auth, errors, emails, settings, admin namespaces, each a plain object literal typed as const (never .json: TypeScript widens a JSON string value to string, silently defeating the interpolation-key guarantee). admin is its own namespace rather than a section of settings.ts (520 lines) because it carries a platform-operator audience apps/app/CLAUDE.md's import-direction rule already keeps structurally apart from customer-facing settings. CustomTypeOptions.resources binds to typeof enCatalog, so t() rejects an unknown key at tsc time; English is the source of truth, French a translation. createI18n({ locale, resources }) builds a fresh i18next instance per call (never a shared singleton: the email worker renders for many recipients in sequence) and always registers the English catalog under DEFAULT_LOCALE alongside the target locale, so fallbackLng actually has something to fall back to: a key missing from French renders its English copy, never a raw key. resolveLocale (packages/i18n/src/resolve.ts) picks the first supported locale from an ordered candidate list, dropping a region subtag (fr-BE → fr) to match i18next's load: "languageOnly". packages/i18n/src/__tests__/parity.test.ts (12 tests: one per namespace for key parity, plus 6 named checks) fails the build the moment en and fr keys diverge, a French value is left byte-identical to its English source (short ALLOWED_IDENTICAL allowlist, swept for stale entries), a French _one plural collapses onto its _other (ALLOWED_SAME_PLURAL_FORM, same stale-sweep: added because a real collapse once passed every other assertion and was caught only by a human reading "1 jours"), or a plural key is declared in one form but not its twin, in either locale.
Backend modules/profile/ (@modules/profile): PUT /me/locale (requireAuth, denyImpersonated, zV("json", localeSchema)) persists to user.locale and emits user.locale.changed (catalog 80 → 81). BetterAuth error codes and API error envelopes are mapped through the same errors namespace (packages/web-kit/src/api/errors/messages.ts's formatApiError(err, fallback, t): exact code match first, longest-suffix match second). Server-side Zod validation is localized through one global map: packages/web-kit/src/i18n/zod-error-map.ts calls z.config({ customError }) once per language change, translating too_small/too_big/invalid_format/etc., and routing any .refine() custom check through { params: { i18nKey } } (a per-issue message: literal always wins over the global map: that's Zod's own precedence, so schemas stay message-free on their built-in checks).
Email: locale is per recipient, not per batch: EmailRecipient<K>.locale (packages/email/src/email.port.ts), rendered via renderTemplate(template, variables, locale) and frozen onto the email_message.locale column at enqueue time, so a later worker retry replays the same language regardless of the recipient's locale changing in between. SendTemplateOptions.locale was deliberately never added: a batch-level option would force one language on every recipient of flush-notification-emails.route.ts's digest and rgpd.service.ts's exports, exactly the two genuinely multi-recipient callers.
Frontend: <I18nextProvider> mounts at the top of app-providers.tsx, so even the top-level error fallback can translate. packages/web-kit/src/i18n/locale-cookie.ts: LOCALE_COOKIE = "locale", non-httpOnly (read pre-render, no secret), path=/, max-age=31536000 (1 year), SameSite=Lax. Language switcher at /settings/account (features/account/components/language-card.tsx) writes the cookie, calls PUT /me/locale when signed in, and reinitializes the running i18next instance. formatDate/formatDateTime (apps/app/src/shared/utils.ts) take an explicit locale argument and go through Intl.DateTimeFormat: no date library. <html lang> is kept in sync and asserted by the a11y gate below.
Fully translated, except three permanent exclusions. E.1b extracted the rest of the app: admin, webhooks, sso, billing, organization, the rest of settings, every /legal/* page (536 strings over 97 files). apps/app/CLAUDE.md's carve-out is retired: no user-visible text is a literal at its call site, anywhere, except (1) the legal prose itself, (2) EVENT_DESCRIPTIONS, and (3) the sub-processor register's non-display fields: see below. As-built in docs/HISTORY.md.
Legal pages apps/app/src/features/legal/policies/: en.tsx holds the real (placeholder) legal bodies, fr.tsx re-exports them verbatim: a clone-owner replaces fr.tsx with real French legal text, not a dev task this repo can do for them. policies/bodies.ts selects the locale module (policyBodyFor) and tells policy-doc-view.tsx when the body is an English fallback (isEnglishFallback); policies.config.tsx holds the version and changelog metadata. All six legal routes (privacy, terms, accessibility, cookies, data-rights, sub-processors) render translated chrome around that body, and every one of them mounts the shared <UntranslatedBodyBanner> (features/legal/components/untranslated-body-banner.tsx) so a French reader is told, in French, that the prose below is not translated: a French title over a silent English body was a real defect on four of these pages before this component existed.
Two documented, permanent exceptions to "everything through t()" that are not legal prose: EVENT_DESCRIPTIONS (packages/events/src/event-descriptions.ts) stays English with the rest of the API documentation surface: it renders on /settings/webhooks beside machine event names (org.member.added) that were never translatable either. apps/app/src/shared/sub-processors.config.ts stays English because it restates the signed DPA and is legal evidence, not display copy, but the two fields a user actually reads off it, purpose and region, are translated through common.legal.subProcessors.*, named by shared/sub-processor-labels.ts and consumed by both /settings/privacy's DataSourcesCard and the public /legal/sub-processors page.
apps/api/src/shared/surface/: the checked-in map of every backend route to what consumes it, and the honesty test that keeps it truthful.
back-routes.ts: the real route table, read off the live Hono app (not hand-maintained): 83 keys. Construction (apps/api/src/app.ts) is split from boot (index.ts) so a test can importapp.routeswithout starting the outbox dispatcher or the workers.front-consumers.ts: 52 call sites extracted fromapps/app's source.route-map.ts:Record<"METHOD /path", Consumer | UiLessReason>over the 83 routes: 28 have no front consumer, classified as 10internal-cron, 9dormant-by-design, 3infra-probe, 3public-api, 2provider-callback, 1library-owned. Nothing is deleted for being unconsumed: a boilerplate keeps dormant scaffolding ready, declared in writing rather than silently absent.- Parity test (4 assertions): diffs the map against the live route set and the extracted consumer list; a stale
UiLessReasonrow dies the moment a front consumer appears for it. docs/SURFACE.mdexplains the mechanism and, in prose, the backend capabilities with no HTTP surface at all (thequotasmodule): structurally invisible to the map, since it has noroutes.tsand is never mounted.
Two gaps closed: the admin role-change UI is wired (previously API-only); the avatar-replace storage leak is closed by deriving the object key server-side from the public URL (DELETE /uploads now accepts { key } or { url }), so the front never learns the storage key format.
Behavioural parity: 28 call sites converted from raw backend English to localized copy; the impersonation refusal has its own business code (IMPERSONATION_ACTION_FORBIDDEN, previously a generic HTTP_403); guarded actions are disabled at the screen with their reason exposed without a mouse, on top of the localized refusal that already arrives after the fact.
requireCurrentPolicies is mounted on business routes, reversing Phase A.2 decision 5 ("composable, not mounted globally"): see the corrected line above and docs/HISTORY.md's Phase H.1 entry. It stays an allowlist: what is gated is enumerated, and what is not is enumerated with its reason, rather than described as "everything minus a few exclusions".
No new event type: the catalog is consumed, not extended.
apps/app/a11y/: Playwright as an axe driver, not an E2E suite. Run by .github/workflows/ci.yml on every PR into develop/main, and locally with pnpm --filter app check:a11y. Details and local setup in apps/app/a11y/README.md.
pages.ts: 7 public + 10 authenticated pages, each audited in light and dark (34 tests). Adding a page is one array entry.a11y.spec.ts: zeroserious/criticalWCAG 2.1 A/AA violations, exactly one<main>and one<h1>, and an assertion on the final URL: a gate redirect (no session, stale policies) renders a page that passes every other check, so without it the suite audits the redirect target and reports green. Dark is acolorSchemeloop over the same page lists: the theme defaults tosystem, so emulating the preference is all it takes, and it caught a contrast failure light never shows.interaction.spec.ts: tab order across every/sign-incontrol, command-palette focus trap (10 Tab presses, released on Escape), andprefers-reduced-motion: reduceskipping the theme view transition (watched with aMutationObserver, sincetheme-transitioningis removed once the transition ends).auth.setup.ts: signs in with the keyboard and storesstorageState. One sign-in per run is a hard budget:/sign-inallows 5 per 15 min per IP and the block is held in the API process, so a second one would lock a developer out after three local runs.- Lighthouse is deliberately absent: its a11y category runs a subset of axe.
See ../ROADMAP.md: the list is short by design; anything not on it was cut rather than deferred.
- Dependency refresh Oct 2026 (Node 26 LTS, Bun 1.4.4, pnpm 12), in progress.
- Phase I: resync on the OpenUp foundations (kit, guardrails, telemetry hygiene, ambient unit of work, leased outbox).
- Manual review pass over the shipped surface.