HISTORY

July 3, 2026 · View on GitHub

Shipped phases — full architectural log. The roadmap stays forward-looking; everything completed lives here.

Each section preserves the original task list (now [x] for the as-built record) plus the why and the non-obvious decisions baked into the codebase. New contributors read this to understand why the code looks like it does.

Note on paths: file paths in entries below reflect the layout at the time of shipping. The codebase has since migrated to vertical-slice on both sides (front: features/<x>/<x>.route.tsx + shared/, code-based routing via apps/app/src/router.tsx; api: modules/<context>/{application,infrastructure,routes.ts,module.ts} + shared/, inwire defineModule() per context). For the current canonical layout see CLAUDE.md ## Layout. The decisions and rationales below stay accurate — only the directory containers moved.


Auth — BetterAuth (end-to-end) ✅ Phase 1 · Phase 2 (organization)

Why: own the token, multi-provider, typed plugins (Stripe, organizations, 2FA, passkeys, magic-link), DB-backed sessions, first lib that runs natively on Bun + Hono with no hacks.

  • Install better-auth + Drizzle adapter (better-auth/adapters/drizzle)
  • Auth schemas generated via @better-auth/cli generatepackages/drizzle/src/schema/auth.ts (6 tables: user, session, account, verification, two_factor, passkey)
  • Hono handler: app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw))
  • React client: createAuthClient in apps/app/src/adapters/auth-client.ts
  • Plugins: twoFactor, passkey, magicLink, bearer (mobile/Capacitor-ready). organization shipped in Phase 2; stripe deferred.
  • sessionMiddleware (adapters/middleware/auth.middleware.ts) populates c.var.user / c.var.session ; companion requireAuth middleware throws HTTPException(401) on protected handlers.
  • Pages /sign-in, /sign-up, /forgot-password, /verify-email, /reset-password, /magic-link, /two-factor in features/auth/.
  • Pathless layouts routes/_protected.tsx (block when no session) and routes/_guest.tsx (block when already logged in) — single beforeLoad shared by all children, URLs unchanged.
  • Cookies: httpOnly + sameSite=lax + secure in production.
  • Performance: session.cookieCache (5 min) on the server — auth check is signature-only between refreshes (no DB hit). DB stays the source of truth at expiry → instant revoke on sign-out/ban.
  • Native readiness: bearer() plugin enables Authorization: Bearer <token> alongside cookies. Web stays cookie-based (httpOnly, XSS-safe), Capacitor/mobile uses bearer with secure storage. Same session row, transport differs.
  • Email URLs route through the app, not the API — every email link points to ${APP_URL}/<route>?token=.... The frontend page consumes the token via the typed client (authClient.verifyEmail, resetPassword, magicLink.verify). No more callbackURL mangling by Outlook & co.
  • Pino structured logging + centralised error handlerhono-pino middleware (adapters/middleware/logger.middleware.ts), JSON in prod, pino-pretty in dev. Single errorHandler (adapters/middleware/error.middleware.ts) returns { error: { code, message, requestId, stack? } }.
  • Session as TanStack Query, not React statesessionQueryOptions (adapters/queries/session.ts, staleTime 5 min aligned with cookieCache). Router context only exposes queryClient; gates do await context.queryClient.ensureQueryData(sessionQueryOptions) in beforeLoad. No useSession() React bridge, zero race between nanostores and beforeLoad.
  • Realtime cross-tab session sync — native BroadcastChannel('clean-stack-auth') (adapters/auth-broadcast.ts, ~15 LoC, no experimental dep). Auth mutations call broadcastAuthChange() after refetching the session query; app-providers.tsx listens once and on receive does refetchQueries(['session']) + router.invalidate(). Tab A signs out → tab B (idle on /dashboard) instantly transitions to /sign-in without polling, hard reload, or navigation in B.
  • Strong password schema split_schemas/auth.schema.ts exposes passwordSchema (loose: min(1), used by sign-in to capture, the server validates) and strongPasswordSchema (strict: min(12).max(128) + lowercase/uppercase/digit, used by sign-up + reset). NIST-aligned: no required special character.
  • StrictMode-safe token consumptionuseRef(false) guard in verify-email.page.tsx and magic-link.page.tsx prevents the dev-only double-fire of single-use tokens.

Multi-tenant — BetterAuth organization plugin ✅ Phase 2

Why from day one: migrating single-user → multi-tenant after the fact is hell (backfill organizationId everywhere, orphaned owners, rewrite every query). The reverse is free: if it ends up being B2C, every user gets an invisible auto-created "personal org".

  • organization plugin enabled in the auth config
  • Drizzle schemas generated: organization, member, invitation (+ team if needed)
  • Auto-create a personal org on signup (databaseHooks.user.create.after, slug personal-${orgId} — UUID v4, never user-visible)
  • Session enriched with activeOrganizationId → Hono middleware that pushes it into c.var.orgId
  • Every business table has an organizationId FK from the very first migration (never added later)
  • Drizzle helper withOrg(qb, orgId) to systematically scope queries
  • Pages: /org/new, /settings/general, /settings/members, /settings/invitations in features/
  • Org switcher in the top-nav header (authClient.organization.setActive(id)), Command popover with search
  • Email invitations (dedicated Resend template)
  • Stripe customer = per organization, not per user (the Stripe plugin supports it natively — wired in Phase 3)
  • Slug auto-generated, never user-input — create-org form only asks for name; mutation generates org-${crypto.randomUUID()}. Slug is a DB uniqueness constraint, not a UX surface. Reintroduce the field only if a future feature exposes the slug in URLs.

Capability-based authorization layer (post-merge hardening)

The plugin ships with built-in role checks (auth.api.organization.* enforce them on plugin endpoints), but our business routes + UI need the same predicate without re-implementing it. The fix: a shared workspace package + a three-layer contract.

  • @packages/access-control — single source of truth. Wraps createAccessControl (better-auth/plugins/access) with defaultStatements extended by organization: ["update", "delete", "leave"] + billing: ["read", "manage"]. Exports ac, roles = { owner, admin, member }, STATEMENTS, types OrgRole / OrgPermissions, and a sync authorizeRole(role, permissions, connector?) predicate. The as unknown as AccessControl cast required by BetterAuth's generic plugin signature is hidden inside the package — call sites stay strict-typed. Built with tsup, ESM-only, peer-dep on better-auth.
  • Three layers, one contract: server requireOrgPermission(permissions) middleware (apps/api/src/adapters/middleware/org.middleware.ts) — wraps every business route guarded by capability, throws HTTPException(403) on deny. Front route gate ensureOrgPermission(permissions) (apps/app/src/adapters/route-helpers/ensure-org-permission.ts) — beforeLoad helper that does ensureQueryData(currentMembershipQueryOptions) + authorizeRole + redirect. UI gate <Can requires={...}> (apps/app/src/adapters/components/can.tsx) backed by useAuthorization() (apps/app/src/adapters/hooks/use-authorization.ts) — declarative subtree gating with optional connector="OR" and fallback slot. Same predicate everywhere; renaming an action requires touching the package only.
  • Capability-based, never role-based, in feature code — describe { organization: ["update"] }, not ["owner", "admin"]. Children call useAuthorization themselves rather than receiving boolean canEdit props (rule 14 promotion: the row owns its own permission check, the page passes only data).
  • Flat _org-scope route layout — one pathless gate (_org-scope.tsx) ensures active-org-required; capabilities live per-route via beforeLoad: ensureOrgPermission({...}). Avoids stacking _org-admin / _org-owner / _can-manage-billing pathless tiers as new resources land.
  • Navigation declares requires: OrgPermissions + requiresOrg: booleanSETTINGS_TABS (adapters/components/contextual-tabs.tsx) and NAVIGATION_ROUTES (adapters/components/command-palette.tsx) filter via useAuthorization().can(requires). The visible tab set matches what the gate accepts; no drift between "I see the tab" and "the gate lets me in".
  • AuthorizationDevTool — dev-only floating panel (adapters/components/authorization-devtool.tsx, mounted in __root.tsx, tree-shaken via import.meta.env.DEV). Visualises the active session's role and the full capability matrix derived from STATEMENTS × roles. PERSONAL_BLOCKED map overlays UI gating for actions the lifecycle blocks on Personal orgs (delete/leave). Use to verify gating per role without seeding test users.

Lifecycle hooks — self-heal + auto-cleanup

Personal org is structurally identical to a team org for every operation except delete/leave (cf rule 5). The lifecycle exception is encoded in two places: isPersonalOrg(slug) helper + the server hooks below.

  • ensurePersonalOrgFor(userId) — idempotent self-heal in auth.ts. Returns existing membership orgId or creates a new Personal org + member row in a transaction. Runs in databaseHooks.user.create.after (signup — covers new users) AND databaseHooks.session.create.before (sign-in — back-fills legacy users that pre-date the signup hook with activeOrganizationId: null). Never duplicate the create-personal-org logic inline.
  • afterRemoveMember — non-Personal orgs auto-collapse when the last member leaves. Hook checks remaining member count post-leave and deletes the empty org via Drizzle. Skipped for Personal orgs (the user must delete their account to remove their Personal org). Empty orgs left behind = zombies in the org table; auto-cleanup keeps it truthful.
  • beforeDeleteOrganization — rejects Personal org deletion outright (throw new Error("Personal organization cannot be deleted...")). The front mirrors this by hiding the Leave button on Personal and rendering a hint on Delete (account deletion is the only path).
  • Owner-leave flow — owner of a non-Personal org can leave: sole member → org auto-deletes via afterRemoveMember; sole owner with other members → must transfer ownership first. transferAndLeaveMutationOptions (adapters/mutations/transfer-and-leave.ts) is the multi-step factory: updateMemberRole then leave. UI is TransferLeaveDialog (features/settings/_components/transfer-leave-dialog.tsx). Post-leave both flows call switchToFirstRemainingOrg(queryClient) (adapters/utils/switch-to-first-org.ts) + broadcastAuthChange().
  • NO_ACTIVE_ORGANIZATION translated to nullcurrentMembershipQueryOptions and activeOrgQueryOptions catch BetterAuth's error code and return null. "No active org" is a valid transient state in our model (between orgs, pre-self-heal); letting the error bubble crashes any consumer that calls ensureQueryData.
  • broadcastAuthChange() extended to org eventssetActive, create-org, delete-org, leave-org, transfer-and-leave, accept-invitation, remove-member all call the broadcast in their onSuccess (call site, not factory). Listener already refetches ["session", "active-org", "current-membership", "orgs"]. Cross-tab consistency under the 5-min cookieCache.maxAge window.

May 2026 cleanup — dropped the teams sub-plugin

  • Removed the BetterAuth teams sub-plugin. Grouping-only (no team-scoped roles or statements) added UX surface for ~zero value at this stage. Re-enables in two lines if a clear use-case emerges. Settings collapsed from a General/Members/Teams split to a single Organization tab with section-level <Can> gates per role.

Email — Resend (dashboard templates) ✅ Phase 1

Why: templates managed from the Resend dashboard (no code, no rebuild to change wording), built-in versioning, native A/B test. Stays pragmatic: we just call the API by template ID.

  • Install resend
  • Port IEmailService (apps/api/src/application/ports/email.port.ts) + adapter ResendEmailService (apps/api/src/adapters/services/email.service.ts) wired through inwire DI in contract mode (key = interface name IEmailService).
  • Type-safe variables per templateEmailTemplates maps each template name to its required variables. Adding a new template = updating the type + adding a RESEND_TPL_* env var. Renaming a variable in the dashboard without updating code = TS red, no silent break.
  • Result<void, EmailError>sendTemplate never throws, returns a discriminated EmailError (EMAIL_TRANSPORT_NOT_CONFIGURED | EMAIL_PROVIDER_FAILURE). Use cases keep the Result until the controller boundary; integration adapters (auth.ts) translate to throw only at the BetterAuth-hook frontier.
  • Retry with exponential backoff — 3 attempts (1s/2s/4s), retry only on 429 and 5xx + network errors (status === 0). 4xx non-rate-limit fail fast (validation = retry futile). Distinct STATUS_HINTS log per 401 / 403 / 409 / 422 so prod debug isn't blind.
  • Idempotency-Key${event-type}/${sha256(token)[:32]} (Resend pattern, 24h window). Hash via Bun.CryptoHasher. Safe under retries — same payload returns the original response, different payload returns 409 with explicit log hint.
  • SendTemplateOptions.from? — per-tenant from override slot for the future organization plugin (per-org sending domain). Defaults to env.RESEND_FROM. Adding it now = zero breaking change in phase 2.
  • SendTemplateOptions.locale? — slot reserved for the i18n phase. Adapter currently logs a warn ("not yet implemented") if passed; resolution will switch to ${template}_${locale} env lookup when locale-prefixed templates land. Port stays stable.
  • Boot-time fail-hard in production — constructor throws if NODE_ENV === "production" and RESEND_API_KEY or any template ID is missing. Prevents a silent deploy where every transactional email is dropped. Dev mode keeps the warn-only fallback.
  • BetterAuth sendVerificationEmail / sendResetPassword / magicLink.sendMagicLink consume di.IEmailService.sendTemplate via a dispatchEmail() helper that unwraps Result (EMAIL_PROVIDER_FAILURE → throw → centralised error handler; EMAIL_TRANSPORT_NOT_CONFIGUREDlogger.warn, never silent).
  • IP reputation guarded by Resend, not by us — Resend ships a domain-scoped suppression list since 2025: hard bounces and spam complaints auto-add the address; future sends to a suppressed address are blocked at the provider edge with a 422 + email.suppressed webhook event. No own suppression table needed until a product feature actually consumes it (org invitations gating, account-settings "your email bounces" UI, abuse detection). Building it earlier is the OpenUp anti-pattern: ~150 LOC + 2 tables sitting empty until the first consumer arrives. Promote on second occurrence (rule 14), not in anticipation. The webhook integration (POST /webhooks/resend, resend.webhooks.verify() first-party SDK helper, Svix HMAC, svix-id dedupe) ships when the first consumer lands.
  • DNS documented in README.md (SPF + DKIM CNAMEs from Resend dashboard + DMARC TXT progression p=nonep=quarantine once stable, target p=reject). Mandatory before any production send — Gmail (Feb 2024), Yahoo (Feb 2024), Microsoft Outlook (May 2025) all reject unauthenticated bulk senders with 550 5.7.515.

Storage — Cloudflare R2 (prod) + SeaweedFS (dev, opt-in) ✅ Phase 1

Why: R2 = no egress fees, S3-compatible, SigV4 only. SeaweedFS local = same S3 API → one codebase, switched via env. R2 drives the design (SeaweedFS is for dev convenience, not a target). Originally MinIO; swapped to SeaweedFS in May 2026 after MinIO was archived (April 25, 2026, features moved behind enterprise license).

R2 quirks that shape the design (verified 2026): R2 does not support Presigned POST policies — only PUT/GET/HEAD/DELETE. There is no native content-length-range condition. ContentLength and ContentType passed to a presigned PUT are signed (the client must send those exact headers or 403 SignatureDoesNotMatch), but R2 does not verify the actual body size against them. Real enforcement therefore requires a post-upload HeadObject + DeleteObject on mismatch step, which is what the confirm route does. Object Lock and Bucket Policies are not implemented on R2; do not depend on them.

Three-step flow: presign → client PUT direct to R2 → confirm (server HeadObject, deletes on size/content-type mismatch).

  • SeaweedFS added to docker-compose.yaml under profile storage (host port pinned to 8333, bucket clean-stack auto-created by seaweedfs-init via weed shell) — dev only, opt-in.
  • Install @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner + @hono/zod-validator.
  • Pure transport port (apps/api/src/application/ports/storage.port.ts) — IStorageService exposes presignUpload / presignDownload / headObject / deleteObject / publicUrlFor. Zero business rules; the adapter just signs S3 requests and forwards SDK calls.
  • S3 adapter (apps/api/src/adapters/services/storage.service.ts) — S3Client with region: "auto" (R2's only accepted value), forcePathStyle (kept on for SeaweedFS/MinIO compat — harmless on R2). Boot-time fail-hard in production if S3_ENDPOINT is localhost or creds are the dev defaults (dev/dev). Presigned PUT signs content-type + content-length headers (signableHeaders) so the client can't drop them.
  • Use-cases for orchestration onlycreate-upload-url, create-download-url, confirm-upload (apps/api/src/application/use-cases/). Each gets IStorageService via constructor. Owner-scoping enforced in use-cases: every key is <userId>/<scope>/<uuid>-<filename>; download + confirm reject any key whose prefix is not <requestingUserId>/ (STORAGE_FORBIDDEN). confirm-upload performs HeadObject, deletes on size/content-type mismatch, returns STORAGE_INTEGRITY_FAILED.
  • Validation lives at the controller boundary — Zod schemas in the route enforce filename regex (^[\w\-. ]+$), scope regex (^[a-z][a-z0-9-]{0,31}$), max size (STORAGE_MAX_UPLOAD_BYTES, default 50 MB), TTL defaults. Zod failures return 400 via the centralised error handler.
  • Per-call granularity: presign body accepts scope (default "uploads") and expiresInSeconds (default 5 min for upload / 10 min for download), clamped server-side to [STORAGE_PRESIGN_TTL_MIN_SECONDS, STORAGE_PRESIGN_TTL_MAX_SECONDS] (default [60, 3600]).
  • Env (apps/api/common/env.ts): S3_ENDPOINT (R2 prod: https://<ACCOUNT_ID>.r2.cloudflarestorage.com or …eu.r2.cloudflarestorage.com for EU jurisdiction — once chosen, R2 cannot move the bucket), S3_REGION (R2: auto), S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY, S3_FORCE_PATH_STYLE, S3_PUBLIC_URL, STORAGE_MAX_UPLOAD_BYTES, STORAGE_PRESIGN_TTL_MIN/MAX_SECONDS. Dev defaults to SeaweedFS (host port pinned to 8333; in-network it's seaweedfs:8333).
  • Routes (typed RPC, chained into the routes export): POST /uploads/presign, POST /uploads/confirm, POST /uploads/download. All requireAuth. Error mapping: 403 (STORAGE_FORBIDDEN), 404 (STORAGE_NOT_FOUND), 422 (STORAGE_INTEGRITY_FAILED), 502 (STORAGE_PROVIDER_FAILURE).
  • Flat DI container (apps/api/src/di/container.ts) — inwire infers everything; sections by line comments (// infra, // uploads). Use-cases registered next to the infra ports they depend on, type-checked by inference (reorder a .add() to put a use-case before its port → tsc rouge). AppDeps = typeof di derived after .build(). Promote a section to modules/<context>.module.ts only when a bounded context grows large enough to bloat container.ts.
  • createUploadMutationOptions (apps/app/src/adapters/mutations/create-upload.ts) — TanStack Query mutationOptions factory chaining presignPUT direct to R2 (with explicit Content-Length) → confirm. Returns { key, publicUrl, size, contentType } only after server-verified integrity. Consumed via useMutation({ ...createUploadMutationOptions, onSuccess, onError }). Accepts optional scope + expiresInSeconds.
  • First Hono RPC consumerapps/app/src/adapters/api-client.ts uses hcWithType from api/client (subpath export, pre-typed ApiClient), with custom fetch interceptor (X-Request-Id, slot for 401/Capacitor) and trailing-slash normalization. Future features call the API exclusively through this typed client.

Dev: opt-in via docker compose --profile storage up -d. SeaweedFS has no auth by default (any creds accepted). No web console; use aws s3 --endpoint-url=http://localhost:8333 or browse via the app.


RGPD / CCPA — data deletion (Art. 17) + export (Art. 20) ✅ Phase 1

Why: clean-stack is a boilerplate cloned to start any SaaS. A clone deployed to EU users without Art. 17 (right to erasure) + Art. 20 (data portability) is illegal day one — fines up to 4 % of revenue. The cascade was built before Billing / Audit-log / Admin landed so every future feature inherits the deletion contract rather than retrofitting it. Lives in apps/api/src/modules/rgpd/ (vertical slice — service + drizzle repo + public + internal routes) and apps/app/src/features/rgpd/ (cards + forms + hooks). Shipped commits fd3b4b7, bfcc15d, da659a0.

  • Export endpoint POST /me/export — auth-gated, sync (walks the user's tables in-request, uploads the JSON bundle to R2, emails a signed 7-day URL via Resend RESEND_TPL_DATA_EXPORT_*). Rate-limited 1/24h per user via lastExportRequestedAt. The presigned URL is never put in an event payload — events carry only storageKey (security).
  • Pre-flight ownership gate GET /me/delete/preflight — returns the sole-owner non-personal orgs that block deletion. UI at /settings/account renders the blocking list with per-row Transfer ownership / Leave org CTAs; the Delete account button stays disabled while the list is non-empty. Auto-transfer rejected on principle — no implicit refiling of legal/billing responsibility onto a member without consent (mirrors the Personal-org deletion posture).
  • Delete endpoint POST /me/delete — auth + 2FA-required (BetterAuth twoFactor) + server-side preflight re-check (409 ACCOUNT_DELETION_BLOCKED if a sole-owner org appeared between read and submit) + 7-day soft-delete grace. Cron POST /internal/rgpd/process-pending-deletions (HMAC-signed) sweeps expired requests, wipes personal data (email, name, sessions, passkeys, MFA factors, R2 avatars) and anonymizes member rows (userId → null, email → deleted-<uuid>@anonymized.local).
  • Cancel-deletion UX — signing in during the grace window prompts a cancel/continue dialog; cancel clears pendingDeletionUntil.
  • Soft-delete confined to RGPDuser.deletedAt + user.pendingDeletionUntil are the only soft-delete columns in the codebase (rule 14 — no creep elsewhere; everything else is hard-delete).
  • Public /legal/data-rights page — linked from /settings/account, lists what's deleted vs anonymized vs retained per legal basis.
  • Audit-log integration — every state transition emits an outbox event (user.deletion.{requested,cancelled}, user.deleted, user.export.{requested,completed}); AuditEventSubscriber writes the audit row with compliance retention. Pino logging retained for ops debugging (different concern).

Decisions (non-obvious, locked-in by code):

  1. Soft-delete grace, not immediate wipe — Art. 17 allows a reasonable processing window. The 7-day grace doubles as a self-service "I changed my mind" path (cancel on sign-in) and a safety net against account-takeover-then-delete attacks. The cron is the only thing that hard-wipes.
  2. Anonymize member, don't cascade-delete it — deleting the member row would corrupt org audit trails ("who invited whom"). Setting userId → null + a tombstone email keeps referential history intact while removing the PII link. The org's other members see "deleted user", not a broken page.
  3. Sole-owner preflight is server-authoritative, re-checked at submit — the UI gate is UX; the POST /me/delete handler re-runs the preflight inside the request so a race (org ownership changing between page-load and click) can't orphan an org without an owner.
  4. 2FA gate on a destructive irreversible action — account deletion reuses the BetterAuth twoFactor challenge, consistent with the "step-up auth on irreversible ops" posture.

Remaining (tracked in their dependent phases, not here): E2E Playwright deletion-cascade gate (A.6), admin export-on-behalf + deletion overrides (C.3), Stripe customer cleanup during wipe (B.1).


App shell — top-nav + ⌘K command palette ✅

Why: sidebar SaaS shells are the 2010-2024 standard, but the SOTA 2026 wave (Vercel, Linear web, Resend, Trigger.dev) consolidated on top-nav + global ⌘K palette. Less chrome, better mobile, keyboard-first power-users.

  • Top-nav header (adapters/components/app-shell.tsx) — sticky, blurred bg, logo + org switcher + primary nav (Dashboard / Settings) + ⌘K trigger + theme toggle + user menu (avatar dropdown).
  • Contextual sub-nav (adapters/components/contextual-tabs.tsx) — second header line that appears only on /settings/*, renders the section tabs inline. No vertical settings nav anywhere.
  • Global ⌘K palette (adapters/components/command-palette.tsx) — Navigate group (every page), Switch organization group (live org list with active marker + create new), Actions group (toggle theme, sign out). Cmd/Ctrl auto-detection.
  • Org switcher (adapters/components/org-switcher.tsx) — Command-powered popover with search. Active org pinned, Check icon. New-org link as the last item.
  • User menu (adapters/components/user-menu.tsx) — avatar dropdown with Account / Security shortcuts + destructive sign-out.
  • /settings hub — single layout (features/settings/settings.layout.tsx) renders Outlet with the contextual tabs as page nav. Six sub-pages: General, Members, Invitations, Billing (placeholder until Stripe ships), Profile, Security. /settings index redirects to /settings/general.
  • One <main> per page — the Outlet content wrapper is the page's <main> landmark. Sub-pages render plain divs/sections inside.
  • Custom inline-SVG LogoMark — two offset rounded squares (front solid, back at 18% opacity), theme-aware via currentColor + var(--background). No asset file.

Event-driven foundation — outbox + dispatcher + audit-log + webhooks ✅ May 2026

Why: every cloned SaaS needs the same event plumbing — outbox for at-least-once delivery, audit log for compliance (SOC2 §CC7.2 + ISO 27001), outbound webhooks for customer integrations, in-process handlers for side-effects. Building that rail once-for-all unlocks Phases A.4 (consent handlers), C.2 (audit), C.5 (webhooks), C.7 (SSO audit), D.3 (in-app notifs), 0.4 (observability subscribers) — each becomes a 1-line onEvent(...) declaration instead of a per-feature plumbing chunk.

DX contract — zero plumbing post-clone: a dev cloning the boilerplate writes (1) a 1-line entry in packages/events/src/event-types.ts, (2) aggregate.addEvent(new XEvent(...)) in their domain method, (3) this.uow.run(async tx => repo.save(agg, tx)) in their use-case. The outbox enqueue happens transparently via AsyncLocalStorage event collector + IUnitOfWork.run() flush pre-commit. Audit + webhook fan-out automatic if the event is in the retention map. In-process handlers via onEvent(type, factory) + 1 inwire b.add(...) are auto-discovered at boot (container introspection via EVENT_HANDLER_SYMBOL marker). See docs/EVENTS.md for the full DX guide.

Catalog @packages/events (new private package)

  • 29 events at foundation (grown to 35 as later phases added webhook-endpoint ×3, profile-updated + email-change-requested ×2, policy-accepted ×1) covering BetterAuth (user/session/account, organization/member/invitation, MFA/passkey), RGPD (deletion + export transitions), uploads (hashKey instead of raw filename for PII).
  • Zod payload schemas — typed discriminated union via PayloadByEventType.
  • RETENTION_MAP — per-event operational (90d) / compliance (7y) / none. The audit subscriber reads this directly — no glue.

@packages/ddd-kit extensions

  • Aggregate.pullDomainEvents() — atomic pull-and-clear (replaces the old getter + clearEvents() pattern).
  • EventCollector (AsyncLocalStorage) — per-uow context isolation. Verified via concurrent Promise.all test.
  • IUnitOfWork.run(cb) standardized — wraps Drizzle db.transaction(...) + opens ALS context, drains events pre-COMMIT via injected flushHandler. Nested run() interdit — the impl throws Error("nested IUnitOfWork.run() is not supported") because Drizzle nested transactions are independent (not savepoints) → events would orphan. Detected via EventCollector.hasContext().
  • onEvent(type, factory) + EventHandler<T> + EVENT_HANDLER_SYMBOL (cross-realm via Symbol.for("clean-stack/event-handler")). Inline-friendly: b.add("X", onEvent(EventTypes.X, c => async e => c.IEmailService.send(...))).
  • UUID v7 inline impl (RFC 9562, no external dep) — replaces v4 in UUID.create(). Time-ordered → B-tree locality on insert. Monotonic across milliseconds; not strict intra-ms (acceptable for B-tree page-level locality, documented).
  • outbox-mapping.domainEventToOutboxRow() — converts IDomainEvent to outbox row shape with CloudEvents 1.0 metadata envelope (specversion, source, subject, traceparent, datacontenttype).

@packages/drizzle schemas + run flush

  • 3 new schemasoutbox_event (UUID v7 PK, partial index WHERE dispatched_at IS NULL, CloudEvents metadata jsonb), audit_log (5 indexes for filter combos, prev_hash/hash columns posed for tamper-evidence), webhook_endpoint + webhook_delivery (FK CASCADE org, FK RESTRICT outbox_event, idempotency_key UNIQUE, partial pending index).
  • TransactionService.run() — implements IUnitOfWork.run with flushHandler injected at construction time (container.ts wires it to outbox.enqueue). Sets idle_in_transaction_session_timeout = '30s' via SET LOCAL to protect against zombie workers.
  • trackEventsOnSuccess(result, aggregate) helper — repos call this in their save/create impl to push aggregate events into the ALS collector. Without it, events stay buffered on the aggregate and are silently lost.

Shared infra

  • OutboxDispatcher (apps/api/src/shared/services/outbox-dispatcher.service.ts) — in-process Bun worker. Dedicated pg.Client for LISTEN outbox_event + reconnect with exponential backoff + 30s poll fallback. SELECT ... FOR UPDATE SKIP LOCKED LIMIT 50 drain (multi-instance ready). Built-in subscribers (audit + webhook fanout) run inside the dispatch TX (atomic with markDispatched); user onEvent(...) handlers run post-commit in a separate loop (best-effort, isolated). pg_notify trigger ensured idempotently at boot via CREATE OR REPLACE TRIGGER (Postgres 14+ atomic). Container introspection auto-wires user handlers via Object.entries(di) + EVENT_HANDLER_SYMBOL filter.
  • Built-in subscribersAuditEventSubscriber writes audit row idempotently via deterministic ID audit-${event.id} (ON CONFLICT DO NOTHING). WebhookFanoutSubscriber enqueues webhook_delivery rows with eventTypes ? <type> ARRAY match AND organizationId = event.organizationId. Multi-tenant safety: events with organizationId = null (platform-level: USER_CREATED, USER_SIGNED_IN) skip the webhook fanout entirely — never broadcast across tenants. Verified runtime.
  • AEAD secret crypto (shared/aead.ts) — @noble/ciphers v2 XChaCha20-Poly1305 + HKDF-SHA256 per-org sub-key from WEBHOOK_MASTER_KEY (32-byte hex). Webhook secrets encrypted at rest, plaintext returned once at endpoint creation (Stripe-style).
  • Decorrelated jitter (shared/jitter.ts) — AWS Architecture Blog formula: min(cap,random(base,lastDelay×3))\text{min}(\text{cap}, \text{random}(\text{base}, \text{lastDelay} \times 3)). BASE = 1000ms, CAP = 12h, MAX_ATTEMPTS = 5. Retry paliers ~1m / 5m / 30m / 2h / 12h, dead-letter after 5 attempts.
  • Ports IOutboxRepository + IAuditPort in shared/ports/. Cross-cutting (consumed by 2+ contexts). Drizzle impls in shared/services/.
  • emitEvent(outbox, ...) shared helper (shared/event-emitter.ts) — used by BetterAuth bridge, RGPD service, UploadService instead of duplicated private emit methods.
  • Request correlation via AsyncLocalStorage (shared/request-context.ts, Jun 2026) — a requestId middleware wraps each request in an ALS carrying its X-Request-Id; DrizzleOutboxRepository.enqueue reads it at the single write choke-point and stamps outbox_event.metadata.requestId, which the AuditEventSubscriber copies into audit_log.request_id. Chosen over threading c.get("requestId") through ~30 call sites because BetterAuth hooks (which emit the majority of events) have no Hono c in scope — ALS is the only source that covers Hono routes, BetterAuth lifecycle hooks, and the internal-route cron alike. The actor stays explicit per rule §7 (ALS is for observability only, never authz).
  • Service-level audit via emitEventAuditEventSubscriber — code without an aggregate (RGPD, uploads, BetterAuth bridge) emits its event through the emitEvent helper; the subscriber writes the audit_log row from the outbox. The outbox event is the audit primitive — there is no separate recordAudit helper.

BetterAuth bridge (apps/api/src/auth.ts) — 21 unique events emitted automatically (23 emit sites; USER_PASSWORD_CHANGED + ORG_MEMBER_JOINED each have 2)

3 voies SOTA combinées :

  • databaseHooks (TX-bound, captures all flows including non-HTTP) — USER_CREATED (user.create.after), USER_SIGNED_IN (session.create.after), USER_SIGNED_OUT (session.delete.after), USER_ACCOUNT_UNLINKED (account.delete.after, skip credential).
  • hooks.after + createAuthMiddleware (path-based, plugin events not exposed in databaseHooks) — filter if (ctx.context.returned instanceof APIError) return to skip on 4xx/5xx (plugin events fire even on errors otherwise). Paths: /two-factor/{enable,disable}, /passkey/verify-registration (lookup latest passkey for userId), /passkey/delete-passkey (body.id), /verify-email, /change-password, /link-social (lookup latest non-credential account < 5s ago to avoid re-link false-positives).
  • Native callbacksemailAndPassword.{sendResetPassword,onPasswordReset} (USER_PASSWORD_RESET_REQUESTED + USER_PASSWORD_CHANGED), magicLink.sendMagicLink (USER_MAGIC_LINK_REQUESTED).
  • organizationHooks (org plugin) — afterCreateOrganization (ORG_CREATED), afterUpdateOrganization, afterDeleteOrganization, afterAddMember + afterAcceptInvitation (both emit ORG_MEMBER_JOINED — the two lifecycles are independent in BetterAuth, missing the second drops every member who joins via invite), afterRemoveMember, afterUpdateMemberRole, afterCreateInvitation (ORG_MEMBER_INVITED), afterCancelInvitation.
  • RGPD service — USER_DELETION_{REQUESTED,CANCELLED}, USER_DELETED, USER_EXPORT_{REQUESTED,COMPLETED} (payload: storageKey, never the presigned URL — security).
  • UploadService — UPLOAD_REQUESTED + UPLOAD_CONFIRMED + UPLOAD_DELETED (DELETE /uploads, ownership-gated by key.startsWith(\${ownerId}/`); payload: hashKey(key)` sha256-truncated, never the raw filename — PII).
  • Race window BetterAuth COMMIT ↔ outbox enqueue documented as accepted (no 2PC available). For SOC2 reconciliation: cron SELECT u.id FROM "user" u LEFT JOIN outbox_event o ON o.aggregate_id = u.id AND o.event_type = 'user.created' WHERE o.id IS NULL.

Built-in modules

  • modules/audit-log/AuditQueryService.listForOrg(orgId, filters) (orgId always from session, never query string), GET /admin/audit-log (gated requireOrgPermission({ auditLog: ["read"] })), POST /internal/audit-log-purge (cron sweep operational rows).
  • modules/webhooks/ — full CRUD /settings/webhooks (gated requireOrgPermission({ webhooks: ["read"|"write"] })), WebhookDeliveryWorker with claim window pattern (claim batch with next_attempt_at=now()+(BATCH_SIZE×FETCH_TIMEOUT+30s)\text{next\_attempt\_at} = \text{now}() + (\text{BATCH\_SIZE} \times \text{FETCH\_TIMEOUT} + 30\text{s}), fetch HTTP outside TX, update status in fresh TX) — prevents lock starvation under sustained load. HMAC signing format t=<unix>,v1=<hex-sha256> (Stripe-style), header x-webhook-signature. Idempotency-key <eventId>:<endpointId> (UNIQUE). Replay endpoint creates fresh delivery row.

Lifecycle

  • BootOutboxDispatcher.start(di) + WebhookDeliveryWorker.start() in apps/api/src/index.ts. EventCollector.setOutOfContextLogger wired to pino warn (DX: events emitted outside uow.run() log a warning instead of disappearing silently).
  • SIGTERM/SIGINTPromise.all([stopWithTimeout(webhookWorker), stopWithTimeout(outboxDispatcher)]) parallel shutdown (each worker has 25s timeout, fits in K8s 30s terminationGracePeriodSeconds).

Permissions

  • @packages/access-control extended — auditLog: ["read"] and webhooks: ["read","write"] added to STATEMENTS. Owner + admin get both, member gets neither.

Tests + smoke runtime

  • Unit testsevent-collector.test.ts (ALS isolation between concurrent contexts), aggregate.test.ts (pullDomainEvents atomic), jitter.test.ts (bounds + dead-letter math), aead.test.ts (encrypt/decrypt round-trip + sub-key determinism + ciphertext tampering rejection), hmac-signer.test.ts (Stripe-format signature + verify round-trip + stale timestamp window).
  • Smoke runtime — signup via /api/auth/sign-up/emailoutbox_event row dispatched in <1s → audit_log row written with audit-${eventId} deterministic ID + retention compliance + extractActor heuristic OK. Endpoint registration → org.updated trigger → webhook_delivery row created + delivery attempt fail (URL fake) + retry attempts=2 (decorrelated jitter in action). Multi-tenant: events with organizationId = null skip fanout (verified).

Decisions clés (non-obvious, locked-in by code)

  1. databaseHooks for core models, hooks.after for plugin events — confirmed by reading BetterAuth v1.6.9 source (Context7). Plugin tables (twoFactor, passkey) not exposed in databaseHooks. hooks.after requires APIError instance check (not "error" in returned — that pattern misses APIError instances thrown by handlers).
  2. Built-in subscribers (audit + webhook fanout) run inside dispatch TX, user handlers run post-commit — atomic for the rail, best-effort for handlers. A user handler throwing doesn't fail markDispatched. A built-in subscriber throwing rolls back the entire batch (retried at next drain).
  3. No nested IUnitOfWork.run — Drizzle nested db.transaction() opens independent TXs (not savepoints). Events from inner run would persist in outbox even if outer rolls back (orphan). Hard guard via EventCollector.hasContext() throw.
  4. organizationId = null skips webhook fanout — platform-level events (USER_CREATED, USER_SIGNED_IN) emit without an org context. Without this skip, cross-tenant data leak (every tenant's webhook receives every signup of every other tenant).
  5. AEAD secret stored, plaintext returned once — Stripe pattern. Master key WEBHOOK_MASTER_KEY (32 hex bytes) required at boot in production (env validation throws). Per-org sub-key via HKDF-SHA256(masterKey, salt: undefined, info: "webhook-secret:${orgId}").
  6. Claim window pattern in delivery worker — fetch HTTP outside TX (otherwise 50 deliveries × 30s timeout = 25min TX, kills connection pool). Claim window = BATCH_SIZE×FETCH_TIMEOUT+30sbuffer\text{BATCH\_SIZE} \times \text{FETCH\_TIMEOUT} + 30\text{s} \text{buffer}. Idempotency-key on receiver side prevents double-POST if worker crashes mid-fetch.
  7. hashKey(rawKey) in UPLOAD events — sha256-truncated to 16 chars. Raw filename stays only in S3 + the user's session (never in audit_log/webhooks). PII compliance.
  8. onPasswordReset + /change-password both emit USER_PASSWORD_CHANGED — different flows (reset via email vs. logged-in change), single event type. Receiver dedupes if needed.
  9. Tamper-evidence deferredprev_hash/hash columns posed in audit_log, calc gated by AUDIT_TAMPER_EVIDENCE env flag (off). Implementation choice (Merkle batch vs. row-lock hash chain) parked until SOC2 audit demands.
  10. 3 rounds of multi-agent review — round 1 found 20 issues fixed, round 2 found 13 issues (3 invalidated round 1 fixes, fixed), round 3 found 1 HIGH (stopWithTimeout séquentiel → Promise.all) + 1 MEDIUM (break on stopping in post-commit loop) fixed. Smoke runtime then validated end-to-end.
  11. End-to-end QA pass found 2 real bugs + drove the ORM-first rule. A 36-test harness exercising every event via real HTTP (signup → outbox → audit_log → webhook_delivery → HMAC-signed POST received) caught: (a) ORG_MEMBER_JOINED silently missing on acceptInvitation because BetterAuth routes it through organizationHooks.afterAcceptInvitation — a separate lifecycle from afterAddMember (which only fires for direct adds, not invites); (b) UPLOAD_DELETED declared in the catalog but never emitted (orphan event). Both fixed: dual-hook wiring + DELETE /uploads route with ownership guard. Then the same QA pass exposed several sql\...` raw fragments in services where Drizzle has typed helpers (arrayContains, isNull, inArray, .for("update", { skipLocked: true })) — codified as **cross-cutting rule #5** (ORM-first; raw SQL only for what the ORM doesn't model: DDL, SET LOCAL, server now(), atomic ${col} + 1` per Drizzle docs). 8 conversions, 4 raw fragments justified, all type-safe column refs preserved.
  12. emitEvent tx-aware + drop swallowing catch (post-merge hardening, May 2026). Original service-level emitEvent (used by code outside an aggregate: rgpd, uploads, BetterAuth bridge) opened its own autonomous TX for the outbox INSERT and wrapped the call in catch + logger.error. Two gaps: (a) state change and event emission were not atomic when the caller already had a TX, so an outbox failure could leave the row mutated without its event — silent audit/webhook gap; (b) callers couldn't react to enqueue failures (silently swallowed). Fix: optional tx?: Transaction arg propagated to outbox.enqueue, catch removed. Rgpd writes that ship state-change events (requestAccountDeletion, cancelAccountDeletion, executeAccountWipe migrated startTransaction → run, requestDataExport for the _COMPLETED event) now wrap in uow.run and pass the TX. Upload service and auth.ts BetterAuth bridge stay autonomous (no local DB write, hooks don't expose a Transaction) — documented limitation.
  13. docs/EVENT_PIPELINE.md — pedagogical complement to EVENTS.md. Visual walkthrough (the dual-write problem, 4-phase flow diagram, LISTEN/NOTIFY explained, two-tier delivery, failure modes, SKIP LOCKED concurrency model). EVENTS.md remains the DX guide (how to declare events, register handlers, retention map, deploy).
  14. Retention sweeps (Phase 0.6, May 2026). Three HMAC-gated /internal/sweep-* routes purge the derived pipeline tables (outbox_event / audit_log / webhook_delivery) — closes the only remaining unbounded-growth gap of the event pipeline. SOTA 2026 defaults validated by parallel research (NServiceBus/Debezium for outbox, SOC2/PCI/NIS2 for audit, Stripe/GitHub/Hookdeck for webhook): OUTBOX_RETENTION_DAYS=7, AUDIT_LOG_OPERATIONAL_RETENTION_DAYS=90, AUDIT_LOG_COMPLIANCE_RETENTION_DAYS=365, WEBHOOK_DELIVERY_RETENTION_DAYS=30. Batch pattern DELETE WHERE id IN (SELECT … LIMIT 5000 FOR UPDATE SKIP LOCKED) + SET LOCAL statement_timeout/lock_timeout/idle_in_transaction_session_timeout in a Drizzle transaction. Cron order matters (FK ON DELETE RESTRICT): webhook → audit → outbox. Legacy /internal/audit-log-purge retiredsweep-audit-log is a strict superset (both buckets, env-driven). Rule §6 (every state change emits an event) gained an explicit exception for infra retention sweeps — the business event was already emitted at write time, the sweep deletes its own audit row. Tests reduced to HMAC-gating (401) — mock.module("@packages/drizzle") leaks across parallel bun test files, so the cross-bucket mock was made the superset (anti-pattern documented in apps/api/src/shared/CLAUDE.md).

Observability — Sentry with IInstrumentation port ✅ Phase 0.4 · May 2026

Why: errors silently swallowed in catch + Result.fail(...) blocks were the #1 source of "no idea why it broke in prod" in the boilerplate. SOC2 §CC7.3 + ISO 27001 A.16.1 require monitored incident detection — without an error-tracking rail the cloneur had to retrofit Sentry per-service. The SOTA-2026 audit landed on Sentry only: OTel sub-Bun 1.3+ requires manual Bun.serve() instrumentation and Prometheus /metrics is dead code until a Grafana consumer exists.

Pattern (Lazar-inspired, port-first): single IInstrumentation port (startSpan + capture + addBreadcrumb) injected via inwire DI. NoOpInstrumentation by default, SentryInstrumentation swaps in when SENTRY_DSN is set — single binding flip in container.ts, zero refactor at call sites. Every I/O class (repos, S3, Resend, subscribers, dispatcher, workers) receives the port via constructor and follows the same shape: outer span per public method, inner span per query.execute() / fetch() / client.send() (with OTel SemConv 1.27+ attributes db.system.name: "postgresql" + op: db.query / db.transaction / http.client / function), catch + capture + return-or-rethrow.

  • apps/api/src/shared/ports/instrumentation.port.ts — single 30-line port. No Result<> (telemetry is fire-and-forget, never blocking). Sentry SDK init via side-effect import "./shared/services/sentry-init" as the first line of index.ts (hooks async-hooks before pino/Hono/Drizzle attach).
  • Pino → Sentry breadcrumb bridge via Sentry.pinoIntegration() (SDK v10.18+ native, replaces deprecated @sentry/pino-transport).
  • beforeSend scrub whitelist — drops email, username, ip_address, cookie/Cookie, authorization/Authorization, x-csrf-token, query_string, data. Preserves innocent headers (content-type). RGPD-clean by default + sendDefaultPii: false. Sentry UI Data Scrubber (Settings → Security & Privacy) documented as defense-in-depth.
  • Front (@sentry/react)Sentry.ErrorBoundary wraps the router with shadcn AppErrorFallback, React 19 createRoot receives onUncaughtError / onCaughtError / onRecoverableError handlers from Sentry.reactErrorHandler(). @sentry/vite-plugin gated on SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECT (CI-only); sourcemap: "hidden" (uploadable but not view-source leakable).
  • 154 tests — every repo / service / subscriber / worker covered. Mocks of @packages/drizzle + @packages/events expose the complete superset of exports in every file (bun:test mock.module leaks globally → partial mocks surface as SyntaxError: Export named 'X' not found in unrelated parallel test files). Anti-pattern documented in apps/api/src/shared/CLAUDE.md.
  • docs/OBSERVABILITY.md — full doc: port usage, Lazar instrumentation pattern (outer/inner span), removability runbook (5 steps: trash module + remove .addModule() + unset DSN = NoOp everywhere, zero refactor), provider swap recipe (Sentry → GlitchTip self-hosted, drop-in DSN), Sentry UI scrubber, deferred section (OTel + Prometheus + Session Replay + tracesSampleRate>0 all wait for Phase D.1 Grafana consumer).
  • Root rule §8 — "Every I/O method declares a span; every catch surfaces the error to telemetry." Omnipotent rule with Test before merging: every public I/O method calls this.instrumentation.startSpan(...) or has a documented reason not to (multi-query exception like executeWipe).

Decisions (Phase 0.4)

  1. Single merged port (vs Lazar's two separate IInstrumentationService + ICrashReporterService). Justification: in this codebase both surfaces consume the same Sentry global, splitting forces double-wiring at every call site for zero portability gain.
  2. Sentry-only, OTel + Prometheus deferred to D.1. Bun OTel auto-instrumentation requires manual Bun.serve() wiring (not stable until Bun 1.4); prom-client without Grafana scrape = code mort. Same anti-NIH principle as Phase 0.3: ship infra cross-cutting only when a consumer exists.
  3. SentryInstrumentation constructor-injected, NOT module-level singleton. Mirrors the IEmailService / IStorageService pattern; respects the "no service-locator" rule. Sentry SDK init remains a side-effect import (the SDK detains global state — wrapping that init would just recopy Sentry.* and lose typings).
  4. createErrorHandler(instrumentation) factory pattern for error.middleware. Middlewares importing di directly from container.ts would risk a runtime cycle if any module imported back into shared/middleware/. Factory takes the dep as a parameter, called once in index.ts after di.build() — cycle-immune.
  5. NoOp + Sentry surfaces stay symmetric. apps/app/src/shared/observability/{noop,sentry}.ts export identical signatures (captureError, addBreadcrumb, ErrorBoundary, reactErrorHandler). noop.ts listed as knip entry so it never goes stale. Swap = single import path change; no runtime alias gymnastics.

Disaster recovery — PITR-first, doc-only deliverable ✅ Phase 0.3 · May 2026

Why: SOC2 §A.1 + ISO 27001 A.12.3 require a tested backup/restore policy. clean-stack had none — a cloneur in prod had to improvise. The original roadmap entry expected a pg_dump cron route in the API; SOTA 2026 audit reversed that decision.

Why no code shipped: SOTA-2026 closed the case. Every managed Postgres provider (Railway, Neon, Supabase, AWS RDS, Fly) ships PITR one-click with sub-minute RPO and 7–35 d retention. pgBackRest lost its maintainer in 2026 — building a clean-stack route on top would have been a regression. A custom /internal/backup-postgres would duplicate what the platform already does (provider snapshots), force postgresql-client into the Docker image, and introduce streaming/OOM/timeout failure modes that the cloneur would inherit for zero value. clean-stack ships cross-cutting multi-tenant infrastructure (audit, webhooks, RGPD, observability) — backup is infra DB, owned by the provider.

  • docs/DISASTER-RECOVERY.md — full DR doc covering: RPO/RTO targets (1–5 min PITR / 7 d fallback), 3-2-1 rule applied to a clean-stack deployment, restore runbook (provision ephemeral target → download → restore → smoke-check inline psql count(*) script → roll-forward vs in-place vs side decision tree).
  • PITR setup per provider — short pointers for Railway (add-on), Neon (branch-based, sub-second), Supabase (add-on, 7 d granularity), AWS RDS (automated, 35 d max), Fly volume snapshots, self-hosted WAL-G (pgBackRest flagged unmaintained, Barman as alternative).
  • Weekly portable pg_dump export — copy-paste recipes for GitHub Actions, Railway Cron, and K8s CronJob. Streams pg_dump | gzip | aws s3 cp - (no OOM, multipart auto via AWS CLI). Targets backups/postgres/<ISO>.sql.gz in the existing S3 bucket — no dedicated bucket required. Read-only Postgres role mandated per CI-secret-leak best practice.
  • Monthly automated restore-test — GitHub Actions workflow recipe: spawns services.postgres:17-alpine on port 5436, downloads latest dump, restores via gunzip | psql, runs inline psql count(*) smoke per table, fails loud.
  • Lifecycle + versioning snippetsaws s3api put-bucket-lifecycle-configuration (expire weekly exports 30 d, transition monthly snapshots STANDARD → GLACIER 1 y), aws s3api put-bucket-versioning Status=Enabled, MFA-delete note. Caveats: Cloudflare R2 has no GLACIER class (use STANDARD_IA), SeaweedFS lifecycle/versioning partial depending on version.
  • README + CRON.md updates — README ## Deployment links to DISASTER-RECOVERY.md alongside HEALTH-PROBES.md. CRON.md adds a "Not an internal endpoint" section pointing at the DR doc, to prevent future contributors from re-litigating the "should we add POST /internal/backup-postgres" question.

Decisions (Phase 0.3)

  1. Doc-only. No route, no script, no Docker image change. SOTA-2026 (Pettus "After pgBackRest", WAL-G K8s guide, provider PITR comparisons) made the boilerplate-side code obsolete before it was written. Encoded as the anti-NIH default for infra layers a provider already owns — applies to Phase 0.4 candidates too (don't ship code that competes with Sentry/Grafana SaaS one-click setup; ship NoOp adapters that swap to those services).
  2. backups/postgres/ prefix in the existing S3 bucket, not a dedicated R2_BACKUP_BUCKET. Trade-off accepted: lifecycle policy + delete-protection apply to the whole bucket, slightly less SOC2-friendly. Justification: simpler ops, no extra env var for the cloneur, and the lifecycle filter (Prefix: "backups/postgres/") still isolates the expiry rule.
  3. Read-only Postgres role for the export job, not the API role. CI-secret-leak best practice: pg_dump only needs GRANT CONNECT, USAGE, SELECT. Documented in the doc itself rather than as a separate runbook — the YAML recipe links its secrets.DATABASE_URL to "a read-only role" inline.
  4. No pnpm db:smoke script committed. The verification step (select count(*) per table) is a 15-line inline snippet in the doc; the cloneur drops it in apps/api/scripts/db-smoke.ts if they want to keep it. Adding it to the boilerplate would couple to a specific schema enumeration that drifts on every domain change — the doc instead shows how to iterate from Drizzle's exported schema namespace, which doesn't drift.
  5. No GitHub Actions workflow committed. Same rationale as docs/CRON.md § Wiring: clean-stack ships the recipes, the cloneur picks the scheduler. Committing .github/workflows/postgres-export.yml would silently fire on every fork without secrets — bad UX. Documented YAML stays inert until copied.

Health probes — /livez · /readyz · /startupz ✅ Phase 0.2

Why: K8s / Railway / Fly / Render all probe liveness/readiness/startup; absence = restart loops + 502s during deploys. SOTA 2026 = three probes, IETF draft-inadarei response format, graceful shutdown wired to /readyz. Full per-PaaS recipes in docs/HEALTH-PROBES.md.

  • modules/health/ vertical slice + IHealthCheckRegistry (shared/ports/health.port.ts). Each infra-owning module ships an XxxHealthProbe implements OnInit that self-registers at di.preload()trash the module removes its probe in one shot, no orphan.
  • /livez liveness, no dependency hit (a DB outage must not restart pods → thundering herd). /readyz aggregates checks (db SELECT 1 critical, storage HeadBucket non-critical), tri-state pass/warn/fail → 200 unless a critical check fails (503). /startupz shields a slow boot from a tight liveness threshold.
  • Asymmetric cache (positive 30s / negative 5s) + self-cancelling 5s timeout on /readyz. Mounted outside requestId/httpLogger/cors/session middleware (probes carry no cookies; ~17k hits/day/pod would drown logs).
  • Graceful shutdownSIGTERM flips lifecycleState/readyz returns 503 within one probe interval (LB drains), waits SHUTDOWN_GRACE_PERIOD_MS (15s default), then stops the workers. Without it: intermittent 502s on every deploy.
  • Prod-validation hardening (Jun 2026)/livez + /startupz payloads trimmed to {status, uptimeMs}; build metadata (version/commitSha/runtime) moved behind /internal/build-info (HMAC-gated). Public probes were an info-disclosure vector (version fingerprinting + exact-source mapping for private clones). See the Railway closeout below.

Removability dry-run — first leaf removed end-to-end ✅ Phase 0.5 · May 2026

Why: the vertical-slice contract claims "a leaf feature is removable in 5 minutes". Until one was actually removed end-to-end, that was theory. Runbook + worked example + edge cases in docs/REMOVABILITY.md.

  • Removed modules/rgpd end-to-end in a throwaway worktree: 46 files touched, −2980 LOC net, 3 DROP COLUMN migration. All 6 gates green (type-check, ci:check, check:unused, check:duplication, build, test baseline-preserving).
  • 4 surprises captured — a 3rd RGPD column missed by the initial cartography, a transitively-dead throwApiError helper, a dangling knip pattern, pre-existing test fails unrelated to the removal. Contract holds: TS error-points the rest.
  • 6-axis checklist codified (schema barrel, DI .addModule() + app.route(), access-control statements, front nav, email templates) — the canonical "how to remove a feature".

Railway reference deploy — config-as-code SOTA 2026 ✅ Phase 0.7 · May 2026

Why: clean-stack mentionnait Railway dans 3 docs (HEALTH-PROBES.md, DISASTER-RECOVERY.md, EVENTS.md) mais aucun cloneur n'avait jamais validé le boilerplate de bout en bout. Le projet Railway existant tombait sur Nixpacks par défaut (pas de railway.toml) et ne savait pas piloter le monorepo pnpm + Bun. Phase 0 ne pouvait pas être fermée avec ce gap.

Why SOTA 2026 décidé après recherche (sources docs.railway.com 2026) : pattern monorepo "shared root" — tous les services pointent rootDirectory = / (build context = repo root pour résoudre packages/), chacun a un custom config file path (infra/railway/<service>.toml). Format TOML (lisibilité > JSON pour edit humain). cronSchedule natif dans deploy block (vs service séparé pré-2025). Reference Variables (${{shared.NAME}}, ${{Postgres.DATABASE_URL}}) pour secrets cross-service — jamais dupliqués.

  • infra/railway/{api,app,cron}.toml — 3 services pinés au schéma officiel ("$schema" = "https://railway.com/railway.schema.json"). api : healthcheckPath = "/livez", restartPolicyType = "ON_FAILURE", numReplicas = 1. app : healthcheckPath = "/health" (Caddy), 0 réplicas extra. cron : cronSchedule = "17 3 * * *", startCommand = "bun dist/cron/sweep.js", restartPolicyType = "NEVER". Tous : watchPatterns scopés pour éviter les rebuilds inutiles (api ne se redéploie pas quand seul apps/app/ change).
  • apps/api/src/cron/sweep.ts — chaîneur des 3 sweeps dans l'ordre FK (webhook → audit → outbox) via signedInternalFetch (object input — la signature outdated dans docs/EVENTS.md qui utilisait 3 args positionnels est corrigée du même coup). Lit API_URL et INTERNAL_SIGNING_KEY de l'env, process.exit(1) au premier non-2xx. Bundle entrypoint ajouté à bun build aux côtés de index.ts+migrate.tsdist/cron/sweep.js. Le service cron Railway réutilise l'image api (single Dockerfile, single source de vérité) — pas de nouveau cron.Dockerfile.
  • Fix in-scope apps/api/prod.Dockerfile + apps/app/prod.Dockerfile — 2 bugs latents qui auraient mordu en prod : (a) HEALTHCHECK api pointait /health (n'existe pas — le boilerplate utilise IETF /livez depuis Phase 0.2). Railway utilise son propre healthcheck via healthcheckPath du .toml donc le bug ne bloquait pas le deploy Railway, mais Docker local marquait le conteneur unhealthy à tort et tout autre orchestrateur (Fly, K8s) aurait souffert. (b) RUN pnpm --filter "@packages/*" run build retiré des deux Dockerfile — viole rule §4 ("internal packages ship source"). Seul ddd-kit a un script build (pour publication npm future), inutile en monorepo : Bun's bundler résout les exports src/index.ts directement. La ligne faisait du compute wasted et risquait de produire un dist/ qui shadow le src.
  • apps/api/.env.example audité — markers # REQUIRED IN PROD sur les vars critiques (DATABASE_URL, BETTER_AUTH_URL, BETTER_AUTH_SECRET, CORS_ORIGIN, APP_URL, RESEND_*, INTERNAL_*, WEBHOOK_MASTER_KEY, S3_*, SENTRY_*, GIT_SHA, BUILD_TIME, API_URL côté cron). Source de vérité unique pour la sync vers Railway Shared Variables — la table de mapping dans DEPLOY-RAILWAY.md réfère ce fichier.
  • Pas de .github/workflows/deploy.yml — Railway watch main nativement via l'intégration GitHub + watchPatterns par service scope les rebuilds. Pas besoin de webhook GH Actions (1ère itération en avait un, retiré après audit : risque double-deploy, ajoute des secrets GH inutiles, viole le principe single-source). GIT_SHA/BUILD_TIME injectés via Reference Variables Railway (${{RAILWAY_GIT_COMMIT_SHA}}/${{RAILWAY_GIT_COMMIT_MESSAGE}}).
  • docs/DEPLOY-RAILWAY.md — runbook complet (12 sections) : setup projet + Postgres add-on EU + création des 3 services (root dir / + config-as-code path par service) + Shared Variables (table de génération openssl rand ...) + per-service Variables (api/app/cron avec Reference Variables) + R2 setup par défaut (jurisdiction EU, lifecycle tmp/ 30j + backups/ 365j aligné DISASTER-RECOVERY, API token scopé) + alternative Railway Bucket (calcul comparatif coût 5GB/50K writes/500K reads/20GB egress — R2 gagne sur free tier + zero egress, Railway Bucket facture egress service→bucket sur réseau public) + Resend + Sentry + custom domains + first deploy (Railway watch auto, pas de plumbing GH Actions) + smoke-test 6 étapes (sign-up→email→org→upload R2→RGPD outbox+audit→Sentry release) + removability runbook vers Fly/Render/Cloud Run (Dockerfile + sweep.ts entrypoint portables) + troubleshooting matrix.
  • docs/EVENTS.md cron recipe nettoyé — section "GitHub Actions example" supprimée (user veut Railway Cron exclusif sur le boilerplate déployé). Remplacée par pointer vers apps/api/src/cron/sweep.ts comme entrypoint canonique, doc des autres orchestrateurs en one-liner (Fly Machines --schedule, Render Cron Job, K8s CronJob, Cloud Scheduler) — la signature primitive est platform-agnostic. docs/CRON.md ligne 164 mise à jour pour pointer vers Railway Cron + entrypoint.
  • ROADMAP.md — 0.7 passé en DONE avec résumé complet. 0.6 entry mise à jour pour refléter le déplacement de l'entrypoint cron du recipe inline vers apps/api/src/cron/sweep.ts.

Decisions (Phase 0.7)

  1. Single-source cron : Railway Cron uniquement, pas de fallback GH Actions. Décision user explicite après la première itération du plan qui proposait les deux. Justification : le boilerplate prescrit Railway pour le reference deploy, multiplier les chemins de cron = noise (double-purge harmless mais log noise + risque de désync) et invite le cloneur à hésiter. Si un cloneur veut un autre scheduler, le runbook documente le swap. .github/workflows/sweep.yml jamais commit ; docs/CRON.md reste scheduler-agnostic au niveau philosophique (Railway Cron + K8s + Inngest comme wiring options) — l'asymétrie est intentionnelle : doc générique vs ship config concrète.
  2. Storage : Cloudflare R2 par défaut, Railway Bucket en alternative documentée. R2 free tier 10 GB + zero egress + $0.36-$4.50/1M ops > Railway Bucket pour un boilerplate. Le piège Railway Bucket : egress service→bucket facturé (réseau public, pas privé) — invisible dans la pricing page Railway, calculé inline dans le runbook. User a posé la question coût en cours de plan : la réponse "R2 reste moins cher même quand tu paies déjà Railway" a été chiffrée + documentée.
  3. Cron service réutilise l'image api, pas de Dockerfile dédié. Single source of truth pour le binary qui parle au pipeline d'événements. Le startCommand override + cronSchedule suffisent — Railway Cron est un type de service ordinaire avec ces 2 deltas. Alternative envisagée : apps/cron/Dockerfile séparé — rejetée car duplique pnpm install + bun build pour zéro gain (et drift entre les deux images au prochain bump de version).
  4. Custom config file path en dashboard, pas multiple railway.toml à la racine. Railway 2026 ne supporte qu'un seul railway.toml par service-root. Pour partager le build context (= repo root, requis pour packages/), seule option = rootDirectory = / pour les 3 services + 3 chemins de config différents dans le dashboard. Pas reproductible via fichier seul (le dashboard setting reste manuel) — documenté en étape 1 du runbook.
  5. Pas de preDeployCommand pour la migration (Railway-spécifique). Garde le pattern CMD migrate && start portable (marche sur Fly, K8s, Cloud Run sans modif). preDeployCommand est une optimisation Railway (1 migration par deploy au lieu d'1 par restart de container) — vaut le coup quand la suite scale > 5 réplicas ; sur 1 réplique le cost est nul. Defer to operational phase.
  6. Pas de numReplicas = 1 explicite. Première itération avait numReplicas = 1 dans [deploy] — invalid : ce champ n'existe que sous [deploy.multiRegionConfig.<region>] (Railway docs 2026). Retiré ; default Railway = 1 réplique implicitement.
  7. Pas de GH Actions deploy workflow. Railway watch main nativement + watchPatterns par service = zero glue nécessaire. Première itération avait un .github/workflows/deploy.yml matrix qui hit les Deploy Webhooks Railway — retiré après audit (double-deploy avec le watch natif, secrets GH supplémentaires, viole single-source). Le pattern webhook reste utile pour deploys cross-service ordonnés (defer si jamais nécessaire en phase opérationnelle).

Right to rectification (Art. 16) + NIST 800-63B-4 ✅ Phase A.1 · Jun 2026

Why: two non-negotiables bundled in one push. Art. 16 GDPR requires a working edit-profile surface (BetterAuth back-end already supports it; the boilerplate only exposed disabled placeholders). NIST SP 800-63B-4 final (August 2025) is the SOTA password baseline — minimum length 15, HIBP breach screening, no complexity rules. Both touch the same surface (/settings/account + auth flows); shipping together avoids a second round-trip.

Back-end

  • user.profile.updated event (USER_PROFILE_UPDATED, retention compliance) — emitted in auth.ts via hooks.after on path /update-user. Payload: { userId, changes } (field-level diff).
  • user.email.change_requested event (USER_EMAIL_CHANGE_REQUESTED, retention compliance) — emitted in the user.changeEmail.sendChangeEmailConfirmation callback. Payload: { userId, newEmail }.
  • IPasswordBreachService port (shared/ports/password-breach.port.ts) + HibpPasswordBreachService impl (shared/services/) — HIBP k-anonymity (api.pwnedpasswords.com/range/<sha1[:5]>, Add-Padding header, timeout HIBP_TIMEOUT_MS default 3000 ms). Instrumented (span http.client). Fail-open on network error — breach check failure is captured and logged but never blocks the user.
  • findPasswordViolation() + validatePassword() helpers (shared/password-policy.ts) — pure, unit-testable. findPasswordViolation bans email-local-part, display name, and app name (clean-stack / cleanstack). validatePassword adds the length-guard (skips HIBP below MIN_PASSWORD_LENGTH) then the breach check, returning the first violation or null. The inline ~20 common-password list was dropped — HIBP already covers every common password, so it was dead weight.
  • auth.ts changesemailAndPassword.minPasswordLength: MIN_PASSWORD_LENGTH; hooks.before calls validatePassword(...) on /sign-up/email, /reset-password, /change-password (throws APIError 422 on violation); user.changeEmail.enabled: true + confirmation sent to the current address (not the new one) + change_email email template; databaseHooks.user.update.after clears pendingEmail and emits user.profile.updated { changes: { email } } once the new address becomes effective.
  • pendingEmail field — nullable pending_email column (packages/drizzle/src/schema/auth.ts, migration 0004) exposed as a BetterAuth additionalField (returned: true, input: false). Set in sendChangeEmailConfirmation, cleared on effective change — drives the front "pending change" badge.
  • Container + envIPasswordBreachService binding in container.ts; HIBP_TIMEOUT_MS in env.ts.
  • Testshibp-password-breach.service.test.ts (k-anonymity, found/not-found, network-error fail-open, non-ok response) + password-policy.test.ts (findPasswordViolation + validatePassword: length-guard skips HIBP, contextual ban, breach hit, fail-open).

Front-end

  • ProfileCard — replaces the dead Card with two disabled inputs in features/account/account.page.tsx. Edits name + email (confirmation to current address; a <Badge> "Pending change to X" renders while user.pendingEmail is set) + avatar upload via the existing createUploadMutationOptions (presign → PUT → confirm), with a client-side type (image/*) + size (5 MB) guard and filename sanitization (accents stripped / invalid chars → -, to satisfy the server ^[\w\-. ]+$ contract) before upload. See docs/STORAGE.md for the key layout.
  • ChangePasswordCard — standalone card below ProfileCard, inline with existing Passkeys/2FA/Sessions/DataExport cards.
  • strongPasswordSchema (shared/auth/auth.schema.ts) — updated to min(15), all complexity regexes removed (NIST SHALL NOT impose complexity). Applied to sign-up + reset flows as well.
  • Password field UX (NIST-aligned)FormTextField (@packages/ui) gained a show/hide reveal toggle (NIST 800-63B-4 §3.1.1.2 recommends letting users reveal the password) + an optional description slot. Every new-password input (sign-up, reset, change) carries the hint "At least 15 characters. Avoid passwords exposed in known data breaches." Server-side policy rejections (HIBP breach, contextual ban, wrong current password) surface inline on the offending field via form.setError — routed to currentPassword vs newPassword by the message — instead of a transient toast.

Decisions

  1. 15 chars everywhere, no MFA exception — the 8-with-MFA floor is a NIST permission, not an obligation. Implementing the two-tier would add session-state coupling to the password validator with zero security benefit at this scale. Simpler to hold 15 universally.
  2. HIBP fail-open — breach-check failure (network, timeout) is captured via IInstrumentation.capture() but never blocks auth. Rationale: a transient HIBP outage must not prevent users from signing up or resetting. The risk of accepting one pwned password during a 3-second HIBP blip is lower than locking out all sign-ups.
  3. Validation via hooks.before, not password.hash overridepassword.hash intercepts only hashing; hooks.before intercepts at the route level before any BetterAuth processing. This cleanly separates validation (policy) from hashing (crypto), and avoids reproducing BetterAuth's internal scrypt call.
  4. user.changeEmail confirmation to the current address — confirms the current owner is aware of the change before the new address takes effect. BetterAuth auto-handles the verification challenge to the new address. The change_email template is new.
  5. No /settings/profile page — rectification fields live in /settings/account (the existing page). A dedicated /settings/profile tab is reserved for Phase A.5 (Privacy dashboard). Splitting now would fragment UX without a composing container.
  6. Password policy extracted to a pure validatePassword() — the security-critical logic (length guard + contextual ban + HIBP) can't be unit-tested inside a BetterAuth hook (hooks aren't testable in isolation), so it lives in password-policy.ts and the hook is a one-line caller. The inline common-password list was removed as redundant with HIBP. Schema change shipped as migration 0004 via db:generate && db:migrate (never db:push for a committed change — push bypasses __drizzle_migrations and desyncs the migrate trail).

Change-email flow (2-step, BetterAuth)

user.changeEmail runs a two-confirmation flow — neither the request nor the first click mutates user.email:

  1. RequestauthClient.changeEmail({ newEmail, callbackURL })POST /change-email (fresh-session gated). Our sendChangeEmailConfirmation hook sets pendingEmail = newEmail, emits user.email.change_requested, and mails the current address a confirmation link.
  2. Confirm (current address) — the link hits GET /api/auth/verify-email?token=… (a change-email-confirmation JWT). BetterAuth mints a second token and mails the new address a verification link. Email still unchanged; redirects to callbackURL.
  3. Verify (new address) — the second link (change-email-verification JWT) is what flips user.email to the new value (emailVerified: true). This fires databaseHooks.user.update.after, where we clear pendingEmail and emit user.profile.updated { changes: { email } }. The front "pending change" badge clears on the next session refetch.

Mail links point at the API (baseURL/verify-email), not the app front — for this one auth mail the click is the state transition (BetterAuth applies it server-side), then redirects to callbackURL (we pass /settings/account). The other auth mails (reset, magic-link, verify) route through the app because the front consumes their token. Storage/key conventions for the avatar upload are documented in docs/STORAGE.md.


Prod-validation closeout (Jun 2026) — live on main, release 1.19.2

The config-as-code shipped (above) but no one had run it end-to-end. Bringing the reference deploy actually green on Railway surfaced a stack of prod-boot traps, each masking the next (a failed deploy only reveals the next layer). All fixed (PR #50/#51 → main); api + app verified live.

  • NODE_ENV override trap. A Railway service var NODE_ENV=development overrode the Dockerfile ENV NODE_ENV=production (service vars beat Dockerfile ENV at runtime). In dev mode the logger loads pino-pretty — a devDependency absent from the --prod install → instant boot crash (unable to determine transport target for "pino-pretty"). Fix: set NODE_ENV=production explicitly, or leave it unset (the Dockerfile wins). Never development in prod.
  • WEBHOOK_MASTER_KEY unsetenv.ts prod guard threw at boot. Generated + set.
  • @packages/access-control declared better-auth as peer/devDependency, not a dependency. A workspace package that imports a lib in its runtime source must declare it under dependencies, else pnpm install --prod skips it and the import is unresolved in the pruned image (Cannot find module 'better-auth/plugins/access'). Compiles in dev (hoisting + devDeps present), breaks only in the pruned prod image. Moved to dependencies.
  • Email + storage threw at boot. di.preload() is eager, so ResendEmailService and S3StorageService are constructed at startup and fail-hard'd when unconfigured. Changed to warn-and-degrade: email logs-not-delivers, storage swaps to a NoOpStorageService (returns STORAGE_PROVIDER_FAILURE; /readyz reports storage:s3 as a non-critical warn). The API now boots without Resend/R2; those features stay inert until configured. Reverses the Phase 1 email "boot-time fail-hard in production" decision — right for a configured SaaS, wrong for a clonable boilerplate that must boot before the cloner wires Resend.
  • app service Start Command override. A leftover Railway Start Command pnpm --filter app start replaced the Dockerfile Caddy CMD in the caddy:2.11-alpine runner (no Node/pnpm) → the container exits instantly with zero logs, the healthcheck never passes, the deploy fails silently and reads as a phantom. Cleared the override so the Dockerfile caddy run CMD applies.
  • Cross-site cookies. app and api sit on different *.up.railway.app hosts = different sites (up.railway.app is a public suffix). BetterAuth session cookie set to sameSite: isProd ? "none" : "lax" so it survives the cross-site credentialed fetch. Custom domain under one shared parent (api.x.com + app.x.com) → lax works and is preferable; documented in DEPLOY-RAILWAY.md §8.
  • Docs hardened for clonersdocs/DEPLOY-RAILWAY.md gained §0 (boot traps + fail-hard-vs-degrade matrix), §8 (cookie strategy by domain topology), §9 (probe info-disclosure note), §12 (troubleshooting matrix with every trap above).

Lessons (locked in):

  1. railway up deploys local working-tree code; a variable change / Redeploy / git push rebuilds from the connected branch. During the fix loop railway up was the only way to test uncommitted code; a mid-fix variable change rebuilt from main (no fixes yet) and re-crashed — confirming the model. Once merged, GitHub-integrated deploys took over and went green.
  2. RAILWAY_GIT_COMMIT_SHA only populates on branch deploys, not railway up. GIT_SHA/BUILD_TIME show unknown under railway up; set them to the Railway reference vars (${{RAILWAY_GIT_COMMIT_SHA}}) so build-info tracks the deployed commit.
  3. A multi-service deploy fails one layer at a time. Each ~3-minute build only reveals the next boot trap. Pre-scan for the whole class (eager-preloaded constructors that throw, peer-deps imported at runtime, dashboard overrides) instead of one-fix-per-deploy.

Privacy / Terms versioning ✅ Phase A.2 · Jun 2026

Why: Art. 7 §1 RGPD — "the controller shall be able to demonstrate that the data subject has consented". Demonstrability requires recording which version was accepted and when. The boilerplate had zero versioning — a cloner shipping a policy update had no way to re-prompt users or produce compliance evidence. This phase closes that gap and lays the foundation for A.4 (consent stamps the policy version) and A.5 (privacy dashboard shows acceptance history).

Shared package @packages/policies — version SSOT

  • Source-only package mirroring @packages/access-control (no build, exports points at src/). Exports POLICY_TYPES (["privacy","terms"]), PolicyType, POLICY_VERSIONS (Record<PolicyType,string>, both "2026-01-15"), POLICY_CHANGELOG, PolicyChangelogEntry. Imported by apps/api (gate + service), apps/app (sign-up form + page render), and @packages/drizzle (schema enum). Single place to bump a version — every consumer sees the change at compile time.

DB — policy_acceptance table

  • Append-only table policy_acceptance (packages/drizzle/src/schema/policies.ts): id, userId (FK user ON DELETE CASCADE), policyType, policyVersion, ipAddress (nullable), acceptedAt + composite index (userId, policyType, acceptedAt DESC). Migration 0005_sudden_leo.sql.
  • Two-layer compliance trail: this table is the live gate evidence (fast lookup of latest accepted version per type); the durable 7-year compliance trail lives in audit_log via the user.policy.accepted event (compliance retention).

Backend module apps/api/src/modules/policies/ — compliance infra, not DDD

  • Not DDD — no aggregate, no domain events on a Value Object. Mirrors the audit-log module shape: a thin service over a port-backed store. A.4 (Cookie consent) is the same class — its expiry/withdrawal/scope rules reduce to date comparisons + includes() + the same version ===, so it ships as infra too. The boilerplate ships zero aggregates; @packages/ddd-kit/Aggregate is a published-lib surface that waits for the cloner's real product domain.
  • IPolicyAcceptanceStore port (module-private) + DrizzlePolicyAcceptanceStore — fully instrumented per rule §8 (outer span wrapping the method, inner span on query.execute(), catch + instrumentation.capture).
  • PolicyAcceptanceServiceaccept(userId, types, ipAddress?) writes N rows + emits N events atomically in one uow.run TX. getStatus(userId) returns Record<PolicyType, { current, acceptedVersion }>. getStaleTypes(userId) returns only the types where the latest accepted version differs from the current. hasAcceptedCurrent(userId) is the gate predicate.
  • Routes: POST /me/policies/accept (body { types?: PolicyType[] } — omit to accept all stale), GET /me/policies. Both require auth. Mounted in index.ts routes chain.
  • requireCurrentPolicies middleware (apps/api/src/shared/middleware/policy.middleware.ts) — throws HTTPException(409) when any policy is stale. Composable, not mounted globally by default — there are no business routes yet; this is defense-in-depth for future routes. The live UX gate (the _shell beforeLoad redirect) is the primary enforcement today.

Event user.policy.accepted — event catalogue grows from 34 to 35

  • Self-actor payload { userId, policyType, policyVersion, ipAddress? }, retention compliance. userId resolves as the actor via AuditEventSubscriber.extractActor (self-actor: the subject accepted for themselves). Emitted from PolicyAcceptanceService.accept — fired from two sites: (1) the BetterAuth /verify-email after-hook in auth.ts (sign-up path), and (2) the POST /me/policies/accept route (explicit re-acceptance).

As-built deviation: acceptance recorded at /verify-email, not /sign-up/email

The original plan proposed recording acceptance at the /sign-up/email route. This was changed during implementation for two reasons:

  1. No session at sign-up. With requireEmailVerification: true, /sign-up/email has no session yet — reading a reliable userId from the response is unsafe because BetterAuth returns a synthetic user on duplicate-email attempts (anti-enumeration). The userId in the response is not guaranteed to be the just-created user.
  2. /verify-email is the natural idempotent boundary. This route fires exactly when the user proves ownership of their email address. The session userId is reliable. Using getStaleTypes(userId) makes the call naturally idempotent — a user who re-verifies after an email change is a no-op because they already accepted the current version.

Safety net: the front _shell beforeLoad gate redirects any authenticated user with stale policies to /legal/accept regardless of which auth path they used (social login, magic link, or future SSO). This ensures the re-acceptance gate catches any edge case that bypasses the auth.ts hook.

Frontend apps/app/src/features/legal/

  • Sign-up formsignUpSchema gained a required acceptedPolicies: z.literal(true) checkbox; its links to the two public pages open in a new tab so a misclick doesn't wipe the entered form. Acceptance is non-optional at sign-up; the server records it at /verify-email.
  • Public pages /legal/privacy-policy + /legal/terms — placeholder legal content keyed by version string, shared PolicyDocView component, policies.config.tsx registry + getChangesSince helper for the diff view.
  • Acceptance gate /legal/accept — under _protected, outside _shell to avoid a redirect loop. Adapts to context: a brand-new user with no prior acceptance (the magic-link / social sign-up path, where no checkbox was ever shown) sees a "Before you get started" welcome + a link to read each full policy; a returning user whose accepted version is stale sees an "Updated policies" header + the per-version changelog diff (getChangesSince). One Accept button either way; on success navigates to the originally intended route. This is what makes the magic-link sign-up path both legally explicit (affirmative accept, version+IP+timestamp recorded) and UX-clean (no confusing "what changed" on a first acceptance).
  • _shell beforeLoad — calls policiesQueryOptions (fresh) and redirects to /legal/accept?redirect=<current> when any type is stale. This is the primary UX enforcement layer.
  • shared/api/queries/policies.ts (policiesQueryOptions), shared/api/mutations/accept-policies.ts, hooks/use-accept-policies.ts.

How a cloner uses it

Drop real legal text in policies.config.tsx. When a policy changes, bump the relevant string in @packages/policies/src/versions.ts and add a POLICY_CHANGELOG entry with a summary of changes. All authenticated users are re-prompted on next visit automatically — the _shell gate picks up the new version on the next query invalidation.

Decisions

  1. @packages/policies as SSOT, not a front-only config. The version string must be the same on the API (gate: is this version current?), the front (display + sign-up checkbox), and the DB schema (enum values). A front-only config means the API has a hardcoded constant elsewhere — drift. A shared package eliminates the sync requirement entirely.
  2. Append-only policy_acceptance, not an upsert. Compliance requires the full history of when each version was accepted. An upsert destroys past evidence. The gate reads the latest row per (userId, policyType) via the DESC index — equivalent to an upsert for the gate predicate, with the full trail preserved.
  3. Not DDD. The acceptance rule is latestAcceptedVersion === currentVersion — a comparison, not an invariant that requires aggregate lifecycle protection. Using an aggregate here would be the OpenUp anti-pattern (rule §test décisif: if the rule fits in a comparison, it's infra). A.4 (Cookie consent) is the same callisActive = withdrawnAt == null && expiresAt > now && policyVersion == current is a WHERE clause, scope is includes(), validity/cooldown are date math; it ships as infra too. The boilerplate ships zero aggregates@packages/ddd-kit/Aggregate earns its keep only once the cloner adds real product domain.
  4. /verify-email hook, not /sign-up/email — see deviation note above. The key insight: the gate-predicate (front _shell) is the primary enforcement; the hook is best-effort plus defense-in-depth for the sign-up path specifically.
  5. requireCurrentPolicies composable, not global default. Mounting it globally on all authenticated routes would make every current API call return 409 for a stale user — too aggressive. The UX re-acceptance gate is the live enforcement. The middleware is opt-in for future business routes that need hard server-side gating.

Security perimeter — rate-limit + CSP + CSRF ✅ Phase C.1 · Jun 2026

Why: a boilerplate that ships auth, multi-tenant, and billing surfaces without a security perimeter is a liability for every cloner. Phase C.1 closes the four cheapest attack vectors: brute-force / credential-stuffing (rate-limit), XSS script injection (CSP), cross-site request forgery (CSRF), and CSP telemetry (report endpoint). All four were implemented as composable infra — no business logic inside, each addable or removable in index.ts without touching modules.

S1 — Rate-limit core

  • requireRateLimit(deps, policy) factory (apps/api/src/shared/middleware/rate-limit.middleware.ts) — Hono middleware wrapping IRateLimiter.consume. On allowed: sets IETF RateLimit-Policy + RateLimit response headers (budget advertising). On blocked: sets Retry-After (floored to 1), throws AppErrorException({ code: "SECURITY_RATE_LIMITED" }) → central error handler → 429. On first block: emits security.rate_limit.exceeded event if policy.emitSecurityEvent and outbox provided. On store error: either 503 RATE_LIMITER_UNAVAILABLE (fail-closed) or warn + pass-through (fail-open) — controlled per policy.
  • rate-limit.policies.ts — defines PolicyConfig interface + all named policies: GLOBAL_POLICY (60 req/min, 1800 req/hr, keyed user-or-IP, fail-open), 8 auth-burst policies (AUTH_SIGN_IN, AUTH_FORGOT_PASSWORD, AUTH_MAGIC_LINK, AUTH_SIGN_UP, AUTH_TWO_FACTOR, AUTH_VERIFY_EMAIL, AUTH_RESET_PASSWORD, AUTH_PASSKEY — all keyed by IP, failClosed: true, emitSecurityEvent: true, budgets hidden on sensitive paths), CSP_REPORT_POLICY (20/min + 200/hr, IP-keyed, fail-open, no budget headers).
  • BetterAuth built-in rateLimit disabled (rateLimit: { enabled: false } in auth.ts) — the Hono middleware is the single 429 path. One envelope, one store, one set of headers.
  • Front 429 toast with countdown (apps/app/src/shared/api/errors/rate-limit-toast.ts) — showRateLimitToast({ message, seconds }) drives a sonner toast that ticks down every second and auto-dismisses at zero. Wired from shared/api/errors/toast.ts: if apiErr.status === 429 and metadata.retryAfter is numeric, shows countdown; otherwise falls back to a plain error toast.

S2 — Shared stores

  • IRateLimiter port (apps/api/src/shared/ports/rate-limiter.port.ts) — consume(key, windows): Promise<Result<RateLimitDecision, RateLimitError>>. RateLimitDecision carries allowed, limit, remaining, resetSeconds, policyName, firstBlock.
  • RateLimiterFlexibleAdapter (apps/api/src/shared/services/rate-limiter-flexible.adapter.ts) — implements IRateLimiter via rate-limiter-flexible. Constructor-injected IInstrumentation (§8 outer span on consume). Per-window limiters are lazily constructed and cached. A thrown RateLimiterRes = blocked decision; any other throw = Result.fail(RATE_LIMITER_INTERNAL_ERROR) after instrumentation.capture(err).
  • Durable Postgres storeRateLimiterDrizzle backed by rate_limit table (migration 0007_medical_liz_osborn.sql): key TEXT PK, points INT, expire TIMESTAMPTZ. clearExpiredByTimeout default (true) keeps the table lean via an unref'd 5-min purge — no sweep route needed for this ephemeral infra table.
  • RATE_LIMIT_STORE envz.enum(["memory", "postgres"]).default("memory") in env.ts. storeFactoryFor(store, clientFactory?) returns memoryFactory, or calls makeDrizzleFactory(clientFactory()) for postgres (throws if the factory is absent). Default is memory (zero-config dev); set to postgres before horizontal scaling.

S3 — Strict CSP

  • Per-request nonce via Caddy templates (apps/app/Caddyfile) — the SPA is static-served by Caddy; Caddy's native {http.request.uuid} provides the per-request nonce with no app-server involvement. The handle block wraps try_files with templates { mime text/html } so Caddy processes the HTML template directives before serving.
  • Content-Security-Policy header in Caddyfile: default-src 'self'; script-src 'nonce-{http.request.uuid}' 'strict-dynamic' https: 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' https: wss:; frame-ancestors 'none'; object-src 'none' + report-uri + Reporting-Endpoints pointing at {$VITE_API_URL}/csp-report.
  • Vite html.cspNonce (apps/app/vite.config.ts:42) — { cspNonce: "{{placeholder \http.request.uuid`}}" }: Vite stamps nonce=attributes ontemplates` directive replaces the literal at request time with the actual UUID nonce.
  • POST /csp-report endpoint (apps/api/src/shared/internal-routes/csp-report.route.ts) — mounted before the global restrictive CORS so browsers can post unauthenticated cross-origin. Handles both application/csp-report (legacy) and application/reports+json (Reporting API v1). IP-rate-limited via CSP_REPORT_POLICY. Sets Cross-Origin-Resource-Policy: cross-origin to prevent Chrome ERR_BLOCKED_BY_RESPONSE on the report POST. Filters out reports whose document-uri / documentURL origin doesn't match APP_URL (third-party extension noise). Emits security.csp.violation event (EventTypes.SECURITY_CSP_VIOLATION) via outbox.

S4 — CSRF

  • requireCsrf(deps) middleware (apps/api/src/shared/middleware/csrf.middleware.ts) — Origin-allowlist strategy: safe methods (GET, HEAD, OPTIONS) and Bearer-authenticated requests pass through unconditionally; for all other methods, the Origin header must be present, non-null, and in deps.allowedOrigins. Violation throws AppErrorException({ code: "SECURITY_CSRF_FORBIDDEN" }) → 403. The rejection reason (missing_origin | origin_mismatch) is included in the emitted security.csrf.rejected event but intentionally absent from the client response (no security-decision leak).
  • Mounted on /me, /me/*, /uploads, /uploads/*, /settings/*, /admin/* in index.ts.
  • allowedOrigins reuses env.CORS_ORIGIN — the same list fed to cors() and BetterAuth trustedOrigins; single source of truth for "who is our front".

S4.1 — Rate-limiter store resilience

  • Dedicated pg pool for the rate-limit store (packages/drizzle/src/rate-limit-client.ts) — getRateLimitDbClient() lazy singleton: new Pool({ max: 3, connectionTimeoutMillis: 500, idleTimeoutMillis: 30_000 }) + drizzle(pool, { schema: rlSchema }). The package exports only getRateLimitDbClient() and the RateLimitDbClient type; the underlying Pool is never exposed. Mirrors the getDb() pattern in config.ts.
  • makeDrizzleFactory(client) higher-order — replaces the old drizzleFactory closure that captured the global db. Takes a RateLimitDbClient and returns a RateLimiterFactory. storeFactoryFor(store, clientFactory?) calls makeDrizzleFactory(clientFactory()) for postgres (throws "RateLimitDbClient factory is required for the postgres store" if absent); for memory, clientFactory is never called — no pool allocated in memory mode.
  • container.ts bindinggetRateLimitDbClient passed by reference (lazy): storeFactoryFor(env.RATE_LIMIT_STORE, getRateLimitDbClient). The dedicated pool is created only if RATE_LIMIT_STORE=postgres, on first HTTP resolution.
  • DoS amplification vector closed — under flood the dedicated pool (max: 3) saturates; pg throws "timeout" in ≤ 500 ms → adapter capture(err) + Result.fail → fail-closed policies → 503 RATE_LIMITER_UNAVAILABLE. The global app pool (max: 20) is never touched.
  • insuranceLimiter — skip (fail-closed-fast). A real pg outage means app-wide outage; an in-memory fallback during pg-down would be moot. Decision to revisit only if a Redis store lands (Redis is not in the stack today).
  • Caddy Reporting-Endpoints backtick syntax — confirmed valid Caddyfile raw-string syntax, not leaked to the browser. Pending: runtime curl -I verification in production.

Hardening pass

Post multi-agent SOTA-2026 review, several low-cost hardening items were folded in before shipping:

  • Fail-closed on auth policiesfailClosed: true on all 8 auth-burst policies. When IRateLimiter.consume returns Result.fail, the middleware throws RATE_LIMITER_UNAVAILABLE (503) instead of passing through. Rationale: a transient store outage must not silently disable brute-force protection (OWASP A10:2025 / CWE-636). GLOBAL_POLICY and CSP_REPORT_POLICY remain fail-open — a store outage should not block normal browsing or CSP telemetry.
  • CORS_ORIGIN fail-hard in productionenv.ts throws at boot if NODE_ENV === "production" and CORS_ORIGIN is unset. Without it the API falls back to localhost, which rejects the real front and collapses both the cors() and requireCsrf allowlists silently.
  • TRUSTED_PROXIES CIDR + private keyword (rate-limit.ip.ts) — resolveClientIp uses node:net BlockList to check trust. The private keyword expands to all RFC1918 + loopback + CGNAT ranges (mirrors Caddy's trusted_proxies private_ranges), allowing Railway/PaaS deploys to set TRUSTED_PROXIES=private without pinning a non-stable internal IP. Plain IPs and CIDR notation also accepted. Boot warns in production if TRUSTED_PROXIES is unset (collective lockout risk behind a load-balancer).
  • CSRF 403 leaks no reason — the reason field used internally for the emitted audit event is not forwarded to the client response. Only "CSRF check failed" is visible externally.

The ROADMAP originally specified a __Host-csrf cookie + X-CSRF-Token double-submit pattern. Dropped for two reasons:

  1. Cross-origin deploy makes double-submit physically impossible. App and API are on different eTLD+1 origins (distinct *.up.railway.app hosts). document.cookie is per-origin; __Host- forbids Domain=; so the front can never read a cookie set by the API origin to echo it back as a header.
  2. Origin-header validation is the 2026 SOTA. Next.js Server Actions, SvelteKit, and Remix all use it. The Origin header is unforgeable by the browser (forbidden header), stateless, and requires zero front-end code or dedicated CSRF endpoint.

Bearer-authed requests (Capacitor mobile) skip requireCsrf entirely: no ambient cookie means no CSRF surface, and a forged cross-origin request cannot set Authorization without a CORS preflight that the cors() allowlist blocks.

As-built deviation: CSP nonce in Caddy, not a Hono middleware

The ROADMAP assumed a csp.middleware.ts injecting the nonce server-side. That model requires the app server to intercept and modify HTML responses — impossible when the SPA is a pre-built static bundle served directly by Caddy. Caddy's templates directive with {http.request.uuid} is the correct per-request nonce mechanism for static SPAs: no app-server roundtrip, zero Bun involvement, cryptographically unique per request (UUID v4 from Caddy's internal counter).

/csp-report is public (browsers post unauthenticated — HMAC verification is impossible from a browser context); it is defended instead by rate-limit + Cross-Origin-Resource-Policy: cross-origin + document-uri origin filter.

As-built deviation: Trusted Types deferred

Trusted Types was in scope as a CSP directive but was deferred to its own story. In report-only mode on a non-migrated React app, every React DOM call produces a violation and floods audit_log with noise. Browser baseline is also partial: Firefox support is not stable, and Safari only reached partial support in 26.1 (2026). The nonce-based CSP ships first; Trusted Types lands once React's DOM abstraction is Trusted-Types-compatible in the project's baseline.

Decisions

  1. Single unified Hono rate-limit middleware; BetterAuth built-in disabled. One 429 error envelope (SECURITY_RATE_LIMITED), one §8-instrumented store, one set of IETF headers. BetterAuth's built-in has its own 429 shape and its own in-memory store — running both creates two codepaths for the same property. Disabling it is the right call once you own the layer.
  2. Fail-closed on auth, fail-open on global traffic. A store outage must not silently disable brute-force protection (OWASP A10:2025). Noted v-next: a circuit-breaker / degraded in-memory fallback would avoid a transient store glitch turning prolonged login into 503 for all users.
  3. env.CORS_ORIGIN as single source of truth for "who is our front". The same list feeds cors(), requireCsrf({ allowedOrigins }), and BetterAuth trustedOrigins. One place to update when the front domain changes; misalignment between cors and csrf would be an open CSRF hole.
  4. TRUSTED_PROXIES=private is the correct Railway value. The container is only reachable via the platform's edge proxy over the private network, so trusting private ranges is safe and avoids pinning a non-stable internal IP. Mirrors the Caddyfile's trusted_proxies static private_ranges. Single-IP pinning is fragile — Railway recycles IPs across deploys.
  5. Cookie sameSite: none in prod is required; requireCsrf is the replacement CSRF layer. The cross-origin (different eTLD+1) Railway-domain deploy means cookies must be none to survive credentialed fetch. SameSite no longer provides transport-layer CSRF protection in that topology; requireCsrf (Origin allowlist) is the explicit in-app replacement. Cloners who deploy under a single parent domain (api.x.com + app.x.com, same eTLD+1) should switch to sameSite: "lax" for a free transport-layer CSRF layer on top.

Deployment debt

  • RATE_LIMIT_STORE=memory is per-replica — all in-process state is lost on restart and not shared across instances. Switch to postgres before horizontal scaling; a second replica with memory store effectively halves the rate-limit budget.
  • TRUSTED_PROXIES must be set (private on Railway) before going to production. If unset, all requests appear to originate from the load-balancer IP — rate-limit keys collide and a small burst from one user can trigger a collective lockout. Boot warns but does not hard-fail (dev default of unset is fine).
  • Pool isolation — shipped S4.1. The Postgres rate-limit store runs on a dedicated pool (max: 3, connectionTimeoutMillis: 500 ms), separate from the app db pool (max: 20). Pool exhaustion under a traffic spike or DDoS flood can no longer spill over into application query capacity.

Still pending

Captcha hook (Turnstile / hCaptcha via ICaptchaService port), abuse-prevention signals (credential-stuffing counter, impossible-travel detection, free-trial abuse, geo deny-list).

How a cloner uses it

  1. Set TRUSTED_PROXIES=private (Railway) or the relevant CIDR for your platform proxy.
  2. Set CORS_ORIGIN to the front's public URL (required in production — hard boot error if absent).
  3. Set RATE_LIMIT_STORE=postgres before horizontal scaling; leave memory for single-replica deploys.
  4. The 8 auth-burst policies and GLOBAL policy are pre-wired in index.ts. Add a requireRateLimit(deps, policy) call for any new public endpoint that needs its own budget.
  5. requireCsrf is already mounted on the mutation-capable prefixes. New mutation prefixes → add a app.use("/new-prefix/*", csrf) line.
  6. CSP report URL is baked into Caddyfile via {$VITE_API_URL}. Violations appear in audit_log with event_type = 'security.csp.violation'.