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 viaapps/app/src/router.tsx; api:modules/<context>/{application,infrastructure,routes.ts,module.ts}+shared/, inwiredefineModule()per context). For the current canonical layout seeCLAUDE.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 generate→packages/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:
createAuthClientinapps/app/src/adapters/auth-client.ts - Plugins:
twoFactor,passkey,magicLink,bearer(mobile/Capacitor-ready).organizationshipped in Phase 2;stripedeferred. -
sessionMiddleware(adapters/middleware/auth.middleware.ts) populatesc.var.user/c.var.session; companionrequireAuthmiddleware throwsHTTPException(401)on protected handlers. - Pages
/sign-in,/sign-up,/forgot-password,/verify-email,/reset-password,/magic-link,/two-factorinfeatures/auth/. - Pathless layouts
routes/_protected.tsx(block when no session) androutes/_guest.tsx(block when already logged in) — singlebeforeLoadshared by all children, URLs unchanged. - Cookies:
httpOnly+sameSite=lax+securein 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 enablesAuthorization: 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 morecallbackURLmangling by Outlook & co. - Pino structured logging + centralised error handler —
hono-pinomiddleware (adapters/middleware/logger.middleware.ts), JSON in prod,pino-prettyin dev. SingleerrorHandler(adapters/middleware/error.middleware.ts) returns{ error: { code, message, requestId, stack? } }. - Session as TanStack Query, not React state —
sessionQueryOptions(adapters/queries/session.ts, staleTime 5 min aligned withcookieCache). Router context only exposesqueryClient; gates doawait context.queryClient.ensureQueryData(sessionQueryOptions)inbeforeLoad. NouseSession()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 callbroadcastAuthChange()after refetching the session query;app-providers.tsxlistens once and on receive doesrefetchQueries(['session']) + router.invalidate(). Tab A signs out → tab B (idle on/dashboard) instantly transitions to/sign-inwithout polling, hard reload, or navigation in B. - Strong password schema split —
_schemas/auth.schema.tsexposespasswordSchema(loose:min(1), used by sign-in to capture, the server validates) andstrongPasswordSchema(strict:min(12).max(128)+ lowercase/uppercase/digit, used by sign-up + reset). NIST-aligned: no required special character. - StrictMode-safe token consumption —
useRef(false)guard inverify-email.page.tsxandmagic-link.page.tsxprevents 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".
-
organizationplugin enabled in theauthconfig - Drizzle schemas generated:
organization,member,invitation(+teamif needed) - Auto-create a personal org on signup (
databaseHooks.user.create.after, slugpersonal-${orgId}— UUID v4, never user-visible) - Session enriched with
activeOrganizationId→ Hono middleware that pushes it intoc.var.orgId - Every business table has an
organizationIdFK 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/invitationsinfeatures/ - 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 generatesorg-${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. WrapscreateAccessControl(better-auth/plugins/access) withdefaultStatementsextended byorganization: ["update", "delete", "leave"]+billing: ["read", "manage"]. Exportsac,roles = { owner, admin, member },STATEMENTS, typesOrgRole/OrgPermissions, and a syncauthorizeRole(role, permissions, connector?)predicate. Theas unknown as AccessControlcast required by BetterAuth's generic plugin signature is hidden inside the package — call sites stay strict-typed. Built withtsup, ESM-only, peer-dep onbetter-auth. - Three layers, one contract: server
requireOrgPermission(permissions)middleware (apps/api/src/adapters/middleware/org.middleware.ts) — wraps every business route guarded by capability, throwsHTTPException(403)on deny. Front route gateensureOrgPermission(permissions)(apps/app/src/adapters/route-helpers/ensure-org-permission.ts) —beforeLoadhelper that doesensureQueryData(currentMembershipQueryOptions)+authorizeRole+redirect. UI gate<Can requires={...}>(apps/app/src/adapters/components/can.tsx) backed byuseAuthorization()(apps/app/src/adapters/hooks/use-authorization.ts) — declarative subtree gating with optionalconnector="OR"andfallbackslot. 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 calluseAuthorizationthemselves rather than receiving booleancanEditprops (rule 14 promotion: the row owns its own permission check, the page passes only data). - Flat
_org-scoperoute layout — one pathless gate (_org-scope.tsx) ensures active-org-required; capabilities live per-route viabeforeLoad: ensureOrgPermission({...}). Avoids stacking_org-admin/_org-owner/_can-manage-billingpathless tiers as new resources land. - Navigation declares
requires: OrgPermissions+requiresOrg: boolean—SETTINGS_TABS(adapters/components/contextual-tabs.tsx) andNAVIGATION_ROUTES(adapters/components/command-palette.tsx) filter viauseAuthorization().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 viaimport.meta.env.DEV). Visualises the active session's role and the full capability matrix derived fromSTATEMENTS×roles.PERSONAL_BLOCKEDmap 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 inauth.ts. Returns existing membership orgId or creates a new Personal org + member row in a transaction. Runs indatabaseHooks.user.create.after(signup — covers new users) ANDdatabaseHooks.session.create.before(sign-in — back-fills legacy users that pre-date the signup hook withactiveOrganizationId: 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:updateMemberRolethenleave. UI isTransferLeaveDialog(features/settings/_components/transfer-leave-dialog.tsx). Post-leave both flows callswitchToFirstRemainingOrg(queryClient)(adapters/utils/switch-to-first-org.ts) +broadcastAuthChange(). -
NO_ACTIVE_ORGANIZATIONtranslated tonull—currentMembershipQueryOptionsandactiveOrgQueryOptionscatch BetterAuth's error code and returnnull. "No active org" is a valid transient state in our model (between orgs, pre-self-heal); letting the error bubble crashes any consumer that callsensureQueryData. -
broadcastAuthChange()extended to org events —setActive,create-org,delete-org,leave-org,transfer-and-leave,accept-invitation,remove-memberall call the broadcast in theironSuccess(call site, not factory). Listener already refetches["session", "active-org", "current-membership", "orgs"]. Cross-tab consistency under the 5-mincookieCache.maxAgewindow.
May 2026 cleanup — dropped the teams sub-plugin
- Removed the BetterAuth
teamssub-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 singleOrganizationtab 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) + adapterResendEmailService(apps/api/src/adapters/services/email.service.ts) wired through inwire DI in contract mode (key = interface nameIEmailService). - Type-safe variables per template —
EmailTemplatesmaps each template name to its required variables. Adding a new template = updating the type + adding aRESEND_TPL_*env var. Renaming a variable in the dashboard without updating code = TS red, no silent break. -
Result<void, EmailError>—sendTemplatenever throws, returns a discriminatedEmailError(EMAIL_TRANSPORT_NOT_CONFIGURED|EMAIL_PROVIDER_FAILURE). Use cases keep theResultuntil the controller boundary; integration adapters (auth.ts) translate tothrowonly at the BetterAuth-hook frontier. - Retry with exponential backoff — 3 attempts (1s/2s/4s), retry only on
429and5xx+ network errors (status === 0). 4xx non-rate-limit fail fast (validation = retry futile). DistinctSTATUS_HINTSlog per401/403/409/422so prod debug isn't blind. -
Idempotency-Key—${event-type}/${sha256(token)[:32]}(Resend pattern, 24h window). Hash viaBun.CryptoHasher. Safe under retries — same payload returns the original response, different payload returns 409 with explicit log hint. -
SendTemplateOptions.from?— per-tenantfromoverride slot for the futureorganizationplugin (per-org sending domain). Defaults toenv.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"andRESEND_API_KEYor 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.sendMagicLinkconsumedi.IEmailService.sendTemplatevia adispatchEmail()helper that unwrapsResult(EMAIL_PROVIDER_FAILURE→ throw → centralised error handler;EMAIL_TRANSPORT_NOT_CONFIGURED→logger.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.suppressedwebhook 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-iddedupe) ships when the first consumer lands. - DNS documented in
README.md(SPF + DKIM CNAMEs from Resend dashboard + DMARC TXT progressionp=none→p=quarantineonce stable, targetp=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.yamlunder profilestorage(host port pinned to8333, bucketclean-stackauto-created byseaweedfs-initviaweed 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) —IStorageServiceexposespresignUpload/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) —S3Clientwithregion: "auto"(R2's only accepted value),forcePathStyle(kept on for SeaweedFS/MinIO compat — harmless on R2). Boot-time fail-hard in production ifS3_ENDPOINTis localhost or creds are the dev defaults (dev/dev). Presigned PUT signscontent-type+content-lengthheaders (signableHeaders) so the client can't drop them. - Use-cases for orchestration only —
create-upload-url,create-download-url,confirm-upload(apps/api/src/application/use-cases/). Each getsIStorageServicevia 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-uploadperformsHeadObject, deletes on size/content-type mismatch, returnsSTORAGE_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") andexpiresInSeconds(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.comor…eu.r2.cloudflarestorage.comfor 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 to8333; in-network it'sseaweedfs:8333). - Routes (typed RPC, chained into the
routesexport):POST /uploads/presign,POST /uploads/confirm,POST /uploads/download. AllrequireAuth. 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 →tscrouge).AppDeps = typeof diderived after.build(). Promote a section tomodules/<context>.module.tsonly when a bounded context grows large enough to bloatcontainer.ts. -
createUploadMutationOptions(apps/app/src/adapters/mutations/create-upload.ts) — TanStack QuerymutationOptionsfactory chainingpresign→PUTdirect to R2 (with explicitContent-Length) →confirm. Returns{ key, publicUrl, size, contentType }only after server-verified integrity. Consumed viauseMutation({ ...createUploadMutationOptions, onSuccess, onError }). Accepts optionalscope+expiresInSeconds. - First Hono RPC consumer —
apps/app/src/adapters/api-client.tsuseshcWithTypefromapi/client(subpath export, pre-typedApiClient), with customfetchinterceptor (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; useaws s3 --endpoint-url=http://localhost:8333or 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 ResendRESEND_TPL_DATA_EXPORT_*). Rate-limited 1/24h per user vialastExportRequestedAt. The presigned URL is never put in an event payload — events carry onlystorageKey(security). - Pre-flight ownership gate
GET /me/delete/preflight— returns the sole-owner non-personal orgs that block deletion. UI at/settings/accountrenders the blocking list with per-rowTransfer ownership/Leave orgCTAs; theDelete accountbutton 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 (BetterAuthtwoFactor) + server-side preflight re-check (409ACCOUNT_DELETION_BLOCKEDif a sole-owner org appeared between read and submit) + 7-day soft-delete grace. CronPOST /internal/rgpd/process-pending-deletions(HMAC-signed) sweeps expired requests, wipes personal data (email, name, sessions, passkeys, MFA factors, R2 avatars) and anonymizesmemberrows (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 RGPD —
user.deletedAt+user.pendingDeletionUntilare the only soft-delete columns in the codebase (rule 14 — no creep elsewhere; everything else is hard-delete). - Public
/legal/data-rightspage — 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});AuditEventSubscriberwrites the audit row withcomplianceretention. Pino logging retained for ops debugging (different concern).
Decisions (non-obvious, locked-in by code):
- 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.
- Anonymize
member, don't cascade-delete it — deleting thememberrow would corrupt org audit trails ("who invited whom"). SettinguserId → 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. - Sole-owner preflight is server-authoritative, re-checked at submit — the UI gate is UX; the
POST /me/deletehandler 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. - 2FA gate on a destructive irreversible action — account deletion reuses the BetterAuth
twoFactorchallenge, 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. -
/settingshub — single layout (features/settings/settings.layout.tsx) rendersOutletwith the contextual tabs as page nav. Six sub-pages: General, Members, Invitations, Billing (placeholder until Stripe ships), Profile, Security./settingsindex redirects to/settings/general. - One
<main>per page — theOutletcontent 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 viacurrentColor+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 (
hashKeyinstead of raw filename for PII). - Zod payload schemas — typed discriminated union via
PayloadByEventType. -
RETENTION_MAP— per-eventoperational(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 concurrentPromise.alltest. -
IUnitOfWork.run(cb)standardized — wraps Drizzledb.transaction(...)+ opens ALS context, drains events pre-COMMIT via injectedflushHandler. Nestedrun()interdit — the impl throwsError("nested IUnitOfWork.run() is not supported")because Drizzle nested transactions are independent (not savepoints) → events would orphan. Detected viaEventCollector.hasContext(). -
onEvent(type, factory)+EventHandler<T>+EVENT_HANDLER_SYMBOL(cross-realm viaSymbol.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()— convertsIDomainEventto outbox row shape with CloudEvents 1.0 metadata envelope (specversion,source,subject,traceparent,datacontenttype).
@packages/drizzle schemas + run flush
- 3 new schemas —
outbox_event(UUID v7 PK, partial indexWHERE dispatched_at IS NULL, CloudEvents metadata jsonb),audit_log(5 indexes for filter combos,prev_hash/hashcolumns posed for tamper-evidence),webhook_endpoint+webhook_delivery(FK CASCADE org, FK RESTRICT outbox_event, idempotency_key UNIQUE, partial pending index). -
TransactionService.run()— implementsIUnitOfWork.runwithflushHandlerinjected at construction time (container.ts wires it tooutbox.enqueue). Setsidle_in_transaction_session_timeout = '30s'viaSET LOCALto protect against zombie workers. -
trackEventsOnSuccess(result, aggregate)helper — repos call this in theirsave/createimpl 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. Dedicatedpg.ClientforLISTEN outbox_event+ reconnect with exponential backoff + 30s poll fallback.SELECT ... FOR UPDATE SKIP LOCKED LIMIT 50drain (multi-instance ready). Built-in subscribers (audit + webhook fanout) run inside the dispatch TX (atomic withmarkDispatched); useronEvent(...)handlers run post-commit in a separate loop (best-effort, isolated).pg_notifytrigger ensured idempotently at boot viaCREATE OR REPLACE TRIGGER(Postgres 14+ atomic). Container introspection auto-wires user handlers viaObject.entries(di)+EVENT_HANDLER_SYMBOLfilter. - Built-in subscribers —
AuditEventSubscriberwrites audit row idempotently via deterministic IDaudit-${event.id}(ON CONFLICT DO NOTHING).WebhookFanoutSubscriberenqueueswebhook_deliveryrows witheventTypes ? <type>ARRAY match ANDorganizationId = event.organizationId. Multi-tenant safety: events withorganizationId = 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/ciphersv2 XChaCha20-Poly1305 + HKDF-SHA256 per-org sub-key fromWEBHOOK_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: .BASE = 1000ms,CAP = 12h,MAX_ATTEMPTS = 5. Retry paliers ~1m / 5m / 30m / 2h / 12h, dead-letter after 5 attempts. - Ports
IOutboxRepository+IAuditPortinshared/ports/. Cross-cutting (consumed by 2+ contexts). Drizzle impls inshared/services/. -
emitEvent(outbox, ...)shared helper (shared/event-emitter.ts) — used by BetterAuth bridge, RGPD service, UploadService instead of duplicated privateemitmethods. - Request correlation via
AsyncLocalStorage(shared/request-context.ts, Jun 2026) — arequestIdmiddleware wraps each request in an ALS carrying itsX-Request-Id;DrizzleOutboxRepository.enqueuereads it at the single write choke-point and stampsoutbox_event.metadata.requestId, which theAuditEventSubscribercopies intoaudit_log.request_id. Chosen over threadingc.get("requestId")through ~30 call sites because BetterAuth hooks (which emit the majority of events) have no Honocin 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
emitEvent→AuditEventSubscriber— code without an aggregate (RGPD, uploads, BetterAuth bridge) emits its event through theemitEventhelper; the subscriber writes theaudit_logrow from the outbox. The outbox event is the audit primitive — there is no separaterecordAudithelper.
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 indatabaseHooks) — filterif (ctx.context.returned instanceof APIError) returnto 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 callbacks —
emailAndPassword.{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 bykey.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(gatedrequireOrgPermission({ auditLog: ["read"] })),POST /internal/audit-log-purge(cron sweep operational rows). -
modules/webhooks/— full CRUD/settings/webhooks(gatedrequireOrgPermission({ webhooks: ["read"|"write"] })),WebhookDeliveryWorkerwith claim window pattern (claim batch with , fetch HTTP outside TX, update status in fresh TX) — prevents lock starvation under sustained load. HMAC signing formatt=<unix>,v1=<hex-sha256>(Stripe-style), headerx-webhook-signature. Idempotency-key<eventId>:<endpointId>(UNIQUE). Replay endpoint creates fresh delivery row.
Lifecycle
- Boot —
OutboxDispatcher.start(di)+WebhookDeliveryWorker.start()inapps/api/src/index.ts.EventCollector.setOutOfContextLoggerwired to pino warn (DX: events emitted outsideuow.run()log a warning instead of disappearing silently). - SIGTERM/SIGINT —
Promise.all([stopWithTimeout(webhookWorker), stopWithTimeout(outboxDispatcher)])parallel shutdown (each worker has 25s timeout, fits in K8s 30sterminationGracePeriodSeconds).
Permissions
-
@packages/access-controlextended —auditLog: ["read"]andwebhooks: ["read","write"]added toSTATEMENTS. Owner + admin get both, member gets neither.
Tests + smoke runtime
- Unit tests —
event-collector.test.ts(ALS isolation between concurrent contexts),aggregate.test.ts(pullDomainEventsatomic),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/email→outbox_eventrow dispatched in <1s →audit_logrow written withaudit-${eventId}deterministic ID + retention compliance + extractActor heuristic OK. Endpoint registration →org.updatedtrigger →webhook_deliveryrow created + delivery attempt fail (URL fake) + retry attempts=2 (decorrelated jitter in action). Multi-tenant: events withorganizationId = nullskip fanout (verified).
Decisions clés (non-obvious, locked-in by code)
databaseHooksfor core models,hooks.afterfor plugin events — confirmed by reading BetterAuth v1.6.9 source (Context7). Plugin tables (twoFactor,passkey) not exposed indatabaseHooks.hooks.afterrequiresAPIErrorinstance check (not"error" in returned— that pattern misses APIError instances thrown by handlers).- 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). - No nested
IUnitOfWork.run— Drizzle nesteddb.transaction()opens independent TXs (not savepoints). Events from innerrunwould persist in outbox even if outer rolls back (orphan). Hard guard viaEventCollector.hasContext()throw. organizationId = nullskips 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).- 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 viaHKDF-SHA256(masterKey, salt: undefined, info: "webhook-secret:${orgId}"). - Claim window pattern in delivery worker — fetch HTTP outside TX (otherwise 50 deliveries × 30s timeout = 25min TX, kills connection pool). Claim window = . Idempotency-key on receiver side prevents double-POST if worker crashes mid-fetch.
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.onPasswordReset+/change-passwordboth emitUSER_PASSWORD_CHANGED— different flows (reset via email vs. logged-in change), single event type. Receiver dedupes if needed.- Tamper-evidence deferred —
prev_hash/hashcolumns posed inaudit_log, calc gated byAUDIT_TAMPER_EVIDENCEenv flag (off). Implementation choice (Merkle batch vs. row-lock hash chain) parked until SOC2 audit demands. - 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 (
stopWithTimeoutséquentiel →Promise.all) + 1 MEDIUM (breakon stopping in post-commit loop) fixed. Smoke runtime then validated end-to-end. - 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_JOINEDsilently missing onacceptInvitationbecause BetterAuth routes it throughorganizationHooks.afterAcceptInvitation— a separate lifecycle fromafterAddMember(which only fires for direct adds, not invites); (b)UPLOAD_DELETEDdeclared in the catalog but never emitted (orphan event). Both fixed: dual-hook wiring +DELETE /uploadsroute with ownership guard. Then the same QA pass exposed severalsql\...`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, servernow(), atomic${col} + 1` per Drizzle docs). 8 conversions, 4 raw fragments justified, all type-safe column refs preserved. emitEventtx-aware + drop swallowing catch (post-merge hardening, May 2026). Original service-levelemitEvent(used by code outside an aggregate: rgpd, uploads, BetterAuth bridge) opened its own autonomous TX for the outbox INSERT and wrapped the call incatch + 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: optionaltx?: Transactionarg propagated tooutbox.enqueue, catch removed. Rgpd writes that ship state-change events (requestAccountDeletion,cancelAccountDeletion,executeAccountWipemigratedstartTransaction → run,requestDataExportfor the_COMPLETEDevent) now wrap inuow.runand pass the TX. Upload service andauth.tsBetterAuth bridge stay autonomous (no local DB write, hooks don't expose aTransaction) — documented limitation.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).- 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 patternDELETE WHERE id IN (SELECT … LIMIT 5000 FOR UPDATE SKIP LOCKED)+SET LOCAL statement_timeout/lock_timeout/idle_in_transaction_session_timeoutin a Drizzle transaction. Cron order matters (FKON DELETE RESTRICT): webhook → audit → outbox. Legacy/internal/audit-log-purgeretired —sweep-audit-logis 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 parallelbun testfiles, so the cross-bucket mock was made the superset (anti-pattern documented inapps/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. NoResult<>(telemetry is fire-and-forget, never blocking). Sentry SDK init via side-effectimport "./shared/services/sentry-init"as the first line ofindex.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). -
beforeSendscrub whitelist — dropsemail,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.ErrorBoundarywraps the router with shadcnAppErrorFallback, React 19createRootreceivesonUncaughtError/onCaughtError/onRecoverableErrorhandlers fromSentry.reactErrorHandler().@sentry/vite-plugingated onSENTRY_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/eventsexpose the complete superset of exports in every file (bun:testmock.moduleleaks globally → partial mocks surface asSyntaxError: Export named 'X' not foundin unrelated parallel test files). Anti-pattern documented inapps/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>0all 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 callsthis.instrumentation.startSpan(...)or has a documented reason not to (multi-query exception likeexecuteWipe).
Decisions (Phase 0.4)
- Single merged port (vs Lazar's two separate
IInstrumentationService+ICrashReporterService). Justification: in this codebase both surfaces consume the sameSentryglobal, splitting forces double-wiring at every call site for zero portability gain. - Sentry-only, OTel + Prometheus deferred to D.1. Bun OTel auto-instrumentation requires manual
Bun.serve()wiring (not stable until Bun 1.4);prom-clientwithout Grafana scrape = code mort. Same anti-NIH principle as Phase 0.3: ship infra cross-cutting only when a consumer exists. SentryInstrumentationconstructor-injected, NOT module-level singleton. Mirrors theIEmailService/IStorageServicepattern; respects the "no service-locator" rule. Sentry SDK init remains a side-effect import (the SDK detains global state — wrapping that init would just recopySentry.*and lose typings).createErrorHandler(instrumentation)factory pattern forerror.middleware. Middlewares importingdidirectly fromcontainer.tswould risk a runtime cycle if any module imported back intoshared/middleware/. Factory takes the dep as a parameter, called once inindex.tsafterdi.build()— cycle-immune.- NoOp + Sentry surfaces stay symmetric.
apps/app/src/shared/observability/{noop,sentry}.tsexport identical signatures (captureError,addBreadcrumb,ErrorBoundary,reactErrorHandler).noop.tslisted asknipentry 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 inlinepsql 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 (
pgBackRestflagged unmaintained, Barman as alternative). - Weekly portable
pg_dumpexport — copy-paste recipes for GitHub Actions, Railway Cron, and K8s CronJob. Streamspg_dump | gzip | aws s3 cp -(no OOM, multipart auto via AWS CLI). Targetsbackups/postgres/<ISO>.sql.gzin 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-alpineon port5436, downloads latest dump, restores viagunzip | psql, runs inlinepsql count(*)smoke per table, fails loud. - Lifecycle + versioning snippets —
aws s3api put-bucket-lifecycle-configuration(expire weekly exports 30 d, transition monthly snapshotsSTANDARD → GLACIER1 y),aws s3api put-bucket-versioning Status=Enabled, MFA-delete note. Caveats: Cloudflare R2 has no GLACIER class (useSTANDARD_IA), SeaweedFS lifecycle/versioning partial depending on version. - README + CRON.md updates — README
## Deploymentlinks toDISASTER-RECOVERY.mdalongsideHEALTH-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 addPOST /internal/backup-postgres" question.
Decisions (Phase 0.3)
- 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).
backups/postgres/prefix in the existing S3 bucket, not a dedicatedR2_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.- Read-only Postgres role for the export job, not the API role. CI-secret-leak best practice:
pg_dumponly needsGRANT CONNECT, USAGE, SELECT. Documented in the doc itself rather than as a separate runbook — the YAML recipe links itssecrets.DATABASE_URLto "a read-only role" inline. - No
pnpm db:smokescript committed. The verification step (select count(*) per table) is a 15-line inline snippet in the doc; the cloneur drops it inapps/api/scripts/db-smoke.tsif 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 exportedschemanamespace, which doesn't drift. - 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.ymlwould 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 anXxxHealthProbe implements OnInitthat self-registers atdi.preload()—trashthe module removes its probe in one shot, no orphan. -
/livezliveness, no dependency hit (a DB outage must not restart pods → thundering herd)./readyzaggregates checks (dbSELECT 1critical, storageHeadBucketnon-critical), tri-statepass/warn/fail→ 200 unless a critical check fails (503)./startupzshields 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 shutdown —
SIGTERMflipslifecycleState→/readyzreturns 503 within one probe interval (LB drains), waitsSHUTDOWN_GRACE_PERIOD_MS(15s default), then stops the workers. Without it: intermittent 502s on every deploy. - Prod-validation hardening (Jun 2026) —
/livez+/startupzpayloads 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/rgpdend-to-end in a throwaway worktree: 46 files touched, −2980 LOC net, 3DROP COLUMNmigration. All 6 gates green (type-check,ci:check,check:unused,check:duplication,build,testbaseline-preserving). - 4 surprises captured — a 3rd RGPD column missed by the initial cartography, a transitively-dead
throwApiErrorhelper, 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 :watchPatternsscopés pour éviter les rebuilds inutiles (api ne se redéploie pas quand seulapps/app/change). -
apps/api/src/cron/sweep.ts— chaîneur des 3 sweeps dans l'ordre FK (webhook → audit → outbox) viasignedInternalFetch(object input — la signature outdated dansdocs/EVENTS.mdqui utilisait 3 args positionnels est corrigée du même coup). LitAPI_URLetINTERNAL_SIGNING_KEYde l'env,process.exit(1)au premier non-2xx. Bundle entrypoint ajouté àbun buildaux côtés deindex.ts+migrate.ts→dist/cron/sweep.js. Le service cron Railway réutilise l'image api (single Dockerfile, single source de vérité) — pas de nouveaucron.Dockerfile. - Fix in-scope
apps/api/prod.Dockerfile+apps/app/prod.Dockerfile— 2 bugs latents qui auraient mordu en prod : (a)HEALTHCHECKapi pointait/health(n'existe pas — le boilerplate utilise IETF/livezdepuis Phase 0.2). Railway utilise son propre healthcheck viahealthcheckPathdu.tomldonc 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 buildretiré des deux Dockerfile — viole rule §4 ("internal packages ship source"). Seulddd-kita un scriptbuild(pour publication npm future), inutile en monorepo : Bun's bundler résout les exportssrc/index.tsdirectement. La ligne faisait du compute wasted et risquait de produire undist/qui shadow le src. -
apps/api/.env.exampleaudité — markers# REQUIRED IN PRODsur 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_URLcôté cron). Source de vérité unique pour la sync vers Railway Shared Variables — la table de mapping dansDEPLOY-RAILWAY.mdréfère ce fichier. - Pas de
.github/workflows/deploy.yml— Railway watchmainnativement via l'intégration GitHub +watchPatternspar 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_TIMEinjecté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érationopenssl rand ...) + per-service Variables (api/app/cron avec Reference Variables) + R2 setup par défaut (jurisdiction EU, lifecycletmp/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.mdcron recipe nettoyé — section "GitHub Actions example" supprimée (user veut Railway Cron exclusif sur le boilerplate déployé). Remplacée par pointer versapps/api/src/cron/sweep.tscomme 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.mdligne 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)
- 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.ymljamais commit ;docs/CRON.mdreste 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. - 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.
- 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
startCommandoverride +cronSchedulesuffisent — Railway Cron est un type de service ordinaire avec ces 2 deltas. Alternative envisagée :apps/cron/Dockerfileséparé — rejetée car duplique pnpm install + bun build pour zéro gain (et drift entre les deux images au prochain bump de version). - Custom config file path en dashboard, pas multiple
railway.tomlà la racine. Railway 2026 ne supporte qu'un seulrailway.tomlpar service-root. Pour partager le build context (= repo root, requis pourpackages/), 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. - Pas de
preDeployCommandpour la migration (Railway-spécifique). Garde le patternCMD migrate && startportable (marche sur Fly, K8s, Cloud Run sans modif).preDeployCommandest 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. - Pas de
numReplicas = 1explicite. Première itération avaitnumReplicas = 1dans[deploy]— invalid : ce champ n'existe que sous[deploy.multiRegionConfig.<region>](Railway docs 2026). Retiré ; default Railway = 1 réplique implicitement. - Pas de GH Actions deploy workflow. Railway watch
mainnativement +watchPatternspar service = zero glue nécessaire. Première itération avait un.github/workflows/deploy.ymlmatrix 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.updatedevent (USER_PROFILE_UPDATED, retentioncompliance) — emitted inauth.tsviahooks.afteron path/update-user. Payload:{ userId, changes }(field-level diff). -
user.email.change_requestedevent (USER_EMAIL_CHANGE_REQUESTED, retentioncompliance) — emitted in theuser.changeEmail.sendChangeEmailConfirmationcallback. Payload:{ userId, newEmail }. -
IPasswordBreachServiceport (shared/ports/password-breach.port.ts) +HibpPasswordBreachServiceimpl (shared/services/) — HIBP k-anonymity (api.pwnedpasswords.com/range/<sha1[:5]>,Add-Paddingheader, timeoutHIBP_TIMEOUT_MSdefault 3000 ms). Instrumented (spanhttp.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.findPasswordViolationbans email-local-part, display name, and app name (clean-stack/cleanstack).validatePasswordadds the length-guard (skips HIBP belowMIN_PASSWORD_LENGTH) then the breach check, returning the first violation ornull. The inline ~20 common-password list was dropped — HIBP already covers every common password, so it was dead weight. -
auth.tschanges —emailAndPassword.minPasswordLength: MIN_PASSWORD_LENGTH;hooks.beforecallsvalidatePassword(...)on/sign-up/email,/reset-password,/change-password(throwsAPIError422 on violation);user.changeEmail.enabled: true+ confirmation sent to the current address (not the new one) +change_emailemail template;databaseHooks.user.update.afterclearspendingEmailand emitsuser.profile.updated { changes: { email } }once the new address becomes effective. -
pendingEmailfield — nullablepending_emailcolumn (packages/drizzle/src/schema/auth.ts, migration0004) exposed as a BetterAuth additionalField (returned: true, input: false). Set insendChangeEmailConfirmation, cleared on effective change — drives the front "pending change" badge. - Container + env —
IPasswordBreachServicebinding incontainer.ts;HIBP_TIMEOUT_MSinenv.ts. - Tests —
hibp-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 infeatures/account/account.page.tsx. Editsname+email(confirmation to current address; a<Badge>"Pending change to X" renders whileuser.pendingEmailis set) + avatar upload via the existingcreateUploadMutationOptions(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. Seedocs/STORAGE.mdfor the key layout. -
ChangePasswordCard— standalone card belowProfileCard, inline with existing Passkeys/2FA/Sessions/DataExport cards. -
strongPasswordSchema(shared/auth/auth.schema.ts) — updated tomin(15), all complexity regexes removed (NISTSHALL NOTimpose 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 optionaldescriptionslot. 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 viaform.setError— routed tocurrentPasswordvsnewPasswordby the message — instead of a transient toast.
Decisions
- 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.
- 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. - Validation via
hooks.before, notpassword.hashoverride —password.hashintercepts only hashing;hooks.beforeintercepts at the route level before any BetterAuth processing. This cleanly separates validation (policy) from hashing (crypto), and avoids reproducing BetterAuth's internal scrypt call. user.changeEmailconfirmation 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. Thechange_emailtemplate is new.- No
/settings/profilepage — rectification fields live in/settings/account(the existing page). A dedicated/settings/profiletab is reserved for Phase A.5 (Privacy dashboard). Splitting now would fragment UX without a composing container. - 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 inpassword-policy.tsand the hook is a one-line caller. The inline common-password list was removed as redundant with HIBP. Schema change shipped as migration0004viadb:generate && db:migrate(neverdb:pushfor a committed change — push bypasses__drizzle_migrationsand 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:
- Request —
authClient.changeEmail({ newEmail, callbackURL })→POST /change-email(fresh-session gated). OursendChangeEmailConfirmationhook setspendingEmail = newEmail, emitsuser.email.change_requested, and mails the current address a confirmation link. - Confirm (current address) — the link hits
GET /api/auth/verify-email?token=…(achange-email-confirmationJWT). BetterAuth mints a second token and mails the new address a verification link. Email still unchanged; redirects tocallbackURL. - Verify (new address) — the second link (
change-email-verificationJWT) is what flipsuser.emailto the new value (emailVerified: true). This firesdatabaseHooks.user.update.after, where we clearpendingEmailand emituser.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_ENVoverride trap. A Railway service varNODE_ENV=developmentoverrode the DockerfileENV NODE_ENV=production(service vars beat DockerfileENVat runtime). In dev mode the logger loadspino-pretty— a devDependency absent from the--prodinstall → instant boot crash (unable to determine transport target for "pino-pretty"). Fix: setNODE_ENV=productionexplicitly, or leave it unset (the Dockerfile wins). Neverdevelopmentin prod. -
WEBHOOK_MASTER_KEYunset →env.tsprod guard threw at boot. Generated + set. -
@packages/access-controldeclaredbetter-authaspeer/devDependency, not adependency. A workspace package that imports a lib in its runtime source must declare it underdependencies, elsepnpm install --prodskips 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 todependencies. - Email + storage threw at boot.
di.preload()is eager, soResendEmailServiceandS3StorageServiceare constructed at startup and fail-hard'd when unconfigured. Changed to warn-and-degrade: email logs-not-delivers, storage swaps to aNoOpStorageService(returnsSTORAGE_PROVIDER_FAILURE;/readyzreportsstorage:s3as 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. -
appservice Start Command override. A leftover Railway Start Commandpnpm --filter app startreplaced the Dockerfile CaddyCMDin thecaddy:2.11-alpinerunner (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 Dockerfilecaddy runCMD applies. - Cross-site cookies.
appandapisit on different*.up.railway.apphosts = different sites (up.railway.appis a public suffix). BetterAuth session cookie set tosameSite: isProd ? "none" : "lax"so it survives the cross-site credentialedfetch. Custom domain under one shared parent (api.x.com+app.x.com) →laxworks and is preferable; documented inDEPLOY-RAILWAY.md§8. - Docs hardened for cloners —
docs/DEPLOY-RAILWAY.mdgained §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):
railway updeploys local working-tree code; a variable change / Redeploy / git push rebuilds from the connected branch. During the fix looprailway upwas the only way to test uncommitted code; a mid-fix variable change rebuilt frommain(no fixes yet) and re-crashed — confirming the model. Once merged, GitHub-integrated deploys took over and went green.RAILWAY_GIT_COMMIT_SHAonly populates on branch deploys, notrailway up.GIT_SHA/BUILD_TIMEshowunknownunderrailway up; set them to the Railway reference vars (${{RAILWAY_GIT_COMMIT_SHA}}) so build-info tracks the deployed commit.- 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,exportspoints atsrc/). ExportsPOLICY_TYPES(["privacy","terms"]),PolicyType,POLICY_VERSIONS(Record<PolicyType,string>, both"2026-01-15"),POLICY_CHANGELOG,PolicyChangelogEntry. Imported byapps/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). Migration0005_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_logvia theuser.policy.acceptedevent (complianceretention).
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-logmodule 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 sameversion ===, so it ships as infra too. The boilerplate ships zero aggregates;@packages/ddd-kit/Aggregateis a published-lib surface that waits for the cloner's real product domain. -
IPolicyAcceptanceStoreport (module-private) +DrizzlePolicyAcceptanceStore— fully instrumented per rule §8 (outer span wrapping the method, inner span onquery.execute(),catch + instrumentation.capture). -
PolicyAcceptanceService—accept(userId, types, ipAddress?)writes N rows + emits N events atomically in oneuow.runTX.getStatus(userId)returnsRecord<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 inindex.tsrouteschain. -
requireCurrentPoliciesmiddleware (apps/api/src/shared/middleware/policy.middleware.ts) — throwsHTTPException(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_shellbeforeLoadredirect) is the primary enforcement today.
Event user.policy.accepted — event catalogue grows from 34 to 35
- Self-actor payload
{ userId, policyType, policyVersion, ipAddress? }, retentioncompliance.userIdresolves as the actor viaAuditEventSubscriber.extractActor(self-actor: the subject accepted for themselves). Emitted fromPolicyAcceptanceService.accept— fired from two sites: (1) the BetterAuth/verify-emailafter-hook inauth.ts(sign-up path), and (2) thePOST /me/policies/acceptroute (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:
- No session at sign-up. With
requireEmailVerification: true,/sign-up/emailhas no session yet — reading a reliableuserIdfrom the response is unsafe because BetterAuth returns a synthetic user on duplicate-email attempts (anti-enumeration). TheuserIdin the response is not guaranteed to be the just-created user. /verify-emailis the natural idempotent boundary. This route fires exactly when the user proves ownership of their email address. The sessionuserIdis reliable. UsinggetStaleTypes(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 form —
signUpSchemagained a requiredacceptedPolicies: 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, sharedPolicyDocViewcomponent,policies.config.tsxregistry +getChangesSincehelper for the diff view. - Acceptance gate
/legal/accept— under_protected, outside_shellto 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). -
_shellbeforeLoad— callspoliciesQueryOptions(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
@packages/policiesas 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.- 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. - 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 call —isActive = withdrawnAt == null && expiresAt > now && policyVersion == currentis a WHERE clause, scope isincludes(), validity/cooldown are date math; it ships as infra too. The boilerplate ships zero aggregates —@packages/ddd-kit/Aggregateearns its keep only once the cloner adds real product domain. /verify-emailhook, 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.requireCurrentPoliciescomposable, 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 wrappingIRateLimiter.consume. On allowed: sets IETFRateLimit-Policy+RateLimitresponse headers (budget advertising). On blocked: setsRetry-After(floored to 1), throwsAppErrorException({ code: "SECURITY_RATE_LIMITED" })→ central error handler → 429. On first block: emitssecurity.rate_limit.exceededevent ifpolicy.emitSecurityEventand outbox provided. On store error: either 503RATE_LIMITER_UNAVAILABLE(fail-closed) or warn + pass-through (fail-open) — controlled per policy. -
rate-limit.policies.ts— definesPolicyConfiginterface + 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
rateLimitdisabled (rateLimit: { enabled: false }inauth.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 fromshared/api/errors/toast.ts: ifapiErr.status === 429andmetadata.retryAfteris numeric, shows countdown; otherwise falls back to a plain error toast.
S2 — Shared stores
-
IRateLimiterport (apps/api/src/shared/ports/rate-limiter.port.ts) —consume(key, windows): Promise<Result<RateLimitDecision, RateLimitError>>.RateLimitDecisioncarriesallowed,limit,remaining,resetSeconds,policyName,firstBlock. -
RateLimiterFlexibleAdapter(apps/api/src/shared/services/rate-limiter-flexible.adapter.ts) — implementsIRateLimiterviarate-limiter-flexible. Constructor-injectedIInstrumentation(§8 outer span onconsume). Per-window limiters are lazily constructed and cached. A thrownRateLimiterRes= blocked decision; any other throw =Result.fail(RATE_LIMITER_INTERNAL_ERROR)afterinstrumentation.capture(err). - Durable Postgres store —
RateLimiterDrizzlebacked byrate_limittable (migration0007_medical_liz_osborn.sql):key TEXT PK,points INT,expire TIMESTAMPTZ.clearExpiredByTimeoutdefault (true) keeps the table lean via an unref'd 5-min purge — no sweep route needed for this ephemeral infra table. -
RATE_LIMIT_STOREenv —z.enum(["memory", "postgres"]).default("memory")inenv.ts.storeFactoryFor(store, clientFactory?)returnsmemoryFactory, or callsmakeDrizzleFactory(clientFactory())for postgres (throws if the factory is absent). Default ismemory(zero-config dev); set topostgresbefore 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. Thehandleblock wrapstry_fileswithtemplates { mime text/html }so Caddy processes the HTML template directives before serving. -
Content-Security-Policyheader 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-Endpointspointing at{$VITE_API_URL}/csp-report. - Vite
html.cspNonce(apps/app/vite.config.ts:42) —{ cspNonce: "{{placeholder \http.request.uuid`}}" }: Vite stampsnonce=attributes ontemplates` directive replaces the literal at request time with the actual UUID nonce. -
POST /csp-reportendpoint (apps/api/src/shared/internal-routes/csp-report.route.ts) — mounted before the global restrictive CORS so browsers can post unauthenticated cross-origin. Handles bothapplication/csp-report(legacy) andapplication/reports+json(Reporting API v1). IP-rate-limited viaCSP_REPORT_POLICY. SetsCross-Origin-Resource-Policy: cross-originto prevent Chrome ERR_BLOCKED_BY_RESPONSE on the report POST. Filters out reports whosedocument-uri/documentURLorigin doesn't matchAPP_URL(third-party extension noise). Emitssecurity.csp.violationevent (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, theOriginheader must be present, non-null, and indeps.allowedOrigins. Violation throwsAppErrorException({ code: "SECURITY_CSRF_FORBIDDEN" })→ 403. The rejectionreason(missing_origin|origin_mismatch) is included in the emittedsecurity.csrf.rejectedevent but intentionally absent from the client response (no security-decision leak). - Mounted on
/me,/me/*,/uploads,/uploads/*,/settings/*,/admin/*inindex.ts. -
allowedOriginsreusesenv.CORS_ORIGIN— the same list fed tocors()and BetterAuthtrustedOrigins; 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 onlygetRateLimitDbClient()and theRateLimitDbClienttype; the underlyingPoolis never exposed. Mirrors thegetDb()pattern inconfig.ts. -
makeDrizzleFactory(client)higher-order — replaces the olddrizzleFactoryclosure that captured the globaldb. Takes aRateLimitDbClientand returns aRateLimiterFactory.storeFactoryFor(store, clientFactory?)callsmakeDrizzleFactory(clientFactory())for postgres (throws"RateLimitDbClient factory is required for the postgres store"if absent); for memory,clientFactoryis never called — no pool allocated in memory mode. -
container.tsbinding —getRateLimitDbClientpassed by reference (lazy):storeFactoryFor(env.RATE_LIMIT_STORE, getRateLimitDbClient). The dedicated pool is created only ifRATE_LIMIT_STORE=postgres, on first HTTP resolution. - DoS amplification vector closed — under flood the dedicated pool (max: 3) saturates;
pgthrows "timeout" in ≤ 500 ms → adaptercapture(err)+Result.fail→ fail-closed policies → 503RATE_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-Endpointsbacktick syntax — confirmed valid Caddyfile raw-string syntax, not leaked to the browser. Pending: runtimecurl -Iverification in production.
Hardening pass
Post multi-agent SOTA-2026 review, several low-cost hardening items were folded in before shipping:
- Fail-closed on auth policies —
failClosed: trueon all 8 auth-burst policies. WhenIRateLimiter.consumereturnsResult.fail, the middleware throwsRATE_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_POLICYandCSP_REPORT_POLICYremain fail-open — a store outage should not block normal browsing or CSP telemetry. -
CORS_ORIGINfail-hard in production —env.tsthrows at boot ifNODE_ENV === "production"andCORS_ORIGINis unset. Without it the API falls back tolocalhost, which rejects the real front and collapses both thecors()andrequireCsrfallowlists silently. -
TRUSTED_PROXIESCIDR +privatekeyword (rate-limit.ip.ts) —resolveClientIpusesnode:netBlockListto check trust. Theprivatekeyword expands to all RFC1918 + loopback + CGNAT ranges (mirrors Caddy'strusted_proxies private_ranges), allowing Railway/PaaS deploys to setTRUSTED_PROXIES=privatewithout pinning a non-stable internal IP. Plain IPs and CIDR notation also accepted. Boot warns in production ifTRUSTED_PROXIESis unset (collective lockout risk behind a load-balancer). - CSRF 403 leaks no reason — the
reasonfield used internally for the emitted audit event is not forwarded to the client response. Only"CSRF check failed"is visible externally.
As-built deviation: CSRF is Origin-allowlist, not double-submit cookie
The ROADMAP originally specified a __Host-csrf cookie + X-CSRF-Token double-submit pattern. Dropped for two reasons:
- Cross-origin deploy makes double-submit physically impossible. App and API are on different eTLD+1 origins (distinct
*.up.railway.apphosts).document.cookieis per-origin;__Host-forbidsDomain=; so the front can never read a cookie set by the API origin to echo it back as a header. - Origin-header validation is the 2026 SOTA. Next.js Server Actions, SvelteKit, and Remix all use it. The
Originheader 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
- 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. - 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.
env.CORS_ORIGINas single source of truth for "who is our front". The same list feedscors(),requireCsrf({ allowedOrigins }), and BetterAuthtrustedOrigins. One place to update when the front domain changes; misalignment between cors and csrf would be an open CSRF hole.TRUSTED_PROXIES=privateis 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'strusted_proxies static private_ranges. Single-IP pinning is fragile — Railway recycles IPs across deploys.- Cookie
sameSite: nonein prod is required;requireCsrfis the replacement CSRF layer. The cross-origin (different eTLD+1) Railway-domain deploy means cookies must benoneto survive credentialedfetch.SameSiteno 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 tosameSite: "lax"for a free transport-layer CSRF layer on top.
Deployment debt
RATE_LIMIT_STORE=memoryis per-replica — all in-process state is lost on restart and not shared across instances. Switch topostgresbefore horizontal scaling; a second replica withmemorystore effectively halves the rate-limit budget.TRUSTED_PROXIESmust be set (privateon 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
dbpool (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
- Set
TRUSTED_PROXIES=private(Railway) or the relevant CIDR for your platform proxy. - Set
CORS_ORIGINto the front's public URL (required in production — hard boot error if absent). - Set
RATE_LIMIT_STORE=postgresbefore horizontal scaling; leavememoryfor single-replica deploys. - The 8 auth-burst policies and GLOBAL policy are pre-wired in
index.ts. Add arequireRateLimit(deps, policy)call for any new public endpoint that needs its own budget. requireCsrfis already mounted on the mutation-capable prefixes. New mutation prefixes → add aapp.use("/new-prefix/*", csrf)line.- CSP report URL is baked into
Caddyfilevia{$VITE_API_URL}. Violations appear inaudit_logwithevent_type = 'security.csp.violation'.