Shipped phases: full architectural log. The roadmap stays forward-looking; everything completed lives here.
Each section preserves 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/, file-based routing viaapps/app/routes.ts+ generatedsrc/routeTree.gen.ts; 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.
Why: /legal/accessibility publishes a WCAG 2.1 AA conformance claim, and the EAA has been in force since 28 June 2025. A11y regressions ship invisibly: nobody files a bug, the complaint arrives through a different channel. A published claim with nothing enforcing it is the worst of both worlds.
What this phase delivered:
apps/app/a11y/: Playwright as an axe driver, not an E2E suite.pages.tslists 4 public + 3 authenticated pages, audited in both colour schemes (atest.use({ colorScheme })loop;next-themesdefaults tosystem, so emulating the preference is enough). Adding a page is one array entry.auth.setup.tsproduces thestorageStatethe authenticated tests reuse..github/workflows/ci.yml: the repo's first PR-triggered CI. Until this phase every gate (ci:check,type-check,test,knip,jscpd) ran only in the local pre-push hook, so a PR opened from anywhere else was never checked by a machine. The job stands up postgres,bootstraps the env files, pushes the schema, seeds, starts the API, and runsturbo run check:a11y --filter=app(which builds first throughdependsOn).- Blocking checks: zero
serious/criticalWCAG 2.1 A/AA violations in light and dark; exactly one<main>and one<h1>per page; tab order across every/sign-incontrol; command-palette focus trap; reduced-motion skipping the theme view transition.
Key decisions:
- Lighthouse dropped, against the original spec. Its accessibility category runs a subset of axe-core, so an
a11y = 100budget restates what a zero-violation axe run already proves: at the cost of ~2 min of CI and one more flake source. Perf/SEO budgets remain a separate, unmade decision. audit()asserts the final URL. A gate redirect (no session, stale policies) renders a page with a<main>, an<h1>and no violations: it passes every other assertion. This was not hypothetical: the authenticated tests were green while auditing/legal/acceptthree times, because the seeded account had never accepted the policies. Without a URL assertion the whole authenticated half of the suite is theatre.- One sign-in per run, by construction.
/sign-inallows 5 attempts per 15 min per IP, andRateLimiterFlexibleAdaptersetsinMemoryBlockDuration, so the block lives in the API process: clearing therate_limittable does not lift it. Two sign-ins per run means three consecutive local runs lock the developer out. The setup therefore signs in with the keyboard (covering WCAG 2.1.1 operability) and the interaction spec asserts tab order without submitting. - Reduced motion is observed, not inferred.
theme-transitioningis added and removed within one transition, so polling for it is a race. AnaddInitScriptMutationObserverrecords whether the class ever appeared; the assertion is on that record. a11y/excluded from vitest. Its default glob collects*.spec.ts, and a Playwrighttest.describe()outside the Playwright runner throws. It also carries its owntsconfig.json(types: ["node"]over the react base) and a secondtscinvocation intype-check: the app'sincludeissrc/**only, so the specs would otherwise never be type-checked.
Seven real defects:
- Every password field in the app had no accessible label (
label, critical).FormTextFieldwrapped<FormControl>around the<div class="relative">holding the input and the reveal button.FormControlis a Slot: it forwards the generatedidto its single child, so<label for>pointed at the div. Fixed by wrapping the<Input>itself. /sign-inand/sign-uphad no<h1>.AuthShellpasses its title toCardTitle, which renders a<div>.CardTitlenow acceptsasChild(the shadcn Slot pattern already used byButtonandNavLink), so the visible title is the heading, rather than bolting on ansr-onlyduplicate the way pages with no visible title do.--accent-foregroundon--accentmeasured 4.27:1 in light (active nav pill). L 0.5445 → 0.52 (4.75:1). Dark measured 7.5:1, untouched.--destructivemeasured 3.85:1 both as text on a card and behind white on a button, and the destructiveAlertcompounded it with/90opacity on the description → 3.36:1. The opacity is gone (no red dark enough to absorb ~0.9 of ratio stays readable) and the token went L 0.629 → 0.58 (4.72:1).- The avatar
input[type=file]wassr-only,tabIndex={-1}and unlabelled. Given anaria-label. - Dark only:
--primarymeasured 4.12:1 under white, i.e. every default button in the app. L 0.6132 → 0.58 (4.75:1). Light was already 5.82:1. - Dark only: the impersonation banner measured 2.76:1.
Alert variant="banner"usedbg-destructiveat full opacity;ButtonandBadgehad long carrieddark:bg-destructive/60for exactly this reason, and the banner now does too.
Why the arithmetic was not enough. The first pass computed contrast by hand (oklch → linear sRGB → relative luminance) and concluded the opposite of the truth: that every destructive button was broken in dark, and that --primary was fine. Tailwind mixes opacity in oklab, so bg-destructive/60 lands far darker than a linear-sRGB blend predicts. Hand calculation is useful to propose a candidate L; only axe against the rendered page decides. Every number above is a measurement.
A defect the gate cannot guard. The impersonation banner only renders inside an impersonated session, so no audited page contains it. It was measured by injecting the markup into a live page and running axe on that node: enough to find and fix it, not enough to keep it fixed. Same blind spot for any surface behind a specific session state.
Two clone-blocking db:seed bugs, surfaced by needing a real account to audit authenticated pages: pnpm --filter api db:seed failed on a fresh clone:
dev@clean-stack.testis rejected by the S5a disposable-email guard:.testis IANA-reserved and can never carry an MX record, whichDisposableEmailServicereads as throwaway. Default nowdev@example.com.- The default password
DevSeed!2026-cleancontains the email local part (dev), whichfindPasswordViolationrejects. Default nowNimbus-Harbor-42-Quartz. - The script also now records the initial policy acceptance. Forcing
emailVerifiedin SQL bypasses the/verify-emailhook that normally writes it, so every seeded account landed on/legal/acceptat first sign-in.
Deliberately not covered: Lighthouse perf/SEO budgets, and screen-reader behaviour (no automated tool covers it).
Why: transactional email is asynchronous and users miss it. An in-app inbox is the SaaS default (Linear, GitHub, Stripe) and is the last piece F.1 (Capacitor) depends on. The interesting part is not the inbox: it is that the event backbone already resolves what happened; notifications only add who should hear about it.
Why a typed catalog projection over a workflow DSL: Knock / Novu / Courier all ship a workflow engine because they do not own the emitting system. Here the outbox already resolves fan-out, so the entire "engine" collapses into Partial<Record<EventType, NotificationConfig>>: the third projection of the catalog after visibility-map (webhooks) and retention-map (purge). 21 of 67 events are notifiable; the rest stay audit-only by default.
What this phase delivered:
- Audience by capability, never by role tuple:
"self" | "actor" | "org:all" | { can: OrgPermissions }.audience: "org:admins"is exactly the hardcoded tuple org-scoping rule §6 forbids. Costs nothing at runtime: roles are static, soORG_ROLES.filter(authorizeRole)resolves at boot intoWHERE member.role = ANY($1). Trap:billing:["manage"]is owner-only: a notification asks who needs to know, not who may act, soreadis nearly always the right level. - Fan-out inside the dispatch TX (
NotificationFanoutSubscriber implements OutboxSubscriber), beside audit and webhook fan-out, not theonEventpost-commit handler the design first suggested.onEventis best-effort and isolated, so a lost notification fails silently. - Preference cascade resolved in the insert statement:
org locked > user > org default > enabled, oneLEFT JOIN notification_preferenceper scope per channel on a singleINSERT ... SELECT. In-app decides theWHERE; email decides aCASEfillingemailPendingAt.forced: trueshort-circuits all four and emits no joins. - SSE carries a signal, never data:
pg_notify('notification_created', user_id)+ oneLISTENconnection per instance (never per client, which exhausts the pool at a few hundred users). The client's only reaction isinvalidateQueries, which makes reconnection self-healing and deletesLast-Event-ID, replay and merge logic outright. Client usesfetch+ReadableStream, notEventSource: it cannot carry anAuthorizationheader, which would break F.1's bearer. - Throttling by partial unique index
(userId, dedupKey) WHERE dedup_key IS NOT NULL, window baked into the key. Dedup at insert inside the TX is concurrency-correct; a counter would need a lock for the same guarantee. - Front: bell + inbox in the shell,
/settings/notificationsmatrix, org defaults card in/settings/organization(a route underorgScopeLayoutwould collide, it flattens children undersettings/). Cross-tab read propagation overBroadcastChannel, applied to the cache without refetching. Polling only as fallback, withrefetchIntervalInBackground: falseso a forgotten tab whose stream died stops asking. - No new event type. Notification creation emits nothing: it would loop with its own subscriber. Only the two preference mutations emit (
notification.preference.updated,notification.org_preference.updated). Catalog stays 67 / 28 public / 39 internal.
Structural decisions:
- The preference cascade shipped inert, and was found only when building the UI on top of it. The fan-out never read the preference table,
forcedwas read nowhere, andNotificationPreferenceService.resolvewas registered in inwire with zero callers: the backend phase had been marked complete with a green suite. The settings page would have written rows that changed nothing, while telling users security alerts could not be disabled (nothing was ever disabled). Branched into the insert statement, and the dead service deleted: two implementations of one cascade, only one of them wired, diverge by construction, and this one already had, sinceresolve()ignored unlocked org rows. org defaultis a fourth cascade level, added deliberately. An unlocked org row now applies when the member expressed no choice. Without it, the "organization defaults" card would set values that never take effect: a default that never defaults.forcedLevelOfreturnsall | some | none, not a boolean:securityis fully forced,billingonly partly (payment failed and cancellation are, subscription created is not). A boolean would have to lie about billing. The UI disables a fully-forced row with its reason rather than hiding the switch: a hidden option is one the user hunts for forever.- SQL logic cannot be covered by this repo's unit tests, and pretending otherwise is the real risk. A mocked
txevaluates noWHERE; there is no DB integration harness. Verification lives inapps/api/scripts/check-fanout-preferences.ts(pnpm --filter api check:fanout, 8 cases against Postgres): re-run it after touching the fan-out. Two tests asserting on generated SQL text were written and removed: they depend on the realsql, which another file'smock.modulereplaces process-wide, so they passed or failed on execution order. That script is also what caughtCASE WHEN … THEN $datebreaking Postgres type inference (cast::timestamprequired). - Everything cross-cutting lives in
apps/app/src/shared/notifications/, not in the feature.shared/may not importfeatures/(the bell mounts inapp-shell), and two route-owning features may not import each other (the matrix serves bothfeatures/notificationsandfeatures/organization). The import the feature layout would need does not exist. Promoted to a placement decisor inapps/app/CLAUDE.md. - Row labels reuse
EVENT_DESCRIPTIONSrather than restating 21 strings in the front. Payloads carry ids, not names, so there is no human detail to render beyond the sentence the catalog already owns.
Out of scope (explicit):
- Native push (mobile / browser): Phase F, needs a device-token registry.
Frequency-driven digest windows: the column and the UI exist, the flush cron still treats every pending row the same.Closed. The window is resolved at fan-out and stamped intoemail_pending_at, which turned out to need no cron change at all: the flush already selected on that column. Seedocs/CRON.md§ Digest windows.- Generalized real-time bus. The stream is notification-shaped on purpose; a generic bus is a different product.
- Resend Broadcasts and Audiences: rejected, they are marketing one-to-many and orthogonal to event-driven one-to-one. Resend Topics (vendor-side email preferences) also rejected: it would hand a product decision to the vendor and covers only the email channel, when D.3 exists to arbitrate between channels. Revisit Broadcasts only if a clone ships a newsletter.
Why: any B2B SaaS needs a machine-to-machine auth primitive that can't be session-stolen. Session cookies work for browsers; PATs work for CI, CLI, and customer integrations. Without them, customers resort to screen-scraping or long-lived session abuse: both unauditable. PATs also unblock C.4's opt-in public API surface, which is the prerequisite for D.2 (OpenAPI docs) and F.1 (Capacitor bearer).
Why hand-rolled over @better-auth/api-key: the plugin covers table + SHA-256 + expiry + CRUD but has no lifecycle hooks: rule §6 requires the event to commit in the same TX as the write. Wrapping every endpoint to add event emission would leave only a table with 8 dead columns. ScopedRepository<ApiToken, RepoScope> + emitEvent in-TX cost fewer LOC than the wrapper, and gives native §8 instrumentation. Scopes format and column names borrowed from the plugin.
What this phase delivered:
- Token format:
clean_prefix (configurable viaAPI_TOKEN_PREFIX) + 32 bytes CSPRNG as 44-char base58 + 6-char CRC32 checksum. The prefix makes tokens greppable in logs; the checksum rejects typos/truncations before the DB hit and sharpens the GitHub secret-scanner regex. - Storage:
HMAC-SHA256(pepper, token)in a unique-indexedtoken_hmaccolumn. No per-row salt: a 256-bit HMAC key makes rainbow tables inconceivable, and per-row salt destroys the O(1) lookup. The pepper (API_TOKEN_PEPPER) is the SOC2 argument: a DB dump alone yields no usable token.pepper_versioncolumn enables zero-downtime rotation viaAPI_TOKEN_PEPPER_PREVIOUS. - Schema:
api_token(id, userId, organizationId, name, tokenHmac, pepperVersion, tokenStart, scopes, lastUsedAt, expiresAt, createdAt, revokedAt, revokedReason).userId= creator + owner.ScopedRepository<ApiToken, RepoScope>: wrong-owner returnsOption.none()/NOT_FOUND, never 403. - Scopes: typed const
API_SCOPES = ["read:profile", "write:profile", "read:organizations"]. Per-token subset. No global*. Noadminscope: platform-admin surface is token-unreachable by construction (token management lives outside/api/v1; a token cannot mint a token). requireApiTokenmiddleware: acceptsAuthorization: Bearer clean_<…>, verifies checksum, HMACs both peppers, setsc.var.user+c.var.tokenScopes.lastUsedAtupdated via time-bucket write (WHERE last_used_at < now() - interval '15 min') to avoid hot-row contention./api/v1sub-app: mounted inindex.tsoutside theconst routes = ...chain, deliberately outsideAppType. Token-auth only; nosessionMiddleware. Dual rate-limit: per-token (detect compromised-key abuse) + per-IP (detect distributed rotation). Seeapps/api/CLAUDE.mdfor the AppType exception note./settings/tokensCRUD: create (name + scope picker + optional expiry, token shown once in<SecretRevealDialog>), list (last-used timestamps), revoke.denyImpersonatedon create + revoke: the admin blocklist grows from 11 to 13 routes.- Cascade revocation:
onEvent(ORG_MEMBER_REMOVED)handler revokes all org-scoped tokens for the departed member in a single pass. Emits oneapi_token.revokedper token. POST /api/token-scanning/github: ECDSA P-256 signature verification (GITHUB-PUBLIC-KEY-SIGNATURE+GITHUB-PUBLIC-KEY-IDENTIFIERagainstapi.github.com/meta/public_keys/secret_scanning). Matching tokens are revoked + owner notified by email.- Event catalog 62 → 65: three new events:
api_token.created(public),api_token.revoked(public),api_token.used(internal: sampled, high-volume).visibility-map.tsintroduced as the explicit public/internal allowlist (28 public, 37 internal). Publishing an event is now a deliberate PR-reviewed decision, not the default.
Structural decisions:
-
/api/v1outsideAppType: chaining it intorouteswould imposeAuthVariableson routes that carryApiTokenVariables, and expose every public-API route tohcWithTypewhich the internal RPC client never needs. The boundary is structural, not config: a route under/api/v1is unreachable by session auth. -
api_token.usedis internal: sampled at ~1 event per 15-min bucket per token. Making it public would create a contract on a high-volume, lossy signal: customers could never build a reliable audit trail from it anyway, and subscribing would flood their endpoints.audit_logis the reliable trail. -
All
webhook.*events reclassified to internal during C.5 curation:webhook.endpoint.created/updated/deletedare operational plumbing, not customer-observable state. This means the subscription picker and/developers/eventsshow no "webhook" group. Testwebhooks-schema.test.tsdocuments this decision assertively. -
visibility-map.tsas the gate, not a naming convention: awebhook.prefix doesn't make an event internal; only an explicit"internal"entry in the map does. New events default to nothing: they must be added to the map (TypeScriptsatisfies Record<EventType, Visibility>enforces this). -
The public API surface is opt-in, route by route. A PAT is never accepted by
requireAuth: only a route that explicitly mountsrequireApiToken({ scopes })is reachable by token, and every other route stays session-only by construction rather than by config. "Authenticated" is not "public API": a token that inherits the whole session surface turns every internal route (admin, billing mutations, impersonation) into a public endpoint the day it ships, and the blast radius of a leaked token becomes the entire app. The opt-in registry also serves as the source for the API documentation: only token-reachable routes are documented. Design decisions were taken on 2026-08-06 (org-scoped, HMAC-SHA256 plus server pepper, CRC32 checksum).
Out of scope (explicit):
- OAuth app flow (next natural step after PATs, requires a separate spec).
read:uploadsscope: the uploads module has no listing route and storage is opt-in; the scope would guard a route that can't exist without out-of-scope work.- GitHub Secret Scanning Partner Program registration: requires a public service; each clone registers independently. The boilerplate ships the verifier + endpoint + prefix convention so registration is a 30-minute job.
Why: an audit of ports, services and the HTTP boundary found Result respected everywhere and Option respected only in recently-written code. The convention had been applied going forward and never back-applied, so modules/webhooks (the module the codebase treats as the reference) was inconsistent with itself: WebhookDeliveryRecord used Option<T> directly above two record types using raw | null.
How it was found: not by review. The same defect had just shipped in the D.5 email queue (Date | null in a port whose sibling used Option) and survived three separate code reviews, each of which had the rule quoted verbatim in its prompt. Reviewers verify what they are asked to trace concretely (index mapping, claim concurrency, attempt accounting) and skip what is stated as a general principle. The lesson, now applied when auditing a cross-cutting convention: give the reviewer the conformant sibling file and ask for a field-by-field comparison.
Decisions:
Result.okoverloaded.static ok<T, E>(value?: T)made the value optional for everyT, soResult.ok<string>()compiled andgetValue()returnedundefinedtypedstring, with abiome-ignorecomment claiming the non-null assertion was safe after theisSuccesscheck. It was not: the result is a success whose value is absent. Two overloads ((): Result<void, E>and(value: T): Result<T, E>) close it. The hole was genuinely exploited: 18 test mocks calledResult.ok<void, E>()with no argument.nullstops at the store. Ports express absence asOption<T>; the repository converts withOption.fromNullableon read and unwraps on write; routes unwrap back tonullonly when serialising, so no wire format changed.- Three residuals kept deliberately:
OutboxEnqueueScope.organizationId(input DTO whose callers hold raw nullables from BetterAuth),AuditFilters.organizationId(three-state SQL filter:undefineddon't-filter /nullIS NULL/ value equality;Optioncannot express it), andAuditEventSubscriberwritingaudit_logdirectly rather than throughIAuditPort(infra subscriber, parallel write path by design). - The audit hash chain was never at risk:
audit-hash.tswas not modified, so the hashed bytes are identical on existing production rows andGET /admin/audit-log/verifystill validates them.
Scope: @packages/ddd-kit (the overloads), then consents, billing, the rate-limiter adapter, webhooks, and the shared outbox + audit ports. An adversarial closure re-audit confirmed no unguarded unwrap, no dropped isFailure check and no paper-over cast.
Trap for later: converting a record type to Option tempts the implementer to satisfy the type-checker with arr[0]! in tests. Three such assertions were introduced and reported back as "pre-existing": they were not. Destructure and guard instead.
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.
Plugins shipped: twoFactor, passkey, magicLink, bearer (Capacitor-ready). organization shipped in Phase 2; stripe deferred.
- Performance:
session.cookieCache(5 min) on the server: auth check is signature-only between refreshes (no DB hit). DB stays 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. No morecallbackURLmangling by Outlook & co. - Session as TanStack Query, not React state:
sessionQueryOptions(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')(~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 instantly transitions to/sign-inwithout polling or hard reload. - Strong password schema split:
passwordSchema(loose:min(1), used by sign-in to capture; 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 in verify-email and magic-link pages prevents the dev-only double-fire of single-use tokens. - Pathless layouts:
_protected.tsx(block when no session) and_guest.tsx(block when already logged in): singlebeforeLoadshared by all children, URLs unchanged.
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 B2C, every user gets an invisible auto-created "personal org".
- Auto-create a personal org on signup (
databaseHooks.user.create.after, slugpersonal-${orgId}: UUID v4, never user-visible). - Session enriched with
activeOrganizationId→ Hono middleware pushes it intoc.var.orgId. - Every business table has an
organizationIdFK from the very first migration (never added later). - 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. - Stripe customer = per organization, not per user (the Stripe plugin supports it natively: wired in Phase 3).
The plugin ships built-in role checks for plugin endpoints, but business routes + UI need the same predicate without re-implementing it. Solution: @packages/access-control: wraps createAccessControl with extended STATEMENTS, exports ac, roles, authorizeRole(role, permissions, connector?) predicate. The as unknown as AccessControl cast required by BetterAuth's generic plugin signature is hidden inside the package: call sites stay strict-typed.
Three layers, one contract: server requireOrgPermission(permissions) middleware; front route gate ensureOrgPermission(permissions) in beforeLoad; UI gate <Can requires={...}> backed by useAuthorization(). 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. - Flat
_org-scoperoute layout: one pathless gate 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:SETTINGS_TABSandNAVIGATION_ROUTESfilter 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 (tree-shaken viaimport.meta.env.DEV) visualising the active session's role and the full capability matrix derived fromSTATEMENTS×roles.
Personal org is structurally identical to a team org for every operation except delete/leave. The lifecycle exception is encoded in isPersonalOrg(slug) + the hooks below.
ensurePersonalOrgFor(userId): idempotent self-heal inauth.ts. Runs indatabaseHooks.user.create.after(signup) ANDdatabaseHooks.session.create.before(sign-in: back-fills legacy users withactiveOrganizationId: null). Never duplicate the create-personal-org logic inline.afterRemoveMember: non-Personal orgs auto-collapse when the last member leaves. Skipped for Personal orgs (user must delete their account). Empty orgs = zombies; auto-cleanup keeps it truthful.beforeDeleteOrganization: rejects Personal org deletion outright. The front mirrors this by hiding the Leave button on Personal and rendering a hint on Delete.- Owner-leave flow: sole member → org auto-deletes; sole owner with other members → must transfer first.
transferAndLeaveMutationOptionsis the multi-step factory:updateMemberRolethenleave. Post-leave both flows callswitchToFirstRemainingOrg(queryClient)+broadcastAuthChange(). NO_ACTIVE_ORGANIZATIONtranslated tonull:currentMembershipQueryOptionsandactiveOrgQueryOptionscatch BetterAuth's error code and returnnull. "No active org" is a valid transient state; letting it bubble crashes consumers.broadcastAuthChange()extended to org events:setActive,create-org,delete-org,leave-org,transfer-and-leave,accept-invitation,remove-memberall call the broadcast. Cross-tab consistency under the 5-mincookieCache.maxAgewindow.
Removed the BetterAuth teams sub-plugin. Grouping-only (no team-scoped roles or statements) added UX surface for ~zero value at this stage. Re-enables in two lines if a clear use-case emerges. Settings collapsed from a General/Members/Teams split to a single Organization tab with section-level <Can> gates per role.
Why: templates managed from the Resend dashboard (no code, no rebuild to change wording), built-in versioning, native A/B test.
- Type-safe variables per template:
EmailTemplatesmaps each template name to its required variables. 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). 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 original response, different payload returns 409.SendTemplateOptions.from?: per-tenantfromoverride slot for future 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 logs a warn ("not yet implemented") if passed; resolution will switch to${template}_${locale}env lookup when locale-prefixed templates land. Note: removed rather than activated by the i18n foundation (Phase E.1a): a batch-level locale would force one language on every recipient of a batch, so locale moved ontoEmailRecipient<K>.localeinstead. See that entry.- Boot-time fail-hard in production: constructor throws if
NODE_ENV === "production"andRESEND_API_KEYor any template ID is missing. Dev mode keeps the warn-only fallback. Note: reversed by prod-validation closeout (Phase 0.7) to warn-and-degrade for the boilerplate: see that entry. - 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. No own suppression table needed until a product feature actually consumes it. Building it earlier is the OpenUp anti-pattern. Promote on second occurrence.
- DNS 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. SPF + DKIM CNAMEs + DMARC TXT (
p=none→p=quarantineonce stable, targetp=reject).
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 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).
- Pure transport port (
IStorageService):presignUpload/presignDownload/headObject/deleteObject/publicUrlFor. Zero business rules; the adapter just signs S3 requests and forwards SDK calls. - S3 adapter:
S3Clientwithregion: "auto"(R2's only accepted value),forcePathStyle(kept on for SeaweedFS compat: harmless on R2). Boot-time fail-hard in production ifS3_ENDPOINTis localhost or creds are dev defaults. Presigned PUT signscontent-type+content-lengthheaders (signableHeaders) so the client can't drop them. - 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 enforce filename regex (
^[\w\-. ]+$), scope regex (^[a-z][a-z0-9-]{0,31}$), max size (STORAGE_MAX_UPLOAD_BYTES, default 50 MB). Zod failures return 400. - R2 region is permanent: once chosen (
https://<ACCOUNT_ID>.r2.cloudflarestorage.comor…eu.r2.cloudflarestorage.comfor EU jurisdiction), R2 cannot move the bucket. - Flat DI container: inwire infers everything. Use-cases registered next to their infra ports, type-checked by inference (reorder a
.add()to put a use-case before its port →tscrouge). Promote a section tomodules/<context>.module.tsonly when a bounded context grows large enough to bloatcontainer.ts. createUploadMutationOptions: TanStack Query mutation factory chainingpresign→PUTdirect to R2 (with explicitContent-Length) →confirm. Returns{ key, publicUrl, size, contentType }only after server-verified integrity.
Dev: opt-in via
docker compose --profile storage up -d. SeaweedFS has no auth by default (any creds accepted). No web console.
Why: clean-stack is a boilerplate cloned to start any SaaS. A clone deployed to EU users without Art. 17 + Art. 20 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.
- Export endpoint
POST /me/export: sync (walks user's tables in-request, uploads JSON bundle to R2, emails a signed 7-day URL via Resend). Rate-limited 1/24h. 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 renders the blocking list; theDelete accountbutton stays disabled while non-empty. Auto-transfer rejected on principle, no implicit refiling of legal/billing responsibility onto a member without consent. - Delete endpoint
POST /me/delete: auth + 2FA-required + server-side preflight re-check (409ACCOUNT_DELETION_BLOCKEDif a sole-owner org appeared between read and submit) + 7-day soft-delete grace. Cron sweeps expired requests, wipes personal data and anonymizesmemberrows (userId → null,email → deleted-<uuid>@anonymized.local). - Cancel-deletion UX: signing in during the grace window prompts a cancel/continue dialog.
- Soft-delete confined to RGPD:
user.deletedAt+user.pendingDeletionUntilare the only soft-delete columns in the codebase (no creep elsewhere; everything else is hard-delete).
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 and a safety net against account-takeover-then-delete attacks.
- 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. - Sole-owner preflight is server-authoritative, re-checked at submit: the UI gate is UX;
POST /me/deletehandler re-runs the preflight so a race (org ownership changing between page-load and click) can't orphan an org. - 2FA gate on a destructive irreversible action: reuses BetterAuth
twoFactorchallenge, consistent with "step-up auth on irreversible ops" posture.
Remaining (tracked in dependent phases): E2E deletion-cascade gate (A.6), admin export-on-behalf + deletion overrides (C.3), Stripe customer cleanup during wipe (B.1).
Why: sidebar SaaS shells are the 2010-2024 standard, but the SOTA 2026 wave (Vercel, Linear, Resend, Trigger.dev) consolidated on top-nav + global ⌘K palette. Less chrome, better mobile, keyboard-first power-users.
Top-nav header (sticky, blurred bg); contextual sub-nav (second header line, appears only on /settings/*); global ⌘K palette with Navigate, Switch organization, and Actions groups; org switcher as Command-powered popover. /settings hub with six sub-pages; /settings index redirects to /settings/general.
Custom inline-SVG LogoMark: two offset rounded squares (front solid, back at 18% opacity), theme-aware via currentColor + var(--background). No asset file.
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 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(...) auto-discovered at boot. See docs/EVENTS.md for the full DX guide.
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, uploads. Zod payload schemas: typed discriminated union via PayloadByEventType. RETENTION_MAP: per-event operational (90d) / compliance (7y) / none.
Aggregate.pullDomainEvents(): atomic pull-and-clear.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()forbidden: impl throwsError("nested IUnitOfWork.run() is not supported")because Drizzle nested transactions are independent (not savepoints) → events would orphan.onEvent(type, factory)+EVENT_HANDLER_SYMBOL(cross-realm viaSymbol.for("clean-stack/event-handler")).- UUID v7 inline impl (RFC 9562, no external dep): replaces v4 in
UUID.create(). Time-ordered → B-tree locality on insert.
OutboxDispatcher: 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. Container introspection auto-wires user handlers viaObject.entries(di)+EVENT_HANDLER_SYMBOLfilter.- Built-in subscribers:
AuditEventSubscriberwrites audit rows idempotently via deterministic IDaudit-${event.id}(ON CONFLICT DO NOTHING).WebhookFanoutSubscriberenqueueswebhook_deliveryrows with org-scoping. Multi-tenant safety: events withorganizationId = null(platform-level) skip the webhook fanout entirely, never broadcast across tenants. - AEAD secret crypto:
@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: AWS Architecture Blog formula:
min(cap, random(base, lastDelay × 3)).BASE = 1000ms,CAP = 12h,MAX_ATTEMPTS = 5. Retry paliers ~1m / 5m / 30m / 2h / 12h, dead-letter after 5 attempts. - Request correlation via
AsyncLocalStorage: arequestIdmiddleware wraps each request in an ALS carryingX-Request-Id;DrizzleOutboxRepository.enqueuereads it at the single write choke-point and stampsoutbox_event.metadata.requestId. 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).
3 voies SOTA combinées :
databaseHooks(TX-bound, captures all flows including non-HTTP): USER_CREATED, USER_SIGNED_IN, USER_SIGNED_OUT, USER_ACCOUNT_UNLINKED.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,/passkey/delete-passkey,/verify-email,/change-password,/link-social.- Native callbacks:
emailAndPassword.{sendResetPassword,onPasswordReset},magicLink.sendMagicLink. organizationHooks: afterCreateOrganization, 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, afterCancelInvitation.- Race window BetterAuth COMMIT ↔ outbox enqueue documented as accepted (no 2PC available).
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.modules/webhooks/: full CRUD/settings/webhooks(role-gated),WebhookDeliveryWorkerwith claim window pattern (claim batch withnext_attempt_at = now() + (BATCH_SIZE × FETCH_TIMEOUT + 30s), fetch HTTP outside TX, update status in fresh TX): prevents lock starvation under sustained load. HMAC signing formatt=<unix>,v1=<hex-sha256>(Stripe-style). Idempotency-key<eventId>:<endpointId>(UNIQUE).
Boot: OutboxDispatcher.start(di) + WebhookDeliveryWorker.start() in index.ts. SIGTERM/SIGINT: Promise.all([stopWithTimeout(webhookWorker), stopWithTimeout(outboxDispatcher)]) parallel shutdown (each 25s timeout, fits K8s 30s terminationGracePeriodSeconds).
databaseHooksfor core models,hooks.afterfor plugin events: confirmed by reading BetterAuth v1.6.9 source. 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 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.
WEBHOOK_MASTER_KEY(32 hex bytes) required at boot in production. Per-org sub-key viaHKDF-SHA256(masterKey, 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 =
BATCH_SIZE × FETCH_TIMEOUT + 30s buffer. 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.- 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: 20 issues fixed; round 2: 13 issues (3 invalidated round 1 fixes); round 3: 1 HIGH (
stopWithTimeoutsequential →Promise.all) + 1 MEDIUM (breakon stopping in post-commit loop) fixed. - End-to-end QA pass found 2 real bugs + drove the ORM-first rule. A 36-test harness catching: (a)
ORG_MEMBER_JOINEDsilently missing onacceptInvitation: 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. Both fixed. Same QA pass exposedsql\...`` raw fragments where Drizzle has typed helpers: codified as cross-cutting rule #5 (ORM-first). emitEventtx-aware + drop swallowing catch (post-merge hardening, May 2026). Original service-levelemitEventopened its own autonomous TX and wrapped incatch + logger.error. Two gaps: (a) state change and event emission were not atomic when the caller already had a TX: silent audit/webhook gap; (b) silently swallowed enqueue failures. Fix: optionaltx?: Transactionarg propagated tooutbox.enqueue, catch removed. RGPD writes that ship state-change events now wrap inuow.run. Upload service andauth.tsbridge stay autonomous (no local DB write, hooks don't expose aTransaction): documented limitation.docs/EVENT_PIPELINE.md: pedagogical complement to EVENTS.md. Visual walkthrough (dual-write problem, 4-phase flow diagram, LISTEN/NOTIFY, two-tier delivery, failure modes, SKIP LOCKED concurrency).- Retention sweeps (Phase 0.6, May 2026). Three HMAC-gated
/internal/sweep-*routes purge derived pipeline tables. SOTA 2026 defaults: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). Cron order matters (FKON DELETE RESTRICT): webhook → audit → outbox. Legacy/internal/audit-log-purgeretired:sweep-audit-logis a strict superset. Rule §6 gained an explicit exception for infra retention sweeps.
Why: errors silently swallowed in catch + Result.fail(...) blocks were the #1 source of "no idea why it broke in prod". SOC2 §CC7.3 + ISO 27001 A.16.1 require monitored incident detection. 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 receives the port via constructor: outer span per public method, inner span per query.execute() / fetch() / client.send() (OTel SemConv 1.27+ attributes db.system.name: "postgresql" + op: db.query / db.transaction / http.client / function), catch + capture + return-or-rethrow.
Front (@sentry/react): Sentry.ErrorBoundary wraps the router with shadcn AppErrorFallback, React 19 createRoot receives onUncaughtError / onCaughtError / onRecoverableError handlers from Sentry.reactErrorHandler(). Sentry Vite plugin gated on CI secrets; sourcemap: "hidden". beforeSend scrub whitelist drops email, username, ip_address, cookie/Cookie, authorization/Authorization, x-csrf-token. sendDefaultPii: false.
- Single merged port (vs Lazar's two separate
IInstrumentationService+ICrashReporterService). 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 = dead code. Ship infra cross-cutting only when a consumer exists. SentryInstrumentationconstructor-injected, NOT module-level singleton. MirrorsIEmailService/IStorageServicepattern. 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 would 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. Front
noop.ts+sentry.tsexport identical signatures. Swap = single import path change; no runtime alias gymnastics.
Why: SOC2 §A.1 + ISO 27001 A.12.3 require a tested backup/restore policy. The original roadmap expected a pg_dump cron route in the API; SOTA 2026 audit reversed that decision.
Superseded by Phase L: the Coolify reference deploy now ships pgBackRest PITR to R2 with restore drills, see
DISASTER-RECOVERY.md.
Why no code shipped: Every managed Postgres provider (Railway, Neon, Supabase, AWS RDS, Fly) ships PITR one-click with sub-minute RPO and 7 to 35 d retention. pgBackRest lost its maintainer in 2026: building a clean-stack route on top would have duplicated what the platform already does, forced postgresql-client into the Docker image, and introduced streaming/OOM/timeout failure modes the cloneur inherits for zero value. clean-stack ships cross-cutting multi-tenant infrastructure: backup is infra DB, owned by the provider.
Deliverables: docs/DISASTER-RECOVERY.md covering RPO/RTO targets, 3-2-1 rule applied, restore runbook, PITR pointers per provider (Railway, Neon, Supabase, AWS RDS, Fly, self-hosted WAL-G: pgBackRest flagged unmaintained), weekly portable pg_dump export recipes (GitHub Actions, Railway Cron, K8s CronJob), monthly automated restore-test recipe.
- Doc-only. No route, no script, no Docker image change. SOTA-2026 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.
backups/postgres/prefix in the existing S3 bucket, not a dedicated bucket. Trade-off accepted: lifecycle policy applies to the whole bucket, slightly less SOC2-friendly. Simpler ops; the lifecycle filterPrefix: "backups/postgres/"still isolates the expiry rule.- Read-only Postgres role for the export job, not the API role.
pg_dumponly needsGRANT CONNECT, USAGE, SELECT. CI-secret-leak best practice. - No
pnpm db:smokescript committed. The verification step is a 15-line inline snippet in the doc; adding it to the boilerplate would couple to a specific schema enumeration that drifts on every domain change. - No GitHub Actions workflow committed. Committing
.github/workflows/postgres-export.ymlwould silently fire on every fork without secrets: bad UX. Documented YAML stays inert until copied.
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.
- Each infra-owning module ships an
XxxHealthProbe 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 critical fails (503).- 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 moved behind/internal/build-info(HMAC-gated). Public probes were an info-disclosure vector (version fingerprinting + exact-source mapping for private clones).
Why: the vertical-slice contract claims "a leaf feature is removable in 5 minutes". Until one was actually removed end-to-end, that was theory. Runbook + worked example + edge cases in docs/REMOVABILITY.md.
Removed modules/rgpd end-to-end in a throwaway worktree: 46 files touched, −2980 LOC net, 3 DROP COLUMN migration. All 6 gates green (type-check, ci:check, check:unused, check:duplication, build, test).
4 surprises captured: a 3rd RGPD column missed by the initial cartography, a transitively-dead throwApiError helper, a dangling knip pattern, pre-existing test fails unrelated to the removal. Contract holds: TS error-points the rest.
6-axis checklist codified (schema barrel, DI .addModule() + app.route(), access-control statements, front nav, email templates): the canonical "how to remove a feature".
Why: clean-stack mentioned Railway in 3 docs but no cloneur had ever validated the boilerplate end-to-end. The existing Railway project fell back to Nixpacks (no railway.toml) and couldn't drive the pnpm + Bun monorepo. Phase 0 couldn't be closed with this gap.
SOTA 2026 pattern: monorepo "shared root": all services point rootDirectory = / (build context = repo root to resolve packages/), each has a custom config file path (infra/railway/<service>.toml). cronSchedule native in deploy block. Reference Variables (${{shared.NAME}}, ${{Postgres.DATABASE_URL}}) for cross-service secrets, never duplicated.
apps/api/src/cron/sweep.ts: chains the 3 sweeps in FK order (webhook → audit → outbox) via signedInternalFetch. Reads API_URL and INTERNAL_SIGNING_KEY from env, process.exit(1) on first non-2xx. Bundle entrypoint added to bun build alongside index.ts + migrate.ts. The cron Railway service reuses the api image (single Dockerfile, single source of truth), no separate cron.Dockerfile.
- Single-source cron: Railway Cron only, no GH Actions fallback. User explicit decision after the first plan proposed both. Multiple cron paths = noise + risk of desync. If a cloneur wants another scheduler, the runbook documents the swap.
- Storage: Cloudflare R2 by default, Railway Bucket as documented alternative. R2 free tier 10 GB + zero egress > Railway Bucket. The trap: Railway Bucket egress (service→bucket) is billed at public network rates: invisible in the Railway pricing page. Calculated inline in the runbook.
- Cron service reuses the api image, no dedicated Dockerfile. Single source of truth for the binary.
startCommandoverride +cronSchedulein the service dashboard suffice. - Custom config file path in dashboard, not multiple
railway.tomlat root. Railway 2026 supports only onerailway.tomlper service-root. For shared build context (= repo root, required forpackages/), only option =rootDirectory = /for all 3 services + 3 different config paths in the dashboard. Not reproducible via file alone: documented as manual step 1 in the runbook. - No
preDeployCommandfor migration. Keeps the patternCMD migrate && startportable (Fly, K8s, Cloud Run).preDeployCommandis a Railway optimization for > 5 replicas; defer to operational phase. - No
numReplicas = 1explicit. First iteration had it: invalid: this field only exists under[deploy.multiRegionConfig.<region>]. Default = 1 implicitly. - No GH Actions deploy workflow. Railway watch
mainnatively +watchPatternsper service = zero glue needed. First iteration had a matrix workflow hitting Railway Deploy Webhooks: removed (double-deploy with native watch, unnecessary GH secrets, violates single-source).
The config-as-code shipped but no one had run it end-to-end. Bringing the reference deploy green on Railway surfaced a stack of prod-boot traps, each masking the next. All fixed:
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"). Never setNODE_ENV=developmentin prod.@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 →Cannot find module 'better-auth/plugins/access'in the pruned prod image. Compiles in dev (hoisting + devDeps present), breaks only in the pruned prod image.- Email + storage threw at boot.
di.preload()is eager, soResendEmailServiceandS3StorageServicefail-hard'd when unconfigured. Changed to warn-and-degrade: email logs-not-delivers, storage swaps toNoOpStorageService. Reverses the Phase 1 "boot-time fail-hard in production" email 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) → container exits instantly with zero logs, healthcheck never passes, deploy fails silently. Cleared the override so the Dockerfilecaddy runCMD applies.- Cross-site cookies.
appandapion different*.up.railway.apphosts = different sites. 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.
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.RAILWAY_GIT_COMMIT_SHAonly populates on branch deploys, notrailway up.GIT_SHA/BUILD_TIMEshowunknownunderrailway up.- 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).
Why: two non-negotiables bundled in one push. Art. 16 GDPR requires a working edit-profile surface. 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).
IPasswordBreachServiceport +HibpPasswordBreachService: HIBP k-anonymity (api.pwnedpasswords.com/range/<sha1[:5]>,Add-Paddingheader, timeoutHIBP_TIMEOUT_MSdefault 3000 ms). 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. The inline ~20 common-password list was dropped: HIBP already covers every common password, so it was dead weight.pendingEmailfield: nullablepending_emailcolumn exposed as a BetterAuth additionalField. Set insendChangeEmailConfirmation, cleared on effective change: drives the front "pending change" badge.- Password field UX (NIST-aligned): show/hide reveal toggle + optional
descriptionslot. Policy rejections (HIBP breach, contextual ban, wrong current password) surface inline on the offending field viaform.setErrorinstead of a transient toast.
- 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.
- HIBP fail-open: 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. Cleanly separates validation (policy) from hashing (crypto). 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.- No
/settings/profilepage: rectification fields live in/settings/account. A dedicated tab is reserved for Phase A.5 (Privacy dashboard). - Password policy extracted to a pure
validatePassword(): the security-critical logic 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.
user.changeEmail runs a two-confirmation flow: neither the request nor the first click mutates user.email:
- Request:
authClient.changeEmail({ newEmail, callbackURL })→ oursendChangeEmailConfirmationhook setspendingEmail = newEmail, emitsuser.email.change_requested, and mails the current address a confirmation link. - Confirm (current address): the link hits
/api/auth/verify-email?token=…. BetterAuth mints a second token and mails the new address a verification link. Email still unchanged. - Verify (new address): this flips
user.emailto the new value.databaseHooks.user.update.afterclearspendingEmailand emitsuser.profile.updated.
Mail links point at the API (not the app front): for this one auth mail the click is the state transition (BetterAuth applies it server-side), then redirects to callbackURL. The other auth mails (reset, magic-link, verify) route through the app because the front consumes their token.
(See Railway Phase 0.7 above for the full prod-boot trap log: this closeout was part of the same merge sequence.)
Why: Art. 7 §1 RGPD: "the controller shall be able to demonstrate that the data subject has consented". 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.
@packages/policies: source-only package. ExportsPOLICY_TYPES,POLICY_VERSIONS(Record<PolicyType,string>, both"2026-01-15"),POLICY_CHANGELOG. Imported by api (gate + service), 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.- Append-only
policy_acceptancetable:(userId, policyType, policyVersion, ipAddress, acceptedAt)+ composite index(userId, policyType, acceptedAt DESC). Two-layer compliance trail: this table is the live gate evidence; the durable 7-year compliance trail lives inaudit_logviauser.policy.acceptedevent. requireCurrentPoliciesmiddleware: throwsHTTPException(409)when any policy is stale. Composable, not mounted globally: the front_shellbeforeLoadredirect is the primary UX enforcement today; the middleware is opt-in for business routes needing hard server-side gating.- Acceptance gate
/legal/accept: adapts to context: new user sees "Before you get started" + links to policies; returning user with stale version sees changelog diff. One Accept button either way; on success navigates to the originally intended route.
The original plan proposed recording acceptance at /sign-up/email. 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). /verify-emailis the natural idempotent boundary. This route fires exactly when the user proves ownership of their email address.getStaleTypes(userId)makes the call naturally idempotent: a user who re-verifies after an email change is a no-op.
Safety net: the front _shell beforeLoad gate redirects any authenticated user with stale policies to /legal/accept regardless of auth path.
@packages/policiesas SSOT, not a front-only config. The version string must be the same on the API, the front, and the DB schema. A front-only config means the API has a hardcoded constant elsewhere: drift. The 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. - Not DDD. The acceptance rule is
latestAcceptedVersion === currentVersion: a comparison, not an invariant requiring aggregate lifecycle protection. If the rule fits in a comparison, it's infra. /verify-emailhook, not/sign-up/email: see deviation note above. The gate-predicate (front_shell) is the primary enforcement; the hook is best-effort defense-in-depth.requireCurrentPoliciescomposable, not global default. Mounting it globally would make every current API call return 409 for a stale user: too aggressive.
Why: a boilerplate that ships auth, multi-tenant, and billing 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), CSRF, and CSP telemetry.
requireRateLimit(deps, policy)factory: on blocked: setsRetry-After, throwsAppErrorException({ code: "SECURITY_RATE_LIMITED" })→ 429. On first block: emitssecurity.rate_limit.exceededevent ifpolicy.emitSecurityEvent. On store error: either 503RATE_LIMITER_UNAVAILABLE(fail-closed) or warn + pass-through (fail-open): controlled per policy.- 8 auth-burst policies (all IP-keyed,
failClosed: true,emitSecurityEvent: true).GLOBAL_POLICYandCSP_REPORT_POLICYremain fail-open. - BetterAuth built-in
rateLimitdisabled: the Hono middleware is the single 429 path. One envelope, one store, one set of headers. BetterAuth's built-in has its own 429 shape and its own in-memory store. - Durable Postgres store (
rate_limittable) with dedicated pool (max: 3, connectionTimeoutMillis: 500 ms): separate from the app pool (max: 20). Pool exhaustion under a DDoS flood cannot spill into application query capacity. RATE_LIMIT_STOREenv:z.enum(["memory", "postgres"]).default("memory"). Default ismemory(zero-config dev); set topostgresbefore horizontal scaling.- Per-request nonce via Caddy
templates: Caddy's native{http.request.uuid}provides the per-request nonce. Vitehtml.cspNoncestampsnonce=attributes at build time using the placeholder string; Caddy'stemplatesreplaces it at request time. POST /csp-reportendpoint: mounted before the global restrictive CORS (browsers post unauthenticated cross-origin). Handles bothapplication/csp-report(legacy) andapplication/reports+json(Reporting API v1). SetsCross-Origin-Resource-Policy: cross-origin. Filters reports whosedocument-uriorigin doesn't matchAPP_URL(third-party extension noise).requireCsrf(deps)middleware: Origin-allowlist strategy: safe methods pass through; for all other methods,Originmust be present and indeps.allowedOrigins. The rejectionreasonis included in the emitted event but absent from the client response (no security-decision leak).allowedOriginsreusesenv.CORS_ORIGIN: single source of truth for "who is our front".TRUSTED_PROXIESCIDR +privatekeyword:resolveClientIpusesnode:netBlockListto check trust.privateexpands to all RFC1918 + loopback + CGNAT ranges, allowing Railway/PaaS deploys to setTRUSTED_PROXIES=privatewithout pinning a non-stable internal IP.CORS_ORIGINfail-hard in production:env.tsthrows at boot if unset. Without it the API falls back tolocalhost, silently rejecting the real front.
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 (
*.up.railway.app).document.cookieis per-origin;__Host-forbidsDomain=; 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, zero front-end code.
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.
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.
/csp-report is public (browsers post unauthenticated: HMAC verification is impossible from a browser context); defended instead by rate-limit + Cross-Origin-Resource-Policy: cross-origin + document-uri origin filter.
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, 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.
Three OSS signals wired into BetterAuth hooks.before, each emitting a security.* event. BetterAuth's Sentinel plugin (@better-auth/infra) covers all of this (credential-stuffing, HIBP, impossible-travel, geo/bot blocking, free-trial abuse) but it is a paid, API-key-bound cloud SaaS, which fails the zero-mandatory-SaaS rule. We mined its threat model and shipped the cheap, calibration-free subset ourselves.
- HIBP breached-password telemetry.
validatePasswordalready rejected Pwned-Passwords hits (A.1); S5a makes the reject observable: emitssecurity.password.breachedonly on a genuine breach. - Per-account credential-stuffing counter. A second
IRateLimiter.consume("auth-sign-in:account:<email>", …)axis on/sign-in(5/15min, fail-closed). IP-only is dead against distributed botnets rotating source IPs against one account; the account axis catches exactly that. - Disposable-email block.
IDisposableEmailService(embeddeddisposable-email-domains~90k-domainSet, O(1)) + DNS MX lookup. Fail-open: a DNS error/timeout warns and lets the sign-up through.
As-built gotcha (cost the demo, not the unit tests): ctx.context.* is undefined in a BetterAuth hooks.before. The global before-hook runs before the session middleware, so ctx.context.request and ctx.context.session are both undefined there (they are populated in hooks.after). The initial implementation read the client IP via ctx.context.request.headers → a TypeError that (a) escaped uncaught on /sign-in → 500 on every login, and (b) was swallowed by the emit's own try/catch on the other paths → the three events never persisted despite the 422/429 firing. Unit tests don't mount the BetterAuth hooks, so only an end-to-end pass (curl + outbox_event query) surfaced it. Fix: read the IP from ctx.headers, and load the change-password actor via auth.api.getSession({ headers: ctx.headers }). Lesson: any story wiring a library's lifecycle hooks must be exercised end-to-end: a green unit suite proves nothing about the hook boundary.
- Single unified Hono rate-limit middleware; BetterAuth built-in disabled. One 429 error envelope, one §8-instrumented store, one set of IETF headers. BetterAuth's built-in creates a second 429 shape and a second in-memory store.
- 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 / in-memory fallback would avoid a transient store glitch turning prolonged login into 503.
env.CORS_ORIGINas single source of truth for "who is our front". The same list feedscors(),requireCsrf({ allowedOrigins }), and BetterAuthtrustedOrigins. Misalignment between cors and csrf would be an open CSRF hole.TRUSTED_PROXIES=privateis the correct Railway value. Trusting private ranges is safe on Railway (reachable only via the platform's edge proxy over the private network) and avoids pinning a non-stable internal IP.- Cookie
sameSite: nonein prod is required;requireCsrfis the replacement CSRF layer. The cross-origin Railway deploy means cookies must benoneto survive credentialedfetch.SameSiteno longer provides 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) should switch tosameSite: "lax".
RATE_LIMIT_STORE=memoryis per-replica: state lost on restart, 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.
Why: two legal obligations were shipping as missing pages: GDPR Art. 28 (sub-processor disclosure is mandatory when acting as a data processor for any EU client) and EAA Art. 14 (accessibility statement mandatory since June 28 2025). Bundled with two contractual templates (DPA + DORA annex) because they share the same context window and are all pure Markdown / static config: no DB, no backend, no event. A missing sub-processor page or accessibility statement is a legal violation the moment a clone ships to EU users; a missing DPA template blocks every EU enterprise deal.
- Public pages
/legal/sub-processorsand/legal/accessibility(no auth gate). Both linked from the command palette (LEGAL_ROUTESgroup) anddata-rights.page.tsx. - Contract templates:
docs/legal/DPA-template.md(12-clause GDPR Art. 28) +docs/legal/DORA-annex-template.md(11-provision DORA Art. 30, mandatory for EU fintech/insurance since Jan 17 2025). VITE_SENTRY_DSNempty-string fix: schema wasz.url().optional().z.url().parse("")throws (empty string ≠ undefined). Fix:z.preprocess((v) => (v === "" ? undefined : v), z.url().optional()). The boilerplate now boots clean on a freshpnpm bootstrapwithout requiring the cloner to manually delete the empty DSN line.
- No footer links → command-palette +
data-rightscross-links. No global footer component exists in the app shell (top-nav only: SOTA 2026 pattern). Footer links can be added when a global footer is introduced (Phase E.2 marketing site). CardTitleheading tree →<TypographyH2>. shadcn<CardTitle>renders as<div>, not an<h2>: the<h1>page title had no<h2>children, a WCAG 1.3.1 heading-structure violation. Fixed so the accessibility statement is itself accessible.
- 0 domain events. Pages are 100% static reads, no aggregate, no write path, no compliance state change.
status: "active" | "planned"split. Active = contractually engaged today (require DPA coverage). Planned = will require a DPA update before going live (Art. 28 §2 30-day advance notice). This makes the contractual obligation visible.url?+dpaUrl?both optional. Not every sub-processor publishes a DPA URL directly.- Accessibility statement written before A.6 CI gate. The statement must exist (obligation since Jun 2025); its accuracy improves as A.6 lands. A statement with a complaint contact satisfies the obligation; a blank page does not.
Why: la directive ePrivacy + RGPD Art. 7 exigent un consentement valide avant tout dépôt de cookie non-nécessaire. Sans banner conforme, un clone qui ajoute Umami, Plausible, Stripe pixel ou n'importe quel tracker est illégal en EU dès le premier déploiement. A.4 fournit la mécanique de réconciliation guest→user et expose les primitifs (<ConsentGate>, <AnalyticsScripts>) pour que les cloners branchent leurs outils sans réécrire la couche.
Pourquoi infra, pas DDD : isActive = withdrawnAt IS NULL AND expiresAt > now AND policyVersion = current est une WHERE clause ; la catégorie = categories.includes(cat) ; la validité = comparaison de dates. Même classe qu'A.2 (modules/policies/).
@packages/cookie-consent: source-only. ExportsCONSENT_CATEGORIES,CONSENT_COOKIE_NAME = "cc_sid",CONSENT_GRANT_TTL_DAYS = 180,COOKIE_CONSENT_VERSION. BumpCOOKIE_CONSENT_VERSION→ re-prompt automatique de tous les users.- Append-only
consent_recordtable:subjectId NOT NULL, userId nullable FK.subjectId= UUID généré serveur, stocké dans le cookiecc_sidhttpOnly. Découple le consentement du compte (un guest peut consentir avant de créer un compte). - Routes
/consents(optionalAuth: guests ET utilisateurs connectés). Cookiecc_sid:httpOnly: true,secure: isProd,sameSite: isProd ? "none" : "lax". Pas de prefix__Host-: le déploiement cross-origin rend__Host-inutilisable. - Rate-limit
CONSENT_POST_POLICYsur POST/DELETE uniquement: un GET rate-limité saturait la fenêtre et bloquait l'affichage du banner (bug découvert en test : après plusieurs reloads, le GET/consentsretournait 429 et la bannière flashait en boucle). <AnalyticsScripts>: exemple d'usage : charge le scriptVITE_ANALYTICS_SRCseulement sianalyticsconsenti via<ConsentGate>, cleanup React au retrait.VITE_ANALYTICS_SRCvide = composant no-op.- Toast 429 consolidé (
observability/query-error-handler.ts):notifyIfRateLimitedcentralise le toast 429 pour toutes les queries ET mutations, dédup parid. L'ancienrate-limit-toast.ts(countdown seconde-par-seconde) supprimé: inadapté aux duréesCONSENT_REFUSAL_TTL_DAYS(heures/jours, pas secondes).
La réconciliation guest→user se fait entièrement côté serveur, sans round-trip client, via le hook BetterAuth hooks.after. Dans auth.ts, si ctx.context.newSession est non-null (= login vient d'avoir lieu: signal couvre TOUS les flux : password/passkey/magic-link/2FA/email-verify/OAuth), lit cc_sid depuis les headers, appelle ConsentService.reconcile(subjectId, userId).
Pourquoi hooks.after + ctx.context.newSession et PAS databaseHooks.session.create :
databaseHooks.session.create.aftern'a pas accès aux cookies de la requête HTTP: confirmé par la source BetterAuth (le hook reçoit le modelsession, pas lesRequestheaders). Lire le cookiecc_sidy est impossible.hooks.after+createAuthMiddlewaredonne accès auxctx.headers.ctx.context.newSessionest le signal canonique "un login vient d'avoir lieu sur cette requête, tous flux confondus".- Un seul point d'entrée = pas de drift si BetterAuth ajoute un nouveau flux d'authentification.
Règle générale extraite : pour exécuter du code à chaque login (tous flux confondus) avec accès aux cookies de requête, utiliser hooks.after + createAuthMiddleware + vérifier ctx.context.newSession. databaseHooks.session.create est TX-bound mais n'a pas les headers.
- Device-scoped vs user-scoped:
userId NOT NULLsurconsent_recordchangé ensubjectId NOT NULL, userId nullable: (a) un guest doit pouvoir consentir avant de créer un compte ; (b) RGPD Art.7§1 requiert une preuve horodatée, pas la session. La réconciliation au login lie le guest au compte sans perdre l'historique. - Réconciliation via
hooks.after+newSession, pasdatabaseHooks: la contrainte technique (pas d'accès aux cookies dansdatabaseHooks) a forcé ce choix ; la solution couvre tous les flux en un point. - Rate-limit GET exclu: le GET
/consentsest appelé à chaque rendu initial. Un rate-limit sur GET saturait la fenêtre en quelques reloads normaux et bloquait l'affichage du banner (bug reproduit en test manuel). - Append-only, pas d'idempotence sur record: la trace complète des changements de consentement est une exigence de compliance. L'auditeur veut voir "l'user avait accepté marketing le 3 juillet, puis l'a retiré le 5" ; une upsert détruirait cet historique.
- GPC requalifié hors scope EU: après SOTA review 2026 : l'EDPB ne l'a pas reconnu comme signal de retrait valide au sens RGPD ; seule la CCPA (California) lui donne force légale. Le modèle opt-in du boilerplate satisfait intrinsèquement la conformité RGPD.
- DNT mort: le W3C a officiellement abandonné la spécification DNT en 2024. Ignorer
DNT: 1est la posture correcte en 2026. - Google Consent Mode v2 hors scope: aucun produit Google dans le stack. IAB TCF v2.2 pareillement (heavy, vendor-specific).
<AnalyticsScripts>comme exemple, pas comme primitif figé: le cloner le remplace ou le réutilise. Aucune dépendance runtime sur un outil spécifique.- Toast 429 consolidé:
CONSENT_REFUSAL_TTL_DAYS=180produirait desRetry-Afterde plusieurs heures, que l'ancien countdown seconde-par-seconde ne gérait pas.
Why: every SaaS needs billing. B.1 ships the plumbing as permanent infra following the same infra-not-DDD principle as policies (A.2) and consent (A.4). Leaving billing to the cloner means every clone rebuilds the same Stripe webhook idempotency, subscription SSOT decision, seat-gating hooks, and free-tier fallback independently: that is the OpenUp anti-pattern at scale.
Posture: zero billing backoffice. Stripe Checkout handles upgrades; Stripe Billing Portal handles subscription management. The app surfaces only the current plan, seat usage, Upgrade button (→ Checkout), and Manage button (→ Portal). Copy and prices live in Stripe; no redeploy to change them.
@better-auth/stripe: Stripe customer = per organization (honoring the Phase 2 multi-tenant decision). Plugin manages thesubscriptiontable + webhook ingestion +createCheckoutSession/createPortalSession.- Subscription state SSOT =
subscriptiontable (plugin-managed, webhook-synced).organization.metadatacarries no plan data. No poll-to-Stripe in the request path. - Hybrid catalog: price + display copy in Stripe Products. Feature entitlements, tier rank, and
maxMembersin typed code atmodules/billing/config.ts(ENTITLEMENTSmap).metadata.tieron the Stripe Product is the sole join key. Editing a gate = a reviewable code change, never a silent dashboard edit. - Unlimited seats =
null(JSON-safe).Infinityis not JSON-serializable (JSON.stringify(Infinity) === "null": a silent wrong value).nullis explicit; the nil-check isif (maxMembers !== null && current >= maxMembers). - Seat gates in all three org hooks:
beforeAddMember+beforeAcceptInvitation+beforeCreateInvitationall checkENTITLEMENTS[tier].maxMembers. Gating onlybeforeAddMemberleaves a window where an admin can issue more invitations than the seat cap allows; the acceptances then fail with an unhelpful error. - Three gate axes (transferable pattern): (1) Role gate:
billing:["read","manage"]in@packages/access-control. (2) Seat quota:ENTITLEMENTS[tier].maxMembers, returns403 BILLING_SEAT_LIMIT_REACHED. (3) Tier/feature gate:requireFeature(flag)/requirePlan(minTier)(back, returning402 BILLING_PAYMENT_REQUIRED);useEntitlements()/<FeatureGate>/<PlanGate>(front).402= "available on a higher plan": semantically distinct from403(wrong permission) or401(unauthenticated). STRIPE_SECRET_KEYunset → free-only degradation, no boot failure.RgpdService.executeAccountWipe: closes the "Stripe customer cleanup during wipe" deferred from Phase 1. Failure is captured and logged (non-fatal: account deletion must never be blocked by a Stripe API error).
- State SSOT = plugin
subscriptiontable, notorganization.metadata. Metadata is an opaque blob: not queryable by column, not typed at compile time, subject to divergence when a webhook arrives out of order. - Hybrid catalog (Stripe + typed code,
metadata.tieras join key). Pure-Stripe means a feature gate change is a dashboard edit: unreviewed, unversioned. Pure-code means a price change requires a deploy. The hybrid: prices in Stripe (the natural editor), capabilities in code (must pass code review, version-controlled). nullfor unlimited, notInfinity.JSON.stringify(Infinity) === "null": a silent wrong value. The sentinel9999was also ruled out: it silently becomes a real cap if a customer ever reaches it.- Standard unlimited-orgs model. Capping org count per account requires a cross-org aggregate query on every org-create path: avoidable complexity. The dominant SaaS precedent (Vercel, Linear, Resend) limits seats and features within an org, not org count.
402 BILLING_PAYMENT_REQUIREDcode suffix required in the response body. A bareHTTPException(402)produces nocodefield in the error envelope: the client-side branch is unreliable.- Whole
billing.*family =complianceretention (no divergence).operationalwas considered forbilling.payment.failed(a transient dunning signal), but keeping the family on one retention lifetime avoids a split audit trail where a failed-payment record is purged before the subscription events that reference it. - Loose-typed
authClient.billing.*(documented debt).@better-auth/stripev1.6.23 client extensions returnanyin client types. The app confines them behind typed adapters infeatures/billing/_api/; the untyped surface is a single file.
- Portal route missing
billing:managegate: any authenticated member could redirect to the org's Stripe portal and change the payment method or cancel the subscription. 402response body missing code suffix: barethrow new HTTPException(402)produced nocodefield; error handler couldn't distinguish it from other 402s.beforeCreateInvitationnot seat-gated: an admin could create 10 invitations for a 3-seat free org; acceptances fail at acceptance time with a generic error and no clear recovery path.- Double
billing.subscription.cancelledemission:subscription.updated(whencancelAtPeriodEndflips, a scheduled cancellation) andsubscription.deleted(actual termination) are distinct business facts. Fix: onlysubscription.deletedemitsbilling.subscription.cancelled.
Why: the third gate axis in the billing design (Role, Seats, Tier/Feature from B.1) was always quotas: quantitative limits per org per billing period. Shipped as a dormant, complete skeleton: no code path calls requireQuota or reserveQuota today, but the plumbing is wired and knip-whitelisted.
Two-layer design (anti-TOCTOU):
- Pre-check (
requireQuotamiddleware): UX-layer, runs before the write, returns429 BILLING_QUOTA_EXCEEDEDearly. UsescountScopedRows(aCOUNT(*)) or thequota_usagedenormalized counter. - Authoritative reserve (
reserveQuota): insideuow.run(), acquires a Postgres advisory lock (pg_advisory_xact_lock(orgId hash, quotaKey hash)) then recomputes the count within the same TX as the insert. No TOCTOU: the count and the write are atomic.
Two counting strategies: Live COUNT(*) (default, zero drift, for uploads/projects/seats). Denormalized quota_usage (high-volume, increment(orgId, resource, period, tx) in same TX as business write, window-aligned on Stripe billing period).
Atomic reserve decision: advisory locks chosen over SELECT … FOR UPDATE because: (a) the quota_usage row may not exist yet (first use in a period), requiring an upsert + lock sequence that advisory locks short-circuit; (b) advisory locks are XACT-scoped (auto-release at TX end); (c) they add zero table contention on the resource table itself.
SOTA rejections (2026):
- Stripe Entitlements API: boolean-only (feature flags, not counts). No quantitative quota, no runtime blocking.
- Stripe Billing Meters: async metering-to-bill, not synchronous gating-to-block. A metered event reaching Stripe does not prevent the next write.
@better-auth/stripenativelimits: would create a 2nd SSOT (entitlements in code + limits in plugin config). Kept unified inENTITLEMENTS[tier].quotas.
Why not DDD: count(rows) >= limit is a WHERE clause and a comparison. No aggregate invariant, no domain event. Every quota rule collapses to config lookup + arithmetic. Applying DDD here matches the OpenUp anti-pattern.
Why: the audit write-path shipped with the event-driven foundation but rows were invisible, no read surface, no integrity proof. C.2 ships the read side as an operator (platform-admin) surface: cross-org filtering, a tamper-evidence hash chain, and the first /admin front zone (foundation for C.3 admin & impersonation).
GET /admin/audit-log: cursor-paginated (limit 1 to 500, default 50), filtersactorId/organizationId/targetType/targetId/actionPrefix/occurredFrom/occurredTo. CQRS read side, no use case.- Gate:
requirePlatformAdmin: operator =env.PLATFORM_ADMIN_IDSallowlist ORuser.role === "admin", optional MFA viaPLATFORM_ADMIN_REQUIRE_MFA. NOTrequireOrgPermission({ auditLog: ["read"] }): the surface is cross-org by design; theauditLogstatement stays in@packages/access-controlfor a future per-tenant view. - Tamper-evidence hash chain (env-gated
AUDIT_TAMPER_EVIDENCE, default off):audit_log.{sequence,prev_hash,hash}: SHA-256 over canonical row content +prevHash, chain writes serialized viapg_advisory_xact_lock(hashtext('audit_log_chain')).GET /admin/audit-log/verifyrecomputes the full chain →{ verified, rowCount, brokenAtId, brokenAtSequence }. - Meta-audit: reading the audit log is itself audited:
security.operator.audit_accessedemitted only on the first page (cursor absent), not per pagination step.
Decisions (C.2):
- Operator surface, not tenant surface: a per-org audit page would either leak platform-level rows (
organizationId = null) or hide them. A future tenant view (feature-gatedaudit_loginENTITLEMENTS) stays a separate deliverable. - Hash chain global, not per-org: one chain, one advisory lock; per-org chains would multiply genesis edge cases without adding forensic value.
- Genesis-at-activation: rows written before
AUDIT_TAMPER_EVIDENCE=truekeephash = null; the chain starts at the first hashed row, so enabling the flag never requires a backfill migration.
Outbound webhooks: SOTA hardening (Plans 1 and 2) + front UI + public catalog (Plan 3) ✅ Phase C.5 · Jul 2026
Why (hardening): the event-driven foundation shipped the webhook worker with HMAC signing and jitter retry, but left several attack surfaces open: (1) SSRF: org admins can register arbitrary URLs; (2) secret rotation: hard-cutting a secret during rotation breaks all in-flight verifications; (3) delivery forensics: debugging a failing endpoint was guesswork; (4) dead endpoints accumulate retry backpressure.
Why (front UI + catalog): the CRUD API was live but operator access required raw HTTP calls. The public /developers/events catalog solves integration onboarding friction: customers need to know what events fire, what their payloads look like, and how to verify signatures.
- SSRF guard: DNS-resolves the URL at create/update AND at delivery time. Re-resolving at delivery closes the DNS-rebinding window (register a public domain → TTL-0 rebind to
169.254.169.254by delivery time). Checks: loopback, RFC1918, link-local, ULA, CGNAT, cloud-metadata hosts (169.254.169.254, metadata.google.internal, 100.100.100.200, etc.). - Dual-secret rotation (
POST /settings/webhooks/:id/rotate-secret): stores new secret alongside old, setsrotatedAt = now(). During grace period (WEBHOOK_SECRET_GRACE_HOURS, default 24), worker signs with both and emitst=<ts>,v1=<hex_old>,v1=<hex_new>in a single header. Consumers accept if anyv1=verifies. - Per-attempt delivery timeline:
webhook_delivery_attempttable:attemptNumber,requestHeaders,requestBody,responseStatus,responseHeaders,responseBody(capped atWEBHOOK_RESPONSE_CAPTURE_BYTES, default 4096 bytes: prevents large HTML error pages from filling the table),durationMs,error,attemptedAt. - Auto-disable failing endpoints:
consecutiveFailures >= WEBHOOK_AUTO_DISABLE_MIN_FAILURES(default 2) AND first failure was more thanWEBHOOK_AUTO_DISABLE_AFTER_DAYS(default 5) days ago:statusflips toauto_disabled. Time-based + count-based: count-only would auto-disable within minutes of a brief server restart: too aggressive. - Wildcard subscriptions:
eventTypesaccepts"*","<group>.*", or exact names. Internal events (webhook.test,webhook.endpoint.*,webhook.delivery.*) are never in the expandable set, no infinite-fanout loop. - Test event (
POST /settings/webhooks/:id/test): bypasses the fanout subscriber (direct delivery-row insertion). Also auto-inserted on endpoint creation (immediate reachability feedback). - 4 new internal events (
webhook.test,webhook.endpoint.secret_rotated,webhook.endpoint.disabled,webhook.delivery.exhausted):operationalretention, non-subscribable, never fanout. Catalog after C.5: 52 total events, 48 subscribable, 4 internal. - Public developer catalog (
/developers/events, no auth): 48 subscribable events with group, retention label, description, expandable JSON schema (from Zod 4 nativez.toJSONSchema({ unrepresentable: "any" })). Node.js signature-verification snippet (cross-checked againsthmac-signer.ts).
- SSRF guard at both create and delivery time (anti-rebinding): validating only at create allows DNS-rebinding: register a safe IP, rebind to
169.254.169.254by delivery time via TTL-0. One extra DNS lookup per delivery attempt is the accepted cost. - Multiple
v1=values for secret rotation (Stripe-compatible): simpler than ax-webhook-signature-oldheader. Receivers already split on,then=; iterating pairs and accepting on first match requires ~3 extra lines. webhook.testandwebhook.endpoint.*as internal events (non-subscribable, non-fanout): the test delivery must travel through the delivery worker for realistic integration testing, but must never fan out to other endpoints' subscriptions.- Zod 4 native
z.toJSONSchemafor the public catalog: Zod 4 ships this natively, no external dependency, no walker, no drift. - Separate
EventTypePickerandEventTypesTablecomponents (shared SSOT): the SSOT isSUBSCRIBABLE_EVENT_TYPES+descriptionFor+jsonSchemaForEventin@packages/events. The two components are separate because their contracts are incompatible (interactive checkboxes + RHF integration vs. static reference table). Both already depend on the shared SSOT, so description and schema changes propagate to both automatically. - Response body capped at
WEBHOOK_RESPONSE_CAPTURE_BYTES(default 4096): 4096 bytes is enough to see the error type and message for every real-world error format. Configurable for operators who need more context. - Auto-disable threshold is time-based + count-based: count-only auto-disables within minutes of a brief server restart: too aggressive. Time-based component (
WEBHOOK_AUTO_DISABLE_AFTER_DAYS) distinguishes "transient outage" from "dead endpoint".
Why: NIST 800-63B-4 §5.1.9 (look-up secrets) mandates that backup codes be stored so the verifier can look up the exact value. BetterAuth encrypts the code set (XChaCha20-Poly1305, symmetric key derived from BETTER_AUTH_SECRET), satisfying this posture. The feature completes the 2FA story: users had TOTP enable/disable but no UI to manage recovery codes or use one as a fallback.
RecoveryCodesCardon/settings/account: regenerate-only (no re-view of existing codes, matching GitHub model). Password gate on regeneration. Codes pre-formattedxxxxx-xxxxxby BetterAuth;BackupCodeListrenders them as-is.- Backup-code fallback on
/two-factor: "Use a recovery code instead" toggle. Input normalization: whitespace stripped; if result is exactly 10 alphanumeric characters, canonical dash is auto-inserted (xxxxx-xxxxx); otherwise passes through as-is. BetterAuth'sverifyBackupCodedoes an exactincludes()comparison on storedxxxxx-xxxxxcodes, so the canonical dashed form must reach the API. - 2 new compliance events:
user.mfa.backup_codes_regeneratedanduser.mfa.backup_code_used(retentioncompliance). Emission placement trap:/two-factor/verify-backup-codeis a login endpoint;ctx.context.sessionis null. The actor is read fromctx.context.newSession(set by BetterAuth after a successful verify), placed before theuserIdearly-return guard. Unit tests do not mount BetterAuth hooks; only the runtime QA catches this trap. BackupCodeUsedNotifier: firstonEventhandler in the project. Sends templatebackup_code_usedvia email; in dev withoutRESEND_API_KEYlogs the variables. SOTA ref: Bitwarden and NetSuite both send on-use email for backup code consumption (breach signal).formatBackupCoderemoved: BetterAuth'sgenerateBackupCodesFnalready returns codes pre-formatted asxxxxx-xxxxx; a client-side re-formatter produced double-dashes (abcde-fghij→abcde--fghij).
- Regenerate-only (no re-view): showing codes again widens the exposure window. GitHub model: show once at generation, force re-generate to see new ones.
- Encrypted-not-hashed (BetterAuth default kept): NIST 800-63B-4 §5.1.9 requires "the verifier can look up the exact value." Hashing would require verifying each hash individually. BetterAuth's symmetric encryption (XChaCha20-Poly1305) satisfies the look-up requirement. No
storeBackupCodesoverride was applied. - No remaining-count UI: showing "8 of 10 codes remaining" encourages users to think of codes as a finite budget and delay regeneration. If a consumer needs it,
backup_code_usedevents in the audit log provide the count. - Input tolerance: whitespace stripped, dash auto-inserted on 10-char input: server comparison is exact, so the canonical dashed form must reach the API. The schema transform handles three input shapes: code pasted with dash (pass-through), code typed without dash (dash re-inserted on exact 10-alphanum match), anything else (passed as-is, fails server-side if wrong).
Why: M3 hub consolidating the privacy/compliance surfaces that were scattered across /settings/account and missing. Refactor-only: composes A.2 (policy acceptance), A.3 (sub-processors), A.4 (consent), RGPD core (export + deletion), and security (sessions). 0 new domain events, 0 back-end changes, 0 migrations.
- Page composition:
<PolicyAcceptanceCard />(acceptance status + up-to-date badge) +<ConsentSettings />(A.4) +<DataSourcesCard />(static list of activeSUB_PROCESSORS) +<DataExportCard />(rgpd, relocated) +<SessionsCard />(security, relocated from account). <RgpdDeletionCard />relocated to bottom ofaccount.page.tsx(contextual danger zone). Deviation from the ROADMAP spec which placed it on the Privacy page.- Danger tab dissolved:
org-danger-card.tsx+transfer-leave-dialog.tsxmoved tofeatures/organization/components/, rendered at bottom oforganization.page.tsx. sub-processors.config.tspromoted toapps/app/src/shared/:features/legal/andfeatures/privacy/are both route-owning features and cannot import from each other (import-direction rule); the 2-consumer config belongs inshared/.
- GitHub-style contextual danger zones: no separate Danger tab. Delete-account at the bottom of the Account page; org leave/delete at the bottom of the Organization page. SOTA convention (GitHub/Linear/Vercel).
PolicyAcceptanceCard= status only: dated/IP acceptance history deferred:policy_acceptancehas the data but exposing it front-side would require a new back-end route; avoided under "refactor-only".- Direct RGPD download link dropped: the presigned R2 export key is discarded after the email is sent. A direct link would require a new user column + route + on-demand presign.
DataSourcesCard= static list of activeSUB_PROCESSORS: no per-user "last-sync timestamp": it is config, not per-user sync state.sub-processors.config.tspromoted toshared/: two route-owning features cannot import from each other → belongs inshared/.- ROADMAP deviation acknowledged: the ROADMAP placed
<RgpdDeletionCard />in Privacy; it shipped in Account (danger zone). Contextual danger zones are the SOTA convention: the deviation is intentional.
Why: IEmailService.sendTemplate was a direct Resend HTTP call: 1 email = 1 HTTP request. Any fan-out (RGPD wipe notifying N members, D.3 digests) serialized into 429s against the 10 req/s per team ceiling. The fix mirrors what the repo already does for webhooks: a durable queue table + a background worker that batches and retries, decoupled from request paths.
email_messagetable: durable outbox for all outgoing emails. Columns: UUID v7 PK,to_address,kind,template,subject,html_body/text_body(rendered at enqueue),variablesjsonb,idempotency_key,status(pending/claimed/sent/failed),claimed_until,attempts,provider_message_id,last_error.QueuedEmailService: binds toIEmailService. Enqueues rows inside the caller's transaction whenoptions.txis passed (atomicity with the write that triggered the email). Renders React Email templates at enqueue time via@packages/emails.@packages/emails: one React Email component +subject()per template key.TEMPLATE_IDSin the worker is now an override map: empty string → render in-repo template; non-empty → use Resend dashboard ID. A fresh clone sends real emails with zero dashboard setup.EmailDeliveryWorker: polls every 2 s, claims up to 300 rows (FOR UPDATE SKIP LOCKED, 120 s claim window), groups by(kind, template), chunks to 100 perresend.batch.sendcall.batchValidation: "permissive"isolates bad entries intoerrors[]without aborting the chunk. Decorrelated jitter retry viashared/jitter.ts. After the ceiling the row moves tostatus = 'failed'andemail.delivery.exhaustedis emitted.- Retention sweep:
POST /internal/sweep-email-messages(EMAIL_MESSAGE_RETENTION_DAYS=7). Purges onlystatus = 'sent';failedrows are kept deliberately as the operator's only trace of a dropped email. - RGPD wipe sweep refactor:
sendTemplateBatchreplaces up to 50 serialsendTemplatecalls, removing per-member latency fan-out in the wipe path.
email_messagetable, not the outbox: the outbox dispatcher's built-in subscribers run inside the dispatch transaction (withidle_in_transaction_session_timeout = '30s'), so an HTTP call there holds a Postgres TX open across network I/O. User handlers run post-commit with no retry. Neither slot can hold a retryable external call.email_messageis the same pattern aswebhook_delivery, for the same reason.batchValidation: "permissive": the original spec proposed replaying the entire rejected chunk one recipient at a time."permissive"is strictly better: the provider isolates invalid entries intoerrors[]and still sends the valid ones: 1 request, no replay. Invalid recipients are markedfailedindividually and kept for operator review.- No
retry-afterparsing: the Resend SDK returns{ data, error }and does not expose response headers. Decorrelated jitter provides the backoff substitute. - Polling at 2 s, not
LISTEN/NOTIFY: a persistentpg.Clientlistener costs a dedicated connection plus reconnect handling. The gain is at most 2 s of latency reduction on the first delivery attempt: acceptable for email.LISTEN/NOTIFYwould be the right call if latency SLA were < 1 s. provider_message_idattached only whendata.length === chunk.length: the Resend batch API does not document positional alignment when some entries fail. Attachingids[i]to rowiwhen array lengths differ would silently associate wrong IDs. Correctness never depends onprovider_message_id(debugging only), so conservatism wins.@react-email/componentsdeprecated mid-task: React Email 6.0 unified everything into thereact-emailpackage. Migrated toreact-email@6.9.1.- Bounce suppression is provider-side: Resend's domain-scoped suppression list automatically blocks future sends to hard-bounced addresses. No local
email_suppressiontable needed: build it only when a product feature needs suppression-list reads.
TEMPLATE_IDSas override, not mandatory: the ROADMAP framed template IDs as required config (boot fails if empty). Post-D.5, an empty string means render the in-repo template. Boot no longer fails on emptyTEMPLATE_IDS.@packages/emailsintroduced: the original ROADMAP spec did not include an in-repo template package. Added to remove the "requires Resend dashboard setup before first email" friction on a fresh clone.sendRaw/sendRawBatchonIEmailService: not in the original spec. Added to support future transactional emails needing full HTML control without a named template. No call sites exist today; public port surface.
The sweep routes and the cron client had no explicit timeout (internalLayers set none, Bun.serve had no idleTimeout, signedInternalFetch issued a bare fetch), which doubling the sweep passes made more likely to matter. Closed as follows.
- The internal sweep rail is bounded in time and against itself. Three nested deadlines (
SWEEP_DEADLINE_MS90 s <SERVER_IDLE_TIMEOUT_SECONDS120 s <INTERNAL_FETCH_TIMEOUT_MS150 s), with env parsing refusing a configuration that inverts them, plus a time-boxed lease insweep_lockthat makes each label single-flight. Timeouts alone were never enough: bounding a run does not stop the next tick from starting a second one, and two concurrent sweeps just split theSKIP LOCKEDrows at double the pool cost. The lease expires on its own, so a crashed run does not wedge the label the way a pooled advisory lock would. It is fenced by an owner token, not just the label, because the deadline is only checked between batches: a run can outlive its own TTL, and without the token its laterelease()could steal back a lease a legitimate successor had already acquired. - Two live bugs found by measuring:
Bun.servedefaultsidleTimeoutto 10 s and applies it while the handler runs, so every sweep past 10 s had its socket closed under it while the work continued server-side, andGET /notifications/stream, whose heartbeat is 25 s, was being dropped and reconnected every 10 s in production. A third was found by reading:sweep-notificationswas mounted but missing from the bundled cron, so read notifications were never purged. - Truncation is reported, not hidden. A sweep out of budget answers
truncated: true(derived per pass from astopReasonsmap:exhausted,budget,batch-cap,batch-error) and resumes next tick.stopReasonskeeps a recurringbatch-errorfrom hiding behind that, since one is a healthy backlog and the other is data that will fail identically forever, and the bundled cron exits non-zero on it. rgpd-sweepgets the budget between account wipes, never inside one: a half-erased account is the one Art. 17 outcome that must not happen. It deliberately has no lease and noSKIP LOCKEDon selection, so two overlapping runs can select the same account. The anonymizing update is guarded bydeletedAt IS NULL, which makes the wipe itself idempotent and lets the losing run short-circuit before emittingUSER_DELETEDagain. Itsprocessedfield now reports accounts actually attempted rather than the batch size.
Why: every paid SaaS needs staff-level tooling to debug a paying user's issue without asking for their credentials, and a one-click ban path for abuse without DB surgery. BetterAuth ships an official admin plugin covering these primitives, no rolling our own. The phase is infra (no DDD), gated by the platform role enforced since C.2.
What was already in place before this phase: the admin plugin wired in auth.ts, the Drizzle schema ban columns and platform role column on user, the requirePlatformAdmin gate, and the _admin layout zone in the front. The phase delivered the rest.
What this phase delivered:
- Read-only user and org back-office:
GET /admin/users,GET /admin/users/:id,GET /admin/orgs,GET /admin/orgs/:id(paginated, search-filtered), backed byDrizzleAdminUserStore,DrizzleAdminOrgStore,AdminQueryService. Front:features/admin-users/+features/admin-orgs/. - Five audited admin actions (
AdminActionService): ban (with reason + optional expiry), unban, platform role change, force password reset (sends reset email via BetterAuth), revoke all sessions: each emits anadmin.*event viaemitEvent. - Justified impersonation:
POST /admin/impersonation/:id/startrequiresreason(min 1 char) and accepts optionalticketRef.POST /admin/impersonation/stopvalidates the BetterAuth response before emitting the audit event (no false audit trail on BetterAuth failure). - Server-side blocklist: two layers: a BetterAuth
beforeHookinauth.ts(impersonation-blocklist.ts) blocking the most sensitive auth endpoints, and per-mutationdenyImpersonatedmiddleware on 11 business mutation routes. Six read-only routes left open. Exit routePOST /admin/impersonation/stopalways preserved. - Non-dismissable impersonation banner:
ImpersonationBannercomponent mounted in_shell, live countdown from session expiry (setInterval, recalculated every second),variant="banner"added to the Alert primitive in@packages/ui. - Transparency email:
NotifyImpersonatedUserHandler(onEvent(ADMIN_IMPERSONATION_STARTED)) sends the impersonated user a "someone accessed your account" email. Failure captured to telemetry, does not abort impersonation. - Session payload extended:
buildSessionPayload(auth-session-payload.ts) enriches BetterAuth's own session response withimpersonatedBy(normalized fromundefinedtonull) andisPlatformAdmin. The front reads these fields from the existing session query; no dedicated admin session endpoint exists. - Event catalog 55 → 62: seven new
admin.*events added to@packages/events, allcomplianceretention.
Structural decision: AdminActionService outside inwire:
AdminActionService is instantiated in routes.ts rather than registered in the inwire container. The reason is a real import cycle (service → auth.ts → container.ts → module.ts) that cannot be resolved without touching auth.ts, which is protected by its own module boundary. Its dependencies (IOutboxRepository, IInstrumentation) are still resolved from di. The same pattern applies to the impersonation routes. Validated and accepted in review.
Decisions:
- Write-enabled impersonation with server-side blocklist: read-only impersonation cannot reproduce write-path bugs. The blocklist specifically targets irreversible or identity-mutating operations: change-email, change-password, MFA enable/disable, link/unlink social account, revoke-sessions, billing portal. Read operations and the exit route stay open.
- Mandatory justification:
reasonis a required field onPOST /admin/impersonation/:id/start. No reason = 400 before any impersonation session is issued. OptionalticketReflinks to a support ticket. - Transparency email: the impersonated user is notified at impersonation start. Failure is captured to telemetry and logged but does not block the action.
supportUrlis derived fromAPP_URL. - No
supportplatform role: the original plan mentioned a read-onlysupportrole. BetterAuth'sadminplugin supports a single platform role column. Adding asupportrole would require custom middleware with zero library support. Deferred out of scope. cookieCache.maxAgereduced to 60 s: makes a ban effective within approximately 60 s without adding a session revocation list lookup to every request. Updated inapps/api/CLAUDE.md. Near-instant ban (< 1 s) would require a revocation list checked in the session middleware: out of scope.- Actions through custom API routes, not the BetterAuth admin client: wrapping
auth.api.*in custom Hono routes lets the implementation injectactorUserIdinto every event, enforcereasonon impersonation, and validate BetterAuth responses before emitting the audit event. Two API deviations found during implementation:auth.api.forgetPassworddoes not exist: the correct method isauth.api.requestPasswordReset;auth.api.setRolerequires{ requireHeaders: true }and aheadersargument fromc.req.raw.headers. /stopvalidates BetterAuth before emitting: if BetterAuth'sstopImpersonatingreturns an error, theADMIN_IMPERSONATION_STOPPEDevent is not emitted. This prevents a false audit trail entry when the stop actually failed.
Out of scope (explicit):
- Dedicated admin subdomain (
admin.<APP_DOMAIN>). - Platform
supportrole (read-only). - Org mutations (member kick, org delete) from the admin back-office.
- IP allowlist for admin access.
- Session-length alerting.
-
APP_URLpromoted to required: Five call sites inauth.ts(auth email links) and the impersonation notification already assumedAPP_URLwas present and producedundefined/...links when it was absent. The optional declaration was the anomaly. Changed fromz.url().optional()toz.url()inshared/env.ts. The task-15 deferred concern aboutsupportUrlbeing inoperable is closed by this change. -
MFA gate on the front-side back-office entry: The server already required MFA (
PLATFORM_ADMIN_REQUIRE_MFA), but the front-side gate checked only the platform role.canAccessPlatformAdmin(shared/auth/can-access-platform-admin.ts) was extracted to combineisPlatformAdmin(role) withtwoFactorEnabled(MFA). A platform admin without 2FA sees neither the "Admin" nav entry nor the_adminroute zone:ensure-platform-admin.tsredirects them to/settings/accountrather than/, so the required action is explicit.isPlatformAdminis kept as a separate predicate for call sites that only need the role check (e.g. event payloads). -
Policy acceptance blocked during impersonation:
denyImpersonatedwas added toPOST /me/policies/accept(defense-in-depth: an admin in impersonation session must not countersign legal terms on behalf of the user, which would write a legally false acceptance). The Decision 1 enumeration above used the collective term "the blocklist" for both layers; this item falls under thedenyImpersonatedHono layer, not the BetterAuth hook. Mirrored on the front:shouldRedirectToLegalAccept(router/should-redirect-to-legal-accept.ts) returnsfalsewhenisImpersonating(session)is true, so the legal acceptance gate does not trap the admin on a page whose only exit is to consent. -
Nav entry renamed "Operator" → "Admin", re-pointed to
/admin/users: The shell nav entry linked to/admin/audit-logunder the label "Operator". Post-QA it links to/admin/users(the accounts list) under the label "Admin", consistent with the product's English convention and with the primary use-case (looking up a specific user before impersonating). Thepathname.startsWith("/admin")active-state check already covered all sub-pages. The term "operator" remains accurate in conceptual contexts (this log, the C.2 section header) where it describes the human role, not a UI label. -
Admin UI translated to English: The admin features were initially written in French, inconsistent with the rest of the product. All UI copy (column headers, button labels, form placeholders, error messages, date locales) was translated to English (commit
27ccbff).
Why: single biggest enterprise-tier price multiplier ($10-30k/deal, recurring): every B2B SaaS targeting >500-employee customers is blocked at procurement without SSO + SCIM. @better-auth/sso (OIDC + SAML 2.0) and @better-auth/scim (RFC 7644) are official BetterAuth plugins, no rolling our own protocol handling.
What this phase delivered: sso() + scim() mounted in auth.ts on their own schema file (packages/drizzle/src/schema/sso.ts); per-org OIDC/SAML provider registration + domain verification + a SCIM bearer-token per provider; JIT provisioning landing a member row on first SSO sign-in; domain-based SSO enforcement rejecting non-SSO sign-in on all four email/passkey paths; a full SCIM Users lifecycle; /settings/sso (provider forms, domain verification card, SCIM connection card, enforcement toggle); a "Sign in with SSO" entry on /sign-in; 13 new events; a local Keycloak profile + round-trip doc for development. Catalog 67 → 80 (28 → 34 public, 39 → 46 internal).
Decisions and deviations from the plan (chronological across the phase's tasks):
- Business-tier gate keyed on the request's target org, not session history:
providersLimit(the SSO plugin's own anti-abuse ceiling) only ever receivesuser, never the org the registration targets, so it cannot enforce thebusiness-tierssoentitlement. The gate lives instead inhooks.beforeon/sso/register, readingbody.organizationIddirectly: the org actually named by the request, never inferred fromsession.activeOrganizationId(a stale or switched active-org would otherwise gate the wrong org). Provider registration also checked against impersonation (denyImpersonated-equivalent at the hook level). - SAML hardened beyond the plugin's defaults: every SAML registration forces
signatureAlgorithm: "sha256"andwantAssertionsSigned: trueserver-side; the client never gets to choose a weaker option. The reject list covers the wholesha1/md5family (not just the bare algorithm string:rsa-sha1,hmac-sha1, etc. all match), closing a bypass the first pass missed. - Domain casing normalized on both read and write paths:
sso_provider.domainis persisted verbatim by the plugin (no casing normalization on its side), and every domain-based lookup used for enforcement (enforcedProviderForDomain) has to compare case-insensitively orAcme.comandacme.comsilently diverge into two providers. Fixed on the write path (register and update-provider: the second one was a follow-up fix after the first pass missed the update path's own domain-change branch) and the read path, usingeq(sql\lower(${column})`, domain)rather thanilike(iliketreats its argument as a pattern, not a literal: a domain containing_or%` would match unintended rows). - SSO enforcement covers all four sign-in surfaces, not just the password form:
/sign-in/email,/sign-up/email,/sign-in/magic-linkare checked inhooks.before(isSsoEnforcedForagainst the request's email, before the rate-limit branch, whichreturns for/sign-in/email, so anything placed after it would never run). Passkey sign-in carries no email, so it can't be checked path-based; it's closed separately indatabaseHooks.session.create.before(note, superseded: this leg was originally keyed off whether the session's linked account already belongs to a registered SSO provider: that is exactly the defect fix round 2 item 2 below removes, since an SSO account is permanent and would wave through every later passkey sign-in. It now keys on the request's endpoint path instead). All four return403 { message: "SSO_REQUIRED", providerId }: confirmed live thatproviderIdsurvives JSON serialization, so the front never re-derives the provider from the email. - No way to turn enforcement ON was in the original plan: the plan shipped
ssoEnforcedas a read/display flag with no mutation endpoint. AddedPOST /settings/organization/sso-enforcement(organization:["update"]), and separately extended it so a platform admin can also flip it on an org's behalf (admin.orgsback-office), since support needs to unblock an org that locked itself out. - D3, no
sso: [...]access-control statement exists. The original plan called for a dedicatedsso: ["configure", "enforce"]statement in@packages/access-control. It was never built; the existingorganization:["update"]permission already covers/settings/ssoand the enforcement toggle, and adding a parallel statement for the same actors (owner) would have been a distinction without a difference.docs/FEATURES.mdandROADMAP.mdare corrected to stop describing it as built. - D9: SCIM
DELETEis an org departure, not a grace-period deletion. The original plan's step 3 described deprovisioning as reusing the RGPD grace path (pendingDeletionUntil+ revoke sessions). What's actually implemented:DELETE /scim/v2/Users/<id>removes only thememberrow for the owning org; the globaluserrow (and any other org's membership) survives untouched. Verified live in this task:select * from memberreturns zero rows for the deprovisioned user in the target org whileselect * from "user"still returns the row.ROADMAP.mdanddocs/FEATURES.mdare corrected. - Every SCIM/SSO event payload carries
actorUserId, including SCIM's bearer-token-only requests: SCIM endpoints authenticate with a token, not a session (ctx.context.sessionis empty), so the actor forscim.*events is resolved from the connection row (the user who generated the token) rather than from session state (note, superseded: this originally read the row withscimConnectionOwner, which trusts the provider id claimed by the bearer header without ever checking the token: see the post-PR review round below, where it becomesverifiedScimConnectionOwner). Pre-delete snapshots (ssoProviderDeleteSnapshots,scimConnectionDeleteSnapshots, keyed onproviderId) supplyorganizationIdfor the*.deleted/*.connection.deletedevents, since the row is already gone by the timehooks.afterruns (note, superseded: these were bare globalMaps until the post-PR round replaced all three withRequestSnapshots<T>). SSO_ENFORCEMENT_CHANGEDis the one event of the 13 not routed through a plugin endpoint: it's emitted byAdminActionService.setSsoEnforcement, not ahooks.afterpath branch, because the enforcement toggle (deviation 5 above) is a bespoke route, not part of either plugin.- R11: the local Keycloak Docker profile moved from this task (originally its own step) into Task 1. Task 6's live-IdP verification and this task's Step 4 round-trip both need a running Keycloak; rather than have Task 6 stub it or reorder the plan, the
ssoopt-in profile indocker-compose.yamlshipped with Task 1 and stayed untouched here. - Two
createSessioncall sites outside the enforcement hook's reach are a known, accepted gap: flagged and accepted rather than fixed in this phase; they don't participate in the four enforced sign-in paths and are out of scope for SSO enforcement specifically, but are noted here so a future security pass doesn't have to rediscover them.
Local development: docs/SSO-LOCAL.md documents the full Keycloak setup (realm, OIDC client, SAML client) and two traps discovered only by actually running the round-trip: (a) @better-auth/sso's samlConfig.issuer field doubles as the SP's own entity ID used both to sign the outgoing AuthnRequest and to validate it against the IdP's registered client: the IdP's real issuer has to go in samlConfig.idpMetadata.entityID instead, or every assertion fails ERR_UNMATCH_ISSUER; (b) the SSO plugin validates an OIDC discovery URL's origin against the auth server's own trustedOrigins (CORS_ORIGIN): a local, non-TLS Keycloak has to be added to that allowlist for local testing only, never in a deployed environment.
Verification performed for this task (Step 4), and what was not exercised live:
- OIDC round-trip: full browser-equivalent flow against a local Keycloak realm:
/sign-in/sso→ authorization URL → Keycloak login →/sso/callback/<providerId>→ session cookie issued,memberrow created in the target org (JIT provisioning,role: "member"). Confirmed live. - SAML round-trip: same, through the SP-initiated
AuthnRequest→ Keycloak SAML login →POSTto/sso/saml2/sp/acs/<providerId>→ session issued,memberrow created. Confirmed live, after working through the two traps above. - SCIM lifecycle:
POST(create) →GET→PATCH(deactivate,active: false) →PUT(update, non-active fields) →DELETE(deprovision) all exercised against/scim/v2/Users, plusPOST /scim/generate-tokenandPOST /scim/delete-provider-connection. Confirmed live, including the D9 org-departure behavior (member row gone, user row intact). - All four enforced sign-in paths:
/sign-in/email,/sign-up/email, and/sign-in/magic-linkwere exercised live against an SSO-enforced domain and each returned403 { message: "SSO_REQUIRED", providerId }. The passkey leg was verified live too, through the shared hook rather than a WebAuthn ceremony:databaseHooks.session.create.beforeis the single hook every session-creation path funnels through (the three above never reach it, since they're already blocked earlier), so triggering it via/magic-link/verifyfor a user who was sent a link before enforcement turned on (then verified after) exercises the identical hook and the identical better-call serialization pipeline the passkey path uses. It returned the raw, unwrapped body{"message":"SSO_REQUIRED","providerId":"keycloak-test-com"}, confirmingproviderIdreaches the client on that leg too (see the R30 fix below: it didn't, until this round). A real WebAuthn ceremony remains out of reach in this curl-only environment. - Event catalog: 12 of the 13
sso.*/scim.*event types were confirmed live viaoutbox_eventrows.sso.domain.verified(1 of 13) was not exercised live: the real endpoint (/sso/verify-domain) performs a DNS TXT lookup, which cannot succeed against a domain this environment doesn't control;domainVerifiedwas set directly in the database to unblock the OIDC/SAML round-trips instead. The event's emit site was confirmed present by code inspection (auth.ts,hooks.afteronSSO_PATHS.verifyDomain) but not exercised end-to-end.
- R30: the passkey leg's
SSO_REQUIREDrejection was missingproviderId.databaseHooks.session.create.beforehad already resolvedenforced.unwrap()before throwing, but the throw itself only setmessage, unlike the three email-bearing paths inhooks.before. A client forced to special-case one path out of four to recover the provider was the actual defect: fixed by adding the field, not by documenting the inconsistency. Verified live via the/magic-link/verifyroute (see above), since it shares the exact hook and the exact serialization path the passkey leg uses. - R31: the SSO-redirect-on-
SSO_REQUIREDbehavior only existed onuse-sign-in.ts.use-sign-up.tsanduse-magic-link.tssurfaced the raw"SSO_REQUIRED"string as an error toast: a dead end for two of the three server-enforced entry points a real user can hit before ever reaching a password field. The redirect logic (callauthClient.signIn.sso({ providerId, callbackURL }), throw a sentinel soonErrorskips the toast) was extracted intoredirectToSsoIfRequiredinapps/app/src/features/auth/auth-error.ts(the file already shared by all three hooks forresolveAuthError) and all three call sites now use it. Unit-tested for the branch that never touches the network (no rejection, or a rejection missingproviderId); the network-calling branch is integration-verified live the same way as the rest of Step 4. - R32: the "dark › sso settings" a11y failure was a genuine, reproducible flake, not a rendering bug, and not a phantom. Root cause: every authenticated-page a11y test shares one seeded identity (
auth.setup.ts), and the API's global rate limit is keyed per-user, not per browser context (GLOBAL_POLICY, 60 req/min), so parallel Playwright workers loading pages as the same identity turn into one shared request bucket./settings/ssofires the most queries per load of any audited page (providers, domain verification token, SCIM connections, entitlements), making it the first to trip that ceiling. The failure's actual page snapshot showed a "Too many requests" toast and the app's global error boundary, confirming it, not a dark-theme defect. Reproduced against a genuinely fresh build (the a11y suite's real failure mode discovered along the way:pnpm --filter app check:a11yrun directly serves a stalevite previewbuild, since onlyturbo run check:a11yhonors the pipeline'sdependsOn: ["build"]: the README already warned about this and it was missed once here), cleared entirely at--workers=1, and made deterministic by pinningworkers: 4ina11y/playwright.config.tsinstead of leaving Playwright's host-core-count-dependent default in place. Confirmed clean across four consecutive fresh-build runs after the fix. - [Minor] The keyboard tab-order test never reached the new SSO control.
apps/app/a11y/interaction.spec.tscovered Email → Password → Remember me → Sign in only. A new test reaches the "Sign in with SSO" trigger, activates it with Space, and asserts the very next Tab stop is the field it revealed: the exact case a rule engine (axe) cannot check on its own. - R33: two more "what ships" docs still described C.7 as unbuilt.
docs/OVERVIEW.md(## What's next) anddocs/MODULES.md(Roadmap modules table + subtotal) both still framed SSO/SCIM as the remaining feature. Both are named in the rootCLAUDE.mdinventory, so a reader following either would be misled about the phase this task closes.OVERVIEW.mdgets its own shipped-feature section (matching every other shipped capability's treatment);MODULES.md's C.7 row moves from the Roadmap table to the Shipped table, with both subtotals recomputed (total unchanged: €51 900 to €86 800, only redistributed).
Note on the "pre-existing palette flake" recorded during fix round 1: it was neither pre-existing nor a separate defect. interaction.spec.ts's "traps focus inside the command palette" fails because /dashboard bounces to /sign-in when its session request is rate-limited, and Ctrl+K then has no palette to open. Same root cause as R32, wrongly diagnosed there: see fix round 2 item 4 below.
- R35: SCIM provisioning bypassed the seat cap and emitted no membership event.
@better-auth/scimwrites thememberrow with a rawctx.context.adapter.create({ model: "member" }), so neitherbeforeAddMembernorbeforeAcceptInvitation(the two authoritative seat gates) norafterAddMemberever fires onPOST /scim/v2/Users. Combined with/scim/generate-tokencarrying no tier gate at all (only/sso/registerhad one), a Free-tier owner could mint a SCIM token and provision pastmaxMemberswith no billing enforcement, no audit row and no webhook delivery. Three fixes, all inhooks.before/hooks.after:/scim/generate-tokennow goes throughassertSsoEntitlementFor: the same helper/sso/registeruses, extracted on this second occurrence rather than duplicated;POST /scim/v2/Userscounts members against the plan cap before the endpoint runs (an after-hook cannot cap anything: the row is already written); andorg.member.joinedis emitted from the SCIM after-hook with the provisioned user as subject and the connection owner as actor. The removal half of the finding was wrong and was not implemented as briefed:@better-auth/scimdoes callafterRemoveMemberafter its own delete transaction (verified in@better-auth/scim@1.6.29dist), soorg.member.removedwas already being emitted: adding a second emit would have produced duplicate audit rows and duplicate webhook deliveries. What was actually broken there is the actor: the organization plugin passes the removed user asuseron every path, so the event named the deprovisioned user as its own actor. Fixed with ahooks.beforesnapshot (scimDeprovisionActors, same read-before-write pattern as the existing provider snapshots) thatafterRemoveMemberprefers over the plugin's argument. - R34: the passkey enforcement leg was disabled for exactly the users it protects, and the proposed fix was rejected. The guard skipped enforcement whenever
findLatestLinkedAccountreturned an account belonging to a registered SSO provider. Since an SSO sign-in creates such an account permanently, a user who had signed in through the IdP once was waved through on every later path (passkey included) which is the deprovisioning guarantee SSO enforcement is sold on. The review proposed widening the lookup to "does ANY account match a registeredsso_provider"; that is strictly more permissive and leaves the scenario failing identically, so it was not implemented. The discriminator has to be the request, never the user: BetterAuth passes the endpoint context as the second argument ofdatabaseHooks.session.create.before(createWithHooks→getCurrentAuthContext()), and itspathis the endpoint's own registered path, so the four SSO callback paths are identifiable and everything else is enforced./admin/impersonate-useris exempt for a different reason: that session is minted for a platform admin who already passed the admin gate, not by the enforced user authenticating. Proven live by A/B: for a user who already owns an SSO account on an enforced domain, the pre-fix build returned302(session issued) on a non-SSO session-creation path and the post-fix build returns403 {"message":"SSO_REQUIRED","providerId":"keycloak-test-com"}. Thatcontext.pathis populated and carries the endpoint's registered form was confirmed live too (a probe read/sign-in/emailout of the hook); the four exempt strings were matched against the plugin's owncreateAuthEndpointregistrations. - The passkey leg's front-end redirect existed only in the docs.
redirectToSsoIfRequiredwas wired intouse-sign-in,use-sign-upanduse-magic-linkbut notuse-sign-in-passkey(rawSSO_REQUIREDtoast) noruse-passkey-autofill(silent no-op), whiledocs/MODULES.mdclaimed all four paths redirect. Wired rather than downgrading the doc: the passkey leg is server-enforced like the other three, so it gets the same treatment; the autofill leg redirects without a toast, since conditional UI must stay silent on every expected failure but an enforced domain is not one. - The
check:a11ygate was reproducibly red, and the rationale shipped with it named the wrong bucket.playwright.config.tsanda11y/README.mdboth blamed the per-user rate-limit bucket and the worker count. Measured: the bucket that overflows is the IP-keyed one (global:60:global:::1at 61 against a 60/min ceiling, twice in a row) while the per-user bucket sat at 39, becausesessionMiddlewarenulls the user for/api/auth/*, so a signed-in page's session and organization queries are counted against the IP alongside every unauthenticated page load. Worker count bounds neither: the ceiling is per minute over the whole run and the IP is the same from every worker. The real defect is the tuning: a signed-in page view fires up to 8 API calls (measured), so a 60/min burst window allowed ~7 navigations per minute before a legitimate user got a 429.GLOBAL_POLICY's minute window is now 300: the hour window (1800, the actual sustained anti-abuse ceiling) is unchanged, and every credential path keeps its own far tighter fail-closed policy. Gate green three consecutive--forceruns, 21/21. sso.login.failurewas public but undeliverable. It was emitted with noorganizationIdalthough the provider row carrying one had already been loaded one line above, andWebhookFanoutSubscriberdrops any event whoseorganizationIdis none before it even reads the visibility map, so the one signal telling a customer their IdP broke reached nobody and was absent from their audit view. One argument added at the emit; verified live (newoutbox_eventrow carries the org id where the two pre-fix rows carryNULL).
Verification for this round: live: the SCIM entitlement gate (403 SSO_ORGANIZATION_REQUIRED / SSO_PLAN_REQUIRED / falls through to the endpoint on a Business org), the SCIM seat cap (402 Seat limit reached (3). on a Free-tier org at 5 members), org.member.joined and org.member.removed emitted with the connection owner as actor and the provisioned user as subject, the enforcement A/B above, sso.login.failure carrying its org id, and three consecutive green check:a11y runs. Established by reading, not exercised live: the SSO callback's own session creation still passing the guard (no IdP round-trip was run this round: the exempt path strings were matched against the plugin's endpoint registrations and context.path was confirmed populated with that exact form), and the passkey/autofill front-end redirect (a real WebAuthn ceremony remains out of reach here, same limitation as fix round 1).
Accepted, documented gaps left by this round:
- The SCIM seat cap is a pre-endpoint count, not a transactional reservation. Two concurrent
POST /scim/v2/Userscan both pass the check and land one seat over. Same race the invitation-accept gate has, same accepted trade-off. organizationHooks.afterAddMember/afterRemoveMembermisattribute the actor on every non-SCIM path too. The organization plugin passes the added/removed user asuser, soorg.member.joined'sactorUserIdis alwaysundefined(theuser.id !== member.userIdtest can never be true) andorg.member.removednames the removed member as its own actor. This pre-dates C.7 and affects the ordinary invite/kick flows; only the SCIM path is corrected here, because only there was an actor recoverable without a new mechanism. A proper fix needs an ambient actor at the hook level and deserves its own pass.- Every
/api/auth/*request is rate-limited by IP, even for signed-in users, becausesessionMiddlewaredeliberately skips the session lookup on those paths. Two signed-in users behind one NAT therefore share a bucket for their BetterAuth traffic (session + organization queries, ~4 calls per navigation). The burst-window retune above mitigates it; keying those requests per user would cost a session lookup on the hottest endpoint of the app and was judged out of scope for a fix round.
A security-focused review of the whole branch was run after the PR was opened and blocked the merge with two Criticals. Both held, so the merge waited: the instruction standing at the time was "merge if everything is OK", and it wasn't.
- A stranded SCIM snapshot could forge the actor on an unrelated kick.
scimDeprovisionActorswas a globalMapkeyed on a bareuserId, written unconditionally inhooks.beforeand consumed only byafterRemoveMember: a hook that never fires when the SCIMDELETE404s or the target holds no member row. The stranded entry then poisoned the next removal of that user, by any admin, in any org, naming the SCIM connection owner as the actor: audit-trail forgery onorg.member.removed. Fixed by introducingRequestSnapshots<T>(apps/api/src/shared/auth/request-snapshots.ts): a keyed store with a freshness TTL bounding how long a stranded entry stays pickable, plus a consumer-suppliedaccepts()predicate, and routing all three before/after snapshot maps through it. The deprovision snapshot now carries{ actorUserId, organizationId }and its consumer requires an organization match; a non-matching entry is left in place rather than deleted, since it may belong to a request still in flight. Reproduced live before the fix and confirmed gone after. The same commit rewrote the seat-cap docblock (it claimed to reuseassertSeatAvailableForwhen it inlined the check, because that helper throwsAppErrorExceptionand SCIM needs anAPIError;seatCapFor()is now the shared predicate both callers use) and gave SCIM refusals an RFC 7644 error body (urn:ietf:params:scim:api:messages:2.0:Error), which is what Okta and Entra expect. /api/auth/send-verification-emailhad no dedicated rate-limit policy. It is a publicPOSTtaking an arbitrary email with no session: the same "make the server email a stranger" primitive asrequest-password-resetandsign-in/magic-link, both of which carry a fail-closed 3/15min policy. It only inherited the global window, which fix round 2 had just widened from 60 to 300/min for legitimate navigation traffic, so the retune quietly loosened it fivefold. Given the same policy as its siblings, mounted in the same guard block ahead of the global limiter.- Critical: SAML hardening applied at registration but not on update.
normalizeSamlConfigran only on/sso/register. An org admin could register a compliant SAML provider, thenPATCH /sso/update-providerwith{ wantAssertionsSigned: false, signatureAlgorithm: "sha1" }, andsamlifyderiveswantMessageSignedfrom that flag, so assertions stopped requiring a signature at all. The normalizer is now applied on the update path too; it was already written spread-based and partial-update-safe (identity fields pass through only when sent, the four security fields are always forced strong), so the fix is the call site plus tests for the partial-update shape. - Critical: the SCIM
hooks.beforebranches trusted an unverified bearer token. They decoded theAuthorizationheader, took theproviderIdout of it, and resolved the connection row withscimConnectionOwner, with no hash check.runBeforeHooksexecutes before an endpoint's ownuse: [sessionMiddleware]bearer auth, andproviderIdis a deterministic slug of the org's domain, so an unauthenticated caller with a guessed id got two primitives: a pre-auth cross-tenant billing oracle (a402leakingmaxMembersversus a401), and a same-org audit-forgery primitive: fix (1)'s organization-match guard had closed only the cross-org case. Fixed withverifiedScimConnectionOwnerinauth-queries.ts: it hashes the decoded token with the exact algorithm thestoreSCIMToken: "hashed"mount uses (SHA-256→ base64url, unpadded: the plugin exports no hasher, so it is reimplemented with a docblock tying it to that mount option) and constant-time-compares it against the stored value. Anyhooks.beforebranch needing the connection owner must use it; on a missing, malformed, unknown or non-matching token it returnsnulland the branch falls through to the endpoint's own401rather than acting on unverified input. The seat cap simply isn't evaluated for such a request, which is correct: it401s before any member row is written.
Merged after this round: PR #61 into dev as a merge commit, all gates green (718 api tests, 147 app tests, type-check 12/12, Biome clean, knip clean, jscpd 28 clones, a11y 21/21).
Gaps this phase never closed, carried forward: sso.domain.verified is still never exercised live (it needs DNS TXT control over a real domain); no real WebAuthn ceremony has been driven; the SSO-callback success path after the final enforcement change rests on construction plus a probe rather than a Keycloak round-trip; enforcedProviderForDomain and the auth.ts wiring have no automated coverage, since the repo has no real-DB test harness; the a11y suite stays coupled to an IP-keyed rate limit; SAML SLO is declared but unwired; and there is no operator runbook for recovering an org that locks itself out with enforcement.
Toolchain refresh: pnpm, Node, Bun, TypeScript 7, Postgres 18, file-based routing ✅ Phase G.1 · Aug 2026
Why: the version floor a boilerplate ships is a feature the cloner inherits silently. Starting point (repo baseline before this phase): Node 24.15.0, Bun 1.3.6, pnpm 11.0.9 (disagreeing with mise.toml's 10.33.2), TypeScript 6.0.3, Postgres 17-alpine. Everything below is what was actually measured landing, on 2026-08-28, not the versions the phase's own planning table guessed at ten days earlier, which had already drifted by the time the work started.
pnpm 11.24.0 (single source of truth now: mise.toml and package.json#packageManager agreed on the same string, corepack and mise no longer race), Node 24.20.0 LTS (.nvmrc moved with it, since CI resolves Node from that file), Bun 1.4.0. Bun held, no fallback was needed: the 718-test API suite and the Bun build both passed clean at 1.4.0.
Landed at 7.0.2 across all 11 workspaces that declare typescript, @packages/ddd-kit included: that inclusion was not a given going in. The native (Go) compiler surfaced two real issues:
@packages/ddd-kit'stsupbuild crashed. Its.d.tsemit went through a bundledrollup-plugin-dtsthat isn't updated for TS7's rewritten compiler-host API. The fix was not to pinddd-kitback to TS6 (which was the initial workaround, and which quietly took it off the TS7 type-check gate that every other workspace now runs): it was to stoptsupemitting declarations at all (dts: false, dropping theignoreDeprecations: "6.0"compatibility shim that went with it) and lettsc -p tsconfig.build.json --emitDeclarationOnlydo it instead.tsconfig.build.jsonalready existed, unwired, left over from the package's publishing setup. The build script now chainstsup && tsc -p tsconfig.build.json, so a singlepnpm buildstill producesdist/index.js,dist/index.cjsanddist/index.d.ts. Trap for the next person touching this config:incrementalmust stayfalseintsconfig.build.json.tsup'sclean: truewipesdist/on every build; a stale.tsbuildinfoleft behind by a priortscrun convincestscthe declarations are already up to date aftertsuphas just deleted them, so it emits nothing: silently. No error, no warning, just an absentdist/*.d.ts. Accepted trade-off: declarations are no longer aggregated into one file.tscemits one.d.tsper source module (dist/domain/entity.d.ts,dist/primitives/result.d.ts, …), re-exported fromdist/index.d.ts, instead oftsup's single flattenedindex.d.ts. Harmless today (nothing in-monorepo consumesdist/, internal packages import fromsrc/per the source-only rule) and only matters the day this package is actually published.lib.dom.d.tsgainedResponse.textStream. Hono'sClientResponsedoesn't implement it, which broke every RPC call site that fed a response intothrowApiError(res: Response). Fixed by narrowing that function's parameter to the subset ofResponseit actually reads, rather than widening Hono's type.
Undocumented breaking change discovered while recreating the dev volume: the postgres:18-alpine image refuses to start against a bind mount at /var/lib/postgresql/data: the 18+ images store data one directory up, at /var/lib/postgresql/<major>/docker, to align with pg_ctlcluster-style layouts. docker-compose.yaml's postgres service now mounts the named volume at /var/lib/postgresql (was /var/lib/postgresql/data); without this the container loops in Restarting with Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster".... Neither the task brief nor the ROADMAP entry that scoped this work anticipated this: it's a genuine image-level change, not something to have caught by reading docker-compose.yaml alone.
Action required for anyone with a pre-existing postgres_data volume: the mount-path move above is silent, not loud, for a volume populated under the old layout. docker compose up won't crash the way a brand-new empty volume did during this task: Postgres 18 will find nothing at the new /var/lib/postgresql mount point (the old data sits one level down, at the now-unmounted /var/lib/postgresql/data) and will just initialize a fresh, empty database with no error. That's a silent dev-data loss, not a visible failure. Run docker volume rm clean-stack_postgres_data once before the first up on a checkout that already ran docker compose up postgres pre-bump, then rebuild with pnpm db:push && pnpm db:seed. Documented in README.md's Database section (the Volume row + the callout above the db:* script block) as the place a reader would hit this before losing data rather than after.
Primary keys stay text: every PK in packages/drizzle/src/schema/ is filled by BetterAuth, which generates its own ids, so uuidv7() is a BetterAuth question rather than a Postgres one. It's documented in docs/MODULES.md as the shape for new cloner-owned tables instead.
The payoff of 18 here is operational (async I/O, B-tree skip scan), so the two hot paths that touch the outbox were EXPLAIN (ANALYZE, BUFFERS)'d before the bump (Postgres 17.x, docker compose exec postgres psql) and again after (Postgres 18.6-alpine, same commands, dev DB recreated from scratch per the brief since a major version doesn't read the previous major's data directory).
1. Outbox drain: mirrors apps/api/src/shared/services/drizzle-outbox.service.ts:95-108, which reads outbox_event_pending_idx (packages/drizzle/src/schema/outbox.ts:30):
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM outbox_event
WHERE dispatched_at IS NULL
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
ORDER BY occurred_at
LIMIT 100
FOR UPDATE SKIP LOCKED;Postgres 17 (0 pending rows at capture time: dev DB had already drained the seeded events):
Limit (cost=8.15..8.17 rows=1 width=582) (actual time=0.024..0.025 rows=0 loops=1)
Buffers: shared hit=4
-> LockRows (cost=8.15..8.17 rows=1 width=582) (actual time=0.023..0.024 rows=0 loops=1)
Buffers: shared hit=4
-> Sort (cost=8.15..8.16 rows=1 width=582) (actual time=0.023..0.023 rows=0 loops=1)
Sort Key: occurred_at
Sort Method: quicksort Memory: 25kB
Buffers: shared hit=4
-> Index Scan using outbox_event_pending_idx on outbox_event (cost=0.12..8.14 rows=1 width=582) (actual time=0.006..0.006 rows=0 loops=1)
Filter: ((dispatched_at IS NULL) AND ((next_attempt_at IS NULL) OR (next_attempt_at <= now())))
Buffers: shared hit=1
Planning:
Buffers: shared hit=197
Planning Time: 0.662 ms
Execution Time: 0.075 ms
Postgres 18.6 (5 pending rows: fresh seed, dispatcher not yet run):
Limit (cost=8.17..8.19 rows=1 width=290) (actual time=0.040..0.043 rows=5.00 loops=1)
Buffers: shared hit=10
-> LockRows (cost=8.17..8.19 rows=1 width=290) (actual time=0.039..0.041 rows=5.00 loops=1)
Buffers: shared hit=10
-> Sort (cost=8.17..8.18 rows=1 width=290) (actual time=0.028..0.029 rows=5.00 loops=1)
Sort Key: occurred_at
Sort Method: quicksort Memory: 27kB
Buffers: shared hit=5
-> Index Scan using outbox_event_pending_idx on outbox_event (cost=0.14..8.16 rows=1 width=290) (actual time=0.009..0.011 rows=5.00 loops=1)
Filter: ((dispatched_at IS NULL) AND ((next_attempt_at IS NULL) OR (next_attempt_at <= now())))
Index Searches: 1
Buffers: shared hit=2
Planning:
Buffers: shared hit=161
Planning Time: 0.553 ms
Execution Time: 0.079 ms
Access path unchanged: both plans drive the drain through Index Scan using outbox_event_pending_idx, wrapped in the same LockRows/Sort/Limit shape, no fallback to a sequential scan. The only textual diff is Postgres 18 printing a new Index Searches: 1 line under each index node (a genuine 18 addition to EXPLAIN's index-scan output, not a plan change) and the row counts differing because the two captures ran against different data volumes (see caveat below). This is the query that matters most for this bump: confirmed stable.
2. Notification fan-out: the INSERT … SELECT at apps/api/src/shared/services/notification-fanout-subscriber.ts:103-132, captured by instrumenting a CapturingInstrumentation that records the span name the code already builds for query.getQuery().sql (the same value the production Sentry span carries), driving it with a real org.member.invited event inside a rolled-back transaction against the seeded dev user/org, then re-running the captured SQL + params through EXPLAIN (ANALYZE, BUFFERS) in a second rolled-back transaction (pg.Pool, BEGIN/ROLLBACK bracketing, no data mutated in either DB).
Postgres 17 (org with 12 members across the accumulated 11h-old dev dataset):
Insert on notification (cost=0.59..34.02 rows=0 width=0) (actual time=0.078..0.078 rows=0 loops=1)
Conflict Resolution: NOTHING
Conflict Arbiter Indexes: notification_dedup_uidx
Tuples Inserted: 1
Conflicting Tuples: 0
Buffers: shared hit=20
-> Subquery Scan on "*SELECT*" (cost=0.59..34.02 rows=1 width=283) (actual time=0.031..0.033 rows=1 loops=1)
Buffers: shared hit=9
-> Nested Loop Left Join (cost=0.59..34.00 rows=1 width=259) (actual time=0.030..0.031 rows=1 loops=1)
Buffers: shared hit=9
-> Nested Loop Left Join (cost=0.44..25.81 rows=1 width=28) (actual time=0.018..0.020 rows=1 loops=1)
Join Filter: (up_mail.scope_id = member.user_id)
Buffers: shared hit=7
-> Nested Loop Left Join (cost=0.29..17.62 rows=1 width=27) (actual time=0.015..0.016 rows=1 loops=1)
Filter: COALESCE(CASE WHEN op_app.locked THEN op_app.enabled ELSE NULL::boolean END, up_app.enabled, op_app.enabled, true)
Buffers: shared hit=5
-> Nested Loop Left Join (cost=0.15..9.44 rows=1 width=28) (actual time=0.011..0.012 rows=1 loops=1)
Join Filter: (up_app.scope_id = member.user_id)
Buffers: shared hit=3
-> Seq Scan on member (cost=0.00..1.24 rows=1 width=27) (actual time=0.006..0.007 rows=1 loops=1)
Filter: ((role = ANY ('{owner,admin}'::text[])) AND (organization_id = 'cd51d944-2022-4038-b6ce-064af9a5be4e'::text))
Rows Removed by Filter: 11
Buffers: shared hit=1
-> Index Scan using notification_preference_uidx on notification_preference up_app (cost=0.15..8.18 rows=1 width=33) (actual time=0.004..0.004 rows=0 loops=1)
Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
Buffers: shared hit=2
-> Index Scan using notification_preference_uidx on notification_preference op_app (cost=0.15..8.17 rows=1 width=2) (actual time=0.003..0.003 rows=0 loops=1)
Index Cond: ((scope = 'org'::text) AND (scope_id = 'cd51d944-2022-4038-b6ce-064af9a5be4e'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
Buffers: shared hit=2
-> Index Scan using notification_preference_uidx on notification_preference up_mail (cost=0.15..8.18 rows=1 width=33) (actual time=0.003..0.003 rows=0 loops=1)
Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
Buffers: shared hit=2
-> Index Scan using notification_preference_uidx on notification_preference op_mail (cost=0.15..8.17 rows=1 width=2) (actual time=0.004..0.004 rows=0 loops=1)
Index Cond: ((scope = 'org'::text) AND (scope_id = 'cd51d944-2022-4038-b6ce-064af9a5be4e'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
Buffers: shared hit=2
Planning:
Buffers: shared hit=359
Planning Time: 0.895 ms
Trigger for constraint notification_user_id_user_id_fk: time=0.253 calls=1
Trigger for constraint notification_organization_id_organization_id_fk: time=0.194 calls=1
Trigger notification_notify_trigger: time=0.293 calls=1
Execution Time: 0.874 ms
Postgres 18.6 (org with 1 member: freshly reseeded dev volume, per Step 6):
Insert on notification (cost=4.75..42.29 rows=0 width=0) (actual time=0.117..0.118 rows=0.00 loops=1)
Conflict Resolution: NOTHING
Conflict Arbiter Indexes: notification_dedup_uidx
Tuples Inserted: 1
Conflicting Tuples: 0
Buffers: shared hit=22
-> Subquery Scan on "*SELECT*" (cost=4.75..42.29 rows=1 width=288) (actual time=0.056..0.057 rows=1.00 loops=1)
Buffers: shared hit=12
-> Nested Loop Left Join (cost=4.75..42.27 rows=1 width=264) (actual time=0.051..0.052 rows=1.00 loops=1)
Buffers: shared hit=12
-> Nested Loop Left Join (cost=4.61..34.08 rows=1 width=33) (actual time=0.041..0.042 rows=1.00 loops=1)
Join Filter: (up_mail.scope_id = member.user_id)
Buffers: shared hit=10
-> Nested Loop Left Join (cost=4.46..25.89 rows=1 width=32) (actual time=0.035..0.036 rows=1.00 loops=1)
Filter: COALESCE(CASE WHEN op_app.locked THEN op_app.enabled ELSE NULL::boolean END, up_app.enabled, op_app.enabled, true)
Buffers: shared hit=8
-> Nested Loop Left Join (cost=4.31..17.70 rows=1 width=33) (actual time=0.029..0.030 rows=1.00 loops=1)
Join Filter: (up_app.scope_id = member.user_id)
Buffers: shared hit=6
-> Bitmap Heap Scan on member (cost=4.16..9.51 rows=1 width=32) (actual time=0.012..0.012 rows=1.00 loops=1)
Recheck Cond: (organization_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text)
Filter: (role = ANY ('{owner,admin}'::text[]))
Heap Blocks: exact=1
Buffers: shared hit=2
-> Bitmap Index Scan on "member_organizationId_idx" (cost=0.00..4.16 rows=2 width=0) (actual time=0.005..0.006 rows=1.00 loops=1)
Index Cond: (organization_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text)
Index Searches: 1
Buffers: shared hit=1
-> Index Scan using notification_preference_uidx on notification_preference up_app (cost=0.15..8.18 rows=1 width=33) (actual time=0.016..0.016 rows=0.00 loops=1)
Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
Index Searches: 1
Buffers: shared hit=4
-> Index Scan using notification_preference_uidx on notification_preference op_app (cost=0.15..8.17 rows=1 width=2) (actual time=0.005..0.005 rows=0.00 loops=1)
Index Cond: ((scope = 'org'::text) AND (scope_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text) AND (category = 'org'::text) AND (channel = 'in_app'::text))
Index Searches: 1
Buffers: shared hit=2
-> Index Scan using notification_preference_uidx on notification_preference up_mail (cost=0.15..8.18 rows=1 width=33) (actual time=0.006..0.006 rows=0.00 loops=1)
Index Cond: ((scope = 'user'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
Index Searches: 1
Buffers: shared hit=2
-> Index Scan using notification_preference_uidx on notification_preference op_mail (cost=0.15..8.17 rows=1 width=2) (actual time=0.004..0.004 rows=0.00 loops=1)
Index Cond: ((scope = 'org'::text) AND (scope_id = 'a1c087a7-9e0d-4f94-a509-6545adfcebdb'::text) AND (category = 'org'::text) AND (channel = 'email'::text))
Index Searches: 1
Buffers: shared hit=2
Planning:
Buffers: shared hit=348
Planning Time: 1.250 ms
Trigger for constraint notification_user_id_user_id_fk: time=0.406 calls=1
Trigger for constraint notification_organization_id_organization_id_fk: time=0.248 calls=1
Execution Time: 0.855 ms
Access path partly changed, and it's not a clean read on Postgres 18 alone: the four notification_preference lookups (up_app/op_app/up_mail/op_mail) stayed Index Scan using notification_preference_uidx on both versions. The member lookup did change shape: Seq Scan on member (17) vs Bitmap Heap Scan through member_organizationId_idx (18), but the two captures ran against different data volumes: the 17 capture reused an 11-hour-old dev DB with 12 accumulated member rows across several orgs, while the 18 capture ran against the freshly reseeded, single-org, single-member DB the brief's Step 6 requires (docker volume rm + db:push && db:seed). A member table with 1 row choosing an index path over a seq scan is exactly the kind of small-table plan choice that's sensitive to ANALYZE timing right after a fresh seed, not evidence of a genuine Postgres 18 planner regression: the estimated costs (4.16..9.51 for the bitmap path) are still tiny in absolute terms. Re-running both captures against matched row counts, on the same major, is the only way to attribute this cleanly to the version bump rather than to dataset drift; it wasn't repeated here because the outbox-drain query is the one the brief calls the primary signal, and that one is unambiguous. Recorded here rather than silently dropped, per the instruction that an unexplained plan change is the thing this step exists to catch.
The three range-blocked bumps (typescript ^6.0.3 → ^7.0.2, @hono/zod-validator ^0.8.0 → ^0.9.0, @types/pg), plus a full pnpm up -r --latest lockfile refresh landing Biome 2.5.10 ($schema moved with it: it had pointed at 2.5.1 and was silently under-validating the config), Turbo, Vitest, Vite, @vitejs/plugin-react, Hono, pg, React/React-DOM, Tailwind, @types/node, @tanstack/react-router and @tanstack/react-query.
One open point, surfaced rather than hidden: pnpm up -r --latest also pulled the @better-auth/* family toward 1.7.2 via their caret ranges. @better-auth/scim@1.7.x turns out to be a full config-shape rewrite (requiredRole, providerOwnership, storeSCIMToken all removed), a real breaking change riding a semver-minor. Re-implementing the SCIM integration C.7 shipped against a different contract was out of scope for a toolchain refresh, so the whole better-auth family was rolled back and pinned to exact 1.6.30 (the newest 1.6.x patch, still ahead of the repo's starting point): better-auth, @better-auth/passkey and @better-auth/stripe moved from caret ranges to exact pins; @better-auth/scim and @better-auth/sso were already pinned and stayed that way. Migrating to @better-auth/scim@1.7.x was deferred to a dedicated task against the C.7 SCIM surface; it landed in Sep 2026 (see "Dependency refresh: better-auth 1.7" below).
Two behavioural side-effects worth a permanent record: @hono/zod-validator 0.8 → 0.9 wraps every input on the API surface through zV, and apps/api/src/shared/validator.ts was re-read after the bump to confirm it still throws HTTPException(400) instead of returning a Response inline: the RPC types depend on that. And the Stripe bump to 22.6.0 changed the API version the client actually calls: apiVersion in stripe-client.ts moved from "2026-06-24.dahlia" to "2026-08-26.dahlia", because 22.6's SDK types only the newest literal (LatestApiVersion = typeof ApiVersion): there is no way to bump the package and keep the old version string typed. The full surface consumed from Stripe was re-read against the 22.4→22.6 changelog with no shape change found, but this is a genuinely open point: no CI signal can exercise a real Stripe call, so nothing here proves the new API version behaves identically in production. Verify against a live Stripe account before deploying this change.
Eight deprecated z.string().email()/.url() call sites (@packages/events/payloads.ts and module DTOs) moved to the standalone Zod 4 form ahead of their removal in 5.
apps/app/routes.ts now declares the entire route tree once via @tanstack/router-plugin/vite's virtualRouteConfig, wired in apps/app/vite.config.ts (routesDirectory: "./src", generatedRouteTree: "./src/routeTree.gen.ts", virtualRouteConfig: "./routes.ts", autoCodeSplitting: true). Deliberately not physical() and not a flattened src/routes/ directory: the 5 pathless layouts wrap routes drawn from 8 different features, which a directory mount can't express, and flattening would have destroyed the vertical slices: every features/<x>/<x>.route.tsx stays exactly where it was, routes.ts just points at it.
Path resolution: verified empirically, not assumed: virtualRouteConfig entries resolve relative to routesDirectory ("./src"), not to vite.config.ts's own location. This was confirmed by generating the tree from a single route before wiring the other 39, rather than trusting the docs and finding out wrong after 40 entries were written.
Route ids are unchanged: all 40 of them, which is what kept every getRouteApi(...) call site (12, at the time) valid through the migration: they're string literals checked through Register, so tsc was the gate that proved nothing broke. autoCodeSplitting stayed off in this first commit deliberately: the 32 lazyRouteComponent call sites were left untouched, merging the pages was treated as a separate, later step so the tree migration alone could be verified in isolation.
That separate step landed in the following commit: autoCodeSplitting: true only chunks a component that's local to its route file (a statically imported one silently stays in the main bundle) so all 32 <name>.page.tsx files were merged into their <name>.route.tsx, and the 32 lazyRouteComponent wrappers deleted along with the getRouteApi 2-file pattern (the 12 call sites collapse to Route.useSearch()/Route.useParams()/Route.useRouteContext() now that page and route share a module). This is also the point at which the 2-file rule in apps/app/CLAUDE.md was rewritten rather than amended, per the repo's cross-cutting rule #1: its justifying property (the bundler only reaching the page through a dynamic import()) no longer held. Main chunk 478,025 → 363,798 bytes (−23.9%), with 32 separate route chunks replacing the one lazyRouteComponent-driven bundle shape.
knip.json gained src/features/**/*.route.tsx as an apps/app entry point, alongside routes.ts and src/router/*.tsx. Necessary because routeTree.gen.ts (gitignored) is the only file that imports each Route export, and the vite.config.ts → routes.ts link goes through a string (virtualRouteConfig) knip can't follow statically, without the entry, every route file reads as dead code. The cost, worth stating plainly for whoever next touches routing: this is symmetric. A future route file created but never added to routes.ts is now itself an entry point, so knip no longer flags it as unreferenced dead code: the string-based indirection that made the entry necessary also makes the failure mode of "wrote a route, forgot to wire it" invisible to the dead-code gate.
Smaller consequences of the dependency floor (G.1d): Biome 2.5.10's stricter noUnsafeOptionalChaining caught three pre-existing unsafe casts in test files, fixed with explicit narrowing. The Postgres 18 notification fan-out EXPLAIN comparison is non-conclusive, because the two runs used different data volumes: it is recorded as such, neither a pass nor a regression.
Why: the ROADMAP originally prescribed Lingui and /en/...//fr/... locale routes. Both were re-decided on evidence before writing any code: see docs/superpowers/specs/2026-08-29-e1a-i18n-foundation-design.md for the full design record this section summarizes.
i18next replaces Lingui. Measured 2026-08-29: i18next at 21.7M and react-i18next at 15.7M weekly downloads against Lingui's 1.6M and Paraglide's 0.5M: a 31× adoption gap against the stated "known and recognized" requirement. Its key typing is also the strongest of the three, the opposite of the received wisdom: ParseKeys (i18next/typescript/t.d.ts) constrains t() across nested keys, namespaces, keyPrefix, context and the six plural categories; a plural key requires { count: number }; interpolation types from the format specifier. Lingui's Typed Message IDs only work in explicit-ID mode, not with its default macros, and react-intl generates no types at all. @vitejs/plugin-react no longer runs Babel for the JSX transform (oxc-transform-react does it now): Lingui's macro would have dragged Babel back into a build that just left it; i18next is pure runtime, no build-time extraction step. Costs accepted: ~23.4 kB gzip of runtime (i18next 13.4 + react-i18next 10.0) against Lingui's ~3.7 kB and Paraglide's ~0.5 kB, and catalogs are not tree-shaken (a dead key still ships). i18next itself has zero runtime dependencies since v26.0.7; MIT license on both packages keeps a fork available if ever needed.
Catalogs are .ts … as const, never .json. Probed directly against the repo's typescript@7.0.2: with resolveJsonModule on (already the case, packages/typescript-config/base.json), TypeScript widens every JSON string value to string: assigning an unrelated string to typeof catalog["greeting"] compiles with no error. The identical catalog written as a .ts module with as const correctly rejects it (TS2322). Since CustomTypeOptions.resources is bound to typeof enCatalog for the t() key-checking guarantee, a .json catalog would silently defeat the entire premise of this design: an unknown or mistyped key would type-check. Keys and plural suffixes (_one/_other) survive either format since they're property names, but only the .ts form protects the values, which is where interpolation placeholders ({{name}}) live. packages/i18n/src/load-catalog.ts's Resources type widens every string-literal leaf of the English catalog's shape (WidenStrings<T>) so a French catalog can share the same structure without being forced to the exact English literals: that widening is scoped to the runtime resources bag only; CustomTypeOptions.resources in types.ts stays bound to the literal English catalog, so the t() guarantee is untouched.
SendTemplateOptions.locale was removed, not activated. An early draft assumed the port's shared options bag was where locale belonged. It isn't: sendTemplateBatch(template, recipients[], options) takes N recipients under one options, so a batch-level locale would force a single language onto every recipient in the batch, and the two genuinely multi-recipient callers (flush-notification-emails.route.ts's one-digest-per-user job and rgpd.service.ts's export/deletion notices) are exactly the ones that must not be forced. Locale instead moved onto the recipient entry itself, EmailRecipient<K>.locale, defaulting to DEFAULT_LOCALE per recipient in QueuedEmailService.sendTemplateBatch and rendered through renderTemplate(template, variables, locale) before the row is written: the resolved language is frozen onto the email_message.locale column at enqueue time, so a queue retry days later still renders in the language the recipient had when the email was requested, not whatever locale they've since switched to.
Partial translation is a stated, accepted state, not an oversight. E.1a extracted auth, account and the app shell into catalogs; admin, webhooks, sso, billing, organization, the rest of settings, and the legal pages remain hardcoded English, tracked as E.1b in the ROADMAP. This is safe only because createI18n always registers the English catalog under DEFAULT_LOCALE in addition to the requested locale's resources: fallbackLng: DEFAULT_LOCALE is otherwise inert, since i18next can only fall back to a language whose resources are actually loaded into the instance. The result: a French user gets French navigation, sign-in/sign-up, account settings, error messages and email subjects, around English content on the unextracted deeper pages: a mixed-language UI, never a raw "settings.billing.title" key rendered to the screen. packages/i18n/src/__tests__/parity.test.ts enforces exact key parity between en and fr for every namespace that does exist, so the partial coverage is a scope decision, not a silently-drifting one.
Two decisions recorded but deliberately out of scope: EVENT_DESCRIPTIONS (packages/events/src/event-descriptions.ts) and the /developers/events page stay English permanently, consistent with the rest of the API documentation surface. And the pre-existing apps/app/CLAUDE.md claim that ThemeProvider's CSP nonce comes from a Caddy-injected meta[property="csp-nonce"] tag was found, while auditing app-providers.tsx, to reference a tag index.html doesn't actually contain: the nonce is always undefined in practice. Left unfixed here: out of this phase's scope, noted so it's a known debt rather than a silent discovery.
Why: Phase D.5 shipped with three known debts (failed rows never purged, markSent one UPDATE per row, RGPD deletion confirmation missing its idempotency key). Closing them surfaced two review findings that widened the phase beyond those three: a live production defect in the provider-side dedup key, and a second, more serious loss mode in the RGPD path that the original debt list hadn't named.
The design draft's central claim was wrong, and it matters: the draft justified skipping a dedicated fix by arguing the email_message.idempotency_key UNIQUE constraint already closed the duplicate-email window. It doesn't. The constraint protects an INSERT: two enqueue calls racing to create the same row. The actual failure mode is a re-send of an already-inserted row: the worker crashes after the provider accepts the send but before markSent commits, the row is re-claimed on the next poll, and sendChunk calls Resend again for a row that was never marked sent. No INSERT occurs on that second call, so the UNIQUE constraint never sees it. What actually closes that window is the provider-side Idempotency-Key header on the batch request: Resend recognizes the key and returns the cached response instead of sending twice. That header was itself broken (next paragraph), so for a period the window this design believed was closed, wasn't, by any mechanism.
Finding 1: silent mail loss via key truncation. chunkIdempotencyKey built the provider's Idempotency-Key by joining every recipient key in the chunk and truncating to 256 characters. Two structurally different chunks (different recipients, different content) can share the same first 256 characters of their joined keys, producing the same truncated header. Resend then returns the cached response of the first send for the second, structurally different chunk, and sendChunk reads that cached response as success and marks its own (different) recipients sent, even though nothing was ever sent to them. This was a live production defect, not a theoretical one: any two chunks colliding on the truncated prefix silently dropped mail while the queue reported success. Fixed by hashing the joined keys instead of truncating them, so no two distinct key-sets can collide onto the same header.
Finding 2: permanent loss of the RGPD deletion confirmation on a crash. The confirmation email was enqueued after the wipe transaction committed. A crash (or any failure) between the commit and the enqueue call permanently lost the confirmation: the wipe is one-shot and never replayed, so there was no second chance to send it. Fixed by moving the enqueue inside the wipe transaction itself, with throw new Error("rollback") on enqueue failure: a failed enqueue now rolls back the wipe rather than completing it silently without notifying the account holder.
The three originally-scoped debts, also closed:
enqueueidempotency at the queue layer: gainedonConflictDoNothingtargeted at theidempotency_keyunique index, plusreturning(): a warning is logged when fewer rows come back than were requested, surfacing silent conflict-driven drops instead of hiding them.- Per-recipient idempotency keys:
EmailRecipient.idempotencyKeylets a caller set a key per recipient; the positional fallback (${options.idempotencyKey}#${index}) is namespaced with#and shared by both the template batch path and the raw batch path. This is a one-time cross-deploy dedup window, not a cosmetic rename: the separator moved from/to#, so every existing single-recipient caller's stored key changes shape (<key>/0→<key>#0). A row enqueued before the deploy and retriggered after it will not match its old key and will not dedupe: one duplicate email per affected key, bounded byEMAIL_MESSAGE_RETENTION_DAYS=7(old rows age out of the dedup window within a week). The digest flush route already keyed its send on a digest of the full row-id list (digestIdempotencyKey(rows.map(r => r.id))), unchanged by this pass, no per-call timestamp was ever involved there. failedrows never purged:EMAIL_MESSAGE_FAILED_RETENTION_DAYS(default 90) plus a new partial index (email_message_failed_sweep_idx) backs a second sweep pass.sentrows still purge on the originalEMAIL_MESSAGE_RETENTION_DAYS=7. The cutoff is measured fromcreated_at(enqueue time), not from when the row transitioned tofailed: since the retry ceiling is minutes, the two are close in practice, but a row enqueued 89 days ago that fails today is purged on the very next sweep.markSentfan-out cost: collapsed from oneUPDATEper row to a singleUPDATE … WHERE id IN (...)(DrizzleinArray) with aCASE WHENselecting each row'sprovider_message_id: a 100-message chunk now costs one round-trip instead of 100.
Supporting changes: runRetentionSweep was promoted from a single pass to N passes, and six route files were migrated onto the promoted signature: the five direct runRetentionSweep callers, plus sweep-audit-log, which had been bypassing the shared runner via runBatchedSweep on its own. All six now share the same runner as the two new email sweep passes (sent, failed), rather than growing a bespoke second code path.
Rule #6 amended, not exempted: closing the failed-row debt meant no new domain event fires when the sweep deletes a failed row, but rule #6's "infra retention sweeps" exception only named outbox_event, audit_log, webhook_delivery. email_message wasn't on that list, and invoking the exception as written anyway would have stretched a rule's scope silently, which rule #1 forbids. The exception list was rewritten to add email_message, with the justification spelled out: a row reaches status = 'failed' only when nextAttempt.isNone() (the exact point email.delivery.exhausted is already emitted) so every purged failed row corresponds 1:1 to an event already emitted and audited before the sweep ever runs.
No new event types. The catalog is consumed here, not extended: verified by importing packages/events/src/visibility-map.ts and counting Object.values(VISIBILITY): 81 total / 35 public / 46 internal, unchanged from Phase E.1a.
New debt found and deliberately left open: the sweep routes and the cron client (internalLayers, Bun.serve, signedInternalFetch) have no explicit request timeout anywhere in the chain, no AbortSignal on the outbound fetch, no idleTimeout on the server. Doubling the number of sweep passes (one more for email_message failed rows, on top of the existing runners) brings a hang closer to actually mattering, but wiring timeouts through the whole internal-call path was out of scope for a queue-correctness closeout. Tracked as D.5 in the ROADMAP's "What's left" table rather than folded in silently.
Why: the debt above closed a queue-correctness gap but left the whole /internal/sweep-* rail dark to tracing: a batch stuck on lock_timeout, a lease that never releases, or a pass silently truncating on MAX_BATCHES all looked identical from Sentry: nothing. The spec named three components that had to land together (the runner, the six purgeBatch bodies, sweep_lock) because instrumenting only one of them (the lease, say) produces a trace with a hole exactly where the run and its batches happened.
Façade over constructor injection: a deliberate rule #8 deviation. Rule #8 asks for IInstrumentation on the constructor; this rail has none to put it on: the runner, the lease helpers and the purge routine are plain functions, not a class. SweepSpans (apps/api/src/shared/internal-routes/sweep-span.ts) is built once per request from di.IInstrumentation and threaded down explicitly instead, which keeps both properties the rule protects: no module-level singleton (two sweep labels can run concurrently without sharing a span budget), and a unit test builds one over NoOpInstrumentation and spies on its calls the same way a constructor-injected mock would.
Span budget: MAX_INSTRUMENTED_BATCHES = 50 against MAX_BATCHES = 1000. Sentry truncates a transaction at roughly 1000 spans by dropping the overflow from the end: the exact spans most worth keeping (the last passes, the lease release) would be the first discarded if every batch opened one. sweepSpans() counts down a budget and, once exhausted, still executes the query: the trace loses resolution, never work. A full-backlog run therefore traces its first 50 batches and executes the remaining 950 unwrapped; verified by apps/api/src/shared/internal-routes/__TESTS__/sweep-span.test.ts (5/5) and by inspection, not by driving a 1000-batch backlog through a real database.
Double-capture avoidance. Two error paths in sweep-runner.ts were previously silent and are now instrumented, but not the same way: a batch failure under onBatchError: "break" and a failed lease release are both swallowed by design (a sweep that already did useful work must not fail the whole request over them) and are now the only two paths that call spans.capture(...) directly. A batch failure with no onBatchError still rethrows, and is deliberately left uncaptured at that point: app.onError already reports every uncaught exception, and capturing it twice would double-count it in Sentry.
purgeBatch promotion: justified by rule #2 and by what it enables, not by jscpd. Six routes carried byte-identical purgeBatch bodies; the plan expected promoting them to purgeBatchWithTimeout (apps/api/src/shared/internal-routes/sweep-purge.ts) to lower jscpd's duplication numbers. It does not. A worktree comparison with the same binary and config measured the repo before and after this branch: 484 duplicated lines / 3042 tokens / 35 clones before, 496 / 3107 / 35 after: duplication went slightly up, not down, and stayed well under the 3% gate (~1.24% both times, exit 0 either way). The reason is specific to jscpd's exact-token matcher: it never flagged the six purgeBatch bodies at all, because each route names different tables and columns: only the 3-line SET LOCAL boilerplate ever matched, and 3 lines is below the 10-line clone threshold. What jscpd flags, before and after, is the six routes' near-identical import blocks, which grew because all six now import di, purgeBatchWithTimeout and sweepSpans. The promotion is still correct: repo rule #2 (2nd occurrence promotes) applied at three occurrences already, and it is what turned "instrument the batched delete" into a one-file change instead of six.
Verification against a real database (the point of this task). Postgres up, schema pushed, dev user seeded, API started with bun --hot, then the bundled cron (apps/api/src/cron/sweep.ts) run against it with a real INTERNAL_SIGNING_KEY. All six sweeps (sweep-email-messages, sweep-webhook-delivery, sweep-audit-log, sweep-outbox, sweep-consents, sweep-notifications) answered OK with deleted: 0 (fresh database), every pass's stopReasons exhausted, no SKIPPED/FAIL/UNREACHABLE line, cron exit code 0. This is the only step in the whole plan that exercises purgeBatchWithTimeout's query against a real Postgres: every unit test in Tasks 1 to 6 runs against a mocked @packages/drizzle, which type-checks a query but cannot execute it.
Sentry: reasoned from code, not observed live. apps/api/.env carries a SENTRY_DSN key with an empty value; env.ts normalizes every empty-string env var to undefined before Zod sees it, so env.SENTRY_DSN is undefined and di.ts's env.SENTRY_DSN ? new SentryInstrumentation() : new NoOpInstrumentation() binds NoOpInstrumentation. No live Sentry project was available in this environment, so the trace shape described in docs/CRON.md's "What a sweep looks like in tracing": one run span (op: "function"), a db.query lease-acquire child, one pass span (op: "function") per pass carrying stop_reason, a db.query child per batch: is asserted from sweepSpans()'s implementation and its call sites in sweep-runner.ts and sweep-purge.ts, not from an opened Sentry trace. What was confirmed directly: with SENTRY_DSN unset, NoOpInstrumentation is what di.ts binds, so every sweep above ran with instrumentation fully disabled and still worked: Sentry off does not change sweep behavior.
Final whole-branch review, live under a real Sentry SDK (Aug 2026). A follow-up review ran the real runRetentionSweep under @sentry/bun with a stub transport instead of reasoning from code, and found two defects the unit suite could not see. First, the lease's two spans shared the same budget counter as the batch and dry-run-count spans; because the lease release runs last (in the runner's finally), any run past ~48 batches dropped delete from sweep_lock from the trace: reproduced live at budget 4, where the release span was simply absent. Fixed by giving the lease an unbudgeted spans.lease(...) path (sweep-span.ts's dbSpan helper, shared by both db and lease), on the reasoning that the budget exists to protect exactly this span, so spending it on the lease defeats its own purpose. Second, the run span itself carried no attributes: a run skipped for a held lease was indistinguishable in Sentry from a run that executed and found nothing. Fixed by writing sweep.skipped: true on the early return and sweep.deleted/sweep.batch_count/sweep.truncated on the completed path, both while the run span is still active. Both fixes are covered by tests: a lease span surviving an exhausted budget (sweep-span.test.ts), and the run-span attributes on both paths (sweep-runner.test.ts).
Sentry's ~1000-span truncation: now measured, not assumed. The original span-budget note above (and the matching claims in docs/CRON.md, docs/FEATURES.md, ROADMAP.md, and sweep-span.ts's docstring) asserted that Sentry truncates a transaction at ~1000 spans by dropping the overflow from the end. The plan that produced those docs had explicitly flagged this as unconfirmed in the installed SDK and said nothing should claim it as fact: that caveat did not survive into the shipped text. The same live-Sentry review closed the gap: emitting 1200 spans in one transaction retained exactly 1000 (b0..b999), with the tail dropped. The claim was true; it is now backed by a measurement instead of an assumption.
Two facts about earlier tasks worth recording here. First, Task 3 found a pre-existing bug already on dev: sweep-notifications.test.ts failed in isolation with Export named 'sweepSchema' not found (introduced in 88e3e92) but passed in the full suite, because bun's mock.module registry is process-global and another test file's factory happened to supply the export first. The shared drizzle-mock.ts factory this plan extracted removes that whole class of trap by giving every sweep test the same, complete mock. Second, the plan's Task 5 expected pnpm check:sweep-lock to pass at that point; it could not, because that script drives the real routes, which were deliberately left non-functional from Task 4 until Task 6 wired the runner, the purge helper and the lease together. The gate moved to Task 6, where it passes 23/23, and stayed 23/23 through Task 7.
No new event types. Nothing here mutates persistent business state beyond what the existing sweeps already purge: catalog stays 81 total / 35 public / 46 internal, unchanged from Phase E.1a and the email queue debt closeout.
Why: E.1a shipped the typed rail and translated auth, account and the shell; the rest of the product (admin, webhooks, sso, billing, organization, the rest of settings, every /legal/* page) stayed hardcoded English, tracked as E.1b. This phase closed that list, then retired the apps/app/CLAUDE.md hedge that had named the state as deliberate.
Namespace split: admin gets its own catalog, not a home inside settings. packages/i18n/src/catalogs/en/settings.ts runs to 520 lines; admin.ts sits at 152. Folding the admin/audit-log/org-admin copy into settings.ts would have made one namespace carry two audiences the app already keeps structurally apart: apps/app/CLAUDE.md's import-direction rule forbids one route-owning feature importing another for exactly this reason, and it's what forced Task 13's role-labels.ts promotion to shared/auth rather than leaving it in features/organization. A separate admin namespace (useTranslation("admin")) means a customer-facing settings screen can never accidentally resolve platform-operator copy, and the reverse.
R2: i18next _one/_other plurals, no new ICU primitive. Ruled before dispatch: the repo already shipped 20 plural keys on this pattern, so introducing an ICU message-format primitive for this phase's own new counts (API-token expiry in days and years, webhook delivery-attempt counts) would have been a second plural mechanism solving a problem the first one already solved. A non-decision once the precedent was checked.
R3: legal prose is restructured per-locale, not translated, because translating placeholder text has negative value. features/legal/policies/{en,fr}.tsx gives every clone-owner one file to replace. The phase measured the placeholder legal corpus at roughly 2,000 words across legal/ (~560 of them in policies.config.tsx alone) against the spec's estimate of "~57 blocks": the disagreement didn't change the decision, it strengthened it: translating a boilerplate's fictional ToS/Privacy Policy is a legal act a clone-owner must redo in their own jurisdiction's language and wording, so doing it here would be work whose entire output gets discarded by every real deployment. policies/fr.tsx re-exports the English bodies verbatim.
Every legal page discloses its untranslated body, not only privacy/terms. Task 14's review found four more pages (accessibility, cookies, data-rights, sub-processors) shipping substantial English prose under already-translated French chrome, with no disclosure at all: 20 English strings on the RGPD walkthrough alone. R3's justification for leaving legal prose untranslated is the disclosure, not the omission: a French title over a silent English body is the "half a page is worse than none" defect this whole phase exists to prevent, even though the prose itself is legitimately in scope for R3. Fixed by extending the shared UntranslatedBodyBanner (already load-bearing on privacy/terms, since requireCurrentPolicies makes accepting the document mandatory) to all six legal pages, never by translating the ~560 words. The carve-out can now close honestly: every legal page either is translated, or tells the reader, in French, that it is not.
R5: an error formats to its final string immediately; a stale message already on screen does not re-translate if the user switches language mid-session. The alternative (carry an i18nKey and resolve it at display time) was considered and rejected: it would have touched roughly 40 call sites, including screens this phase had already finished, for a benefit that only matters in the narrow window between a language switch and the next re-render of a stale error. The frozen-language trade-off is accepted as the cheaper, correct-enough choice.
Three live defects the phase inherited, closed as a side effect of extraction rather than as its purpose (commit 8ae9485):
apps/app/src/features/api-tokens/api-tokens.schema.ts: thenameandscopesfields carried inline Zodmessage:strings ("Name is required","Select at least one scope") that silently outrank the globalcustomErrormap, since Zod gives a per-issue message absolute precedence. Both fields already had a translated key in the map; the inline literal was quietly winning over it, invisible because the screen was English throughout anyway.apps/app/src/features/sso/sso.schema.ts:domain,issuerandentryPointvalidated through.regex()/.startsWith(), both of which raise Zod'sinvalid_formatcode, which can never carryparams.i18nKeyback to a specific catalog key: the global map can only answer it generically. Fixed via.refine(), whosecustomissue code does carryparams.i18nKey.apps/app/src/features/admin-audit-log/components/audit-row.tsxandmetadata-sheet.tsx:row.occurredAt as unknown as stringrendered the timestamp in the browser's own locale, unconditionally, regardless of the active language. Fixed by routing both throughuseFormatDateTime(commit31bf02a).
The currency-formatting fix: the sibling of E.1a's formatDate promotion. apps/app/src/shared/components/pricing-table.tsx built prices by hand (`${amount} ${CURRENCY}/${interval}`), which is wrong per locale by construction: English wants $12/month, French wants 12 €/mois, and no single template-literal shape produces both. formatPlanPrice now goes through Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }): the same "no date/currency library, Intl bound to an explicit locale argument" pattern E.1a established for formatDate/formatDateTime (commit 90b1302).
The parity gate grew by three assertions in Task 10 (17 → 20 tests), and they exist because a subagent volunteered a negative result about its own work. The implementer reported (and the reviewer independently reproduced) that collapsing the French expiryDays_one onto its _other left both the en/fr-comparison suite and the completeness suite green: the gate compares the two locales against each other and is structurally blind to a catalog that is wrong within one locale. Only a human reading "1 jours" caught it. Three assertions now close that hole (8377540): no French _one may equal its _other outside an explicit ALLOWED_SAME_PLURAL_FORM list (English is deliberately exempt: its two forms are legitimately identical wherever the noun doesn't inflect around {{count}}); that list is swept for stale entries the same way ALLOWED_IDENTICAL is; and every plural must be declared in both forms, in both locales, or i18next silently falls back out of the missing bucket at runtime. The suite reached 21 independently, in Task 12, when the new admin namespace added its own entry to the per-namespace parity loop. Worth recording plainly: every other gate this phase added came from a defect that had already shipped. This one came from a defect caught before it shipped, because the implementer said so instead of reporting green and moving on.
The inventory undercounted in five of the nine tasks where it was checked against the real file: always low, never high. Task 5 (organization): 71 strings, not 70. Task 9 (webhooks): 71, not 70. Task 12 (admin users): 80 call sites, not 78. Task 13 (admin orgs / audit log): a fifth undercount, same shape. In each case the miss was not a quoted string the grep-based inventory failed to notice: it was a bare enum variable interpolated raw into what became a translated sentence (invitation.role, d.status, user.role, item.role, member.role, row.actorType): invisible to a literal-string grep because there is no literal to find, only a value the surrounding sentence now depends on. The recipe's Step F grew a second pass for exactly this after Task 5 shipped the first instance of it, and that second pass paid for itself on every task afterward. The rule this leaves behind: an inventory is a hypothesis about what a screen contains, not an authority on it. Grep the file before trusting a count, every time: the question that matters is not "how many quoted strings are there" but "how many places does English still reach the screen."
Four follow-ups this phase deliberately did not close, recorded here so none reads later as an oversight:
- Business codes for the 61
HTTPExceptioncall sites inapps/api(verified:grep -rn "new HTTPException" apps/api/srcstill returns 61 at the end of this phase). Task 2 closed the user-visible half on the front: abyStatuslayer inmessages.tscovering all 61 sites by HTTP status; the API itself still answersHTTP_<status>where it should answer a domain code. Spec §4's "the API returns codes" is only half-held until the API side is done too. - A French legal corpus.
features/legal/policies/fr.tsxre-exports the English bodies by design (R3).requireCurrentPoliciesmakes accepting the document mandatory, so theUntranslatedBodyBanneris a disclosure, not a substitute: whoever ships this to real French users must replace that one file. EVENT_DESCRIPTIONSstays English, permanently (R4). It renders on/settings/webhooksviaevent-type-picker.tsx, directly beside the machine event names (org.member.added) that were never going to be translated either: that adjacency, decided in Task 9, is the justification: the row reads as technical data, not as untranslated prose.domain-verification-card.tsx's dead branch, found in Task 11 and deliberately not fixed there. The component testserr.message === "DOMAIN_VERIFIED"(domain-verification-card.tsx:43), but@better-auth/ssoputs that string in.code, not.message, andsso.mutations.tsdiscards.codeentirely. Re-verifying an already-verified domain therefore shows "DNS record not found" instead of the correct message: misleading, not crashing. Fixing it means threading.codethrough the mutation, a real behaviour change; burying that in a translation commit would have made the diff unreviewable.
Three permanent exclusions, not two: the sub-processor register earns its own line. Alongside the legal prose (R3) and EVENT_DESCRIPTIONS (R4), apps/app/src/shared/sub-processors.config.ts stays English because it restates the signed DPA: it is legal evidence, not display copy. But the two fields a user actually reads off it, purpose and region, are translated: they move through common.legal.subProcessors.*, named by shared/sub-processor-labels.ts, so /settings/privacy's DataSourcesCard and the public /legal/sub-processors page both render French purpose/region text around an English-titled register. The exclusion covers the source-of-truth file, not what the user sees.
Final gates, at 751d22c. pnpm ci:check (Biome): 881 files, 0 fixes. pnpm test: api 800/800, app 279/279 (19/19 turbo tasks, matching Task 14's own count). pnpm type-check: 13/13 turbo tasks. pnpm check:unused: exit 0, 4 configuration hints, none i18n-related (the POLICY_TYPES warning is gone, as Task 14 expected). pnpm check:duplication: 34 clones / 483 duplicated lines / 1.13%, against the measured baseline (dev @ 429d974, before Task 1) of 35 clones / 496 duplicated lines / 1.24%. The number moved down, not flat as expected. The phase's working hypothesis going in (that jscpd would stay flat because it flags no i18n catalog pair, en and fr differing by construction in every value) held: nothing in the new catalog pairs shows up as a clone. The actual movement came from the opposite direction: extraction promoted a few small duplicated blocks to shared helpers along the way (isHttpsUrl to shared/api/, role-labels.ts to shared/auth/), which removed pre-existing clones elsewhere in files this phase was already touching: a side effect of the extraction, not a target of it. a11y gate: docker compose up postgres -d → pnpm db:push && pnpm db:seed → pnpm --filter app build (main chunk 257.14 kB, matching Task 14's own post-fix measurement, no export leak regression) → pnpm --filter api dev → pnpm --filter app check:a11y: 22/22, matching Task 14's baseline.
No new event types. Nothing here mutates persistent business state: catalog stays 81 total / 35 public / 46 internal, unchanged since Phase E.1a.
Why: a boilerplate is judged on what the cloner can do, not on what the API can serve. A backend capability with no UI is invisible; a UI with no backend is a dead button. H.1 ran last on purpose: auditing the finished surface before E.1b would have meant translating screens twice.
The map is a checked-in TypeScript artifact, not a hand-written table. apps/api/src/shared/surface/back-routes.ts reads the route table off the live Hono app (71 keys) rather than being maintained by hand alongside it, which would drift the moment one of the two changed without the other. Reading app.routes from a test required splitting app construction from boot: apps/api/src/index.ts previously ran await di.preload() and started the outbox dispatcher and three workers at module scope, so importing it booted the whole server. apps/api/src/app.ts now exports app/routes; index.ts keeps the boot. front-consumers.ts extracts 52 call sites from apps/app's source. route-map.ts is the Record<"METHOD /path", Consumer | UiLessReason> over the 71 routes, with a runtime diff test (4 assertions) standing in for compiler exhaustiveness: routes have no hand-maintained union to satisfies against, since the true set only exists as app.routes at runtime. The template is the i18n parity test: flatten both sides, diff the sets, assert nothing is missing or stale.
19 routes have no front consumer, and every one is declared, not silently absent. 9 internal-cron, 3 infra-probe, 3 public-api, 2 provider-callback, 1 library-owned (the /api/auth/* wildcard: better-call's internal route table is not exported, so it is documented as one opaque row rather than a wrong enumeration), 1 dormant-by-design. Nothing was deleted for being unconsumed: Axel: "faut être futur proof, si demain j'ai besoin du flux ça doit rester implémenté, le but c'est d'avoir tout de prêt, c'est un boilerplate." POST /uploads/download stays, declared dormant in writing. Capabilities with no HTTP surface at all (quotas, no routes.ts, never mounted) can never appear in app.routes, so the map is structurally incapable of carrying them; they get a short prose section in docs/SURFACE.md instead, honestly labelled as a human claim rather than a machine-verified one.
Two gaps found, both confirmed and closed. GAP 1: PUT /admin/users/:id/role was live, guarded and audited on the backend with no front consumer: a platform admin could not change a role from the UI. Now wired. GAP 2: replacing an avatar leaked the previous storage object: createUpload then authClient.updateUser({ image }) never deleted the old one, and neither POST /uploads/download nor DELETE /uploads had a consumer. Fixed by deriving the object key server-side: keyFromPublicUrl(url): Option<string> on IStorageService, symmetric with publicUrlFor, and deleteUploadBodySchema now accepts { key } or { url }. The front must not learn the storage key format: apps/app has no VITE_STORAGE_PUBLIC_URL, and duplicating the public-URL base into the front is a config that drifts. A URL that isn't this storage's returns { deleted: false }, a legitimate no-op: a social-login avatar is an ordinary user.image and must not raise.
GAP 3: the inverse direction (a front call site targeting a route that doesn't exist) was INFIRMED. A sweep of disabled|TODO|FIXME|coming soon over apps/app/src found only legitimate form states (isPending, isFetchingNextPage, current-plan). Nothing to fix; recorded here so a future reader doesn't re-run the audit.
Behavioural parity: three refusal families didn't mirror, now two do. denyImpersonated (~29 call sites across 9 modules) had no proactive front mirror: every guarded action stayed enabled, and the refusal only arrived as a 403 after the click. Seven call sites rendered raw backend English via toast.error(err.message) instead of the localized formatApiError path. Fixed on both legs, per the ROADMAP's own wording ("the action is hidden or disabled where it would be refused"): localized refusal copy (28 sites converted in total) and proactive disabling with the reason exposed without a mouse. The impersonation refusal gained its own business code: deny-impersonated.middleware.ts now throws AppErrorException({ code: "IMPERSONATION_ACTION_FORBIDDEN" }) instead of a bare HTTPException(403, ...), which previously resolved to the generic "You don't have permission to do this": wrong, since the user does have permission, they're impersonating. Six of the nine refusal families already mirrored correctly and were left alone (auth/session, CSRF, rate-limit, requirePlatformAdmin + MFA, org capability, ownership scoping).
requireCurrentPolicies mounted for real: reversing Phase A.2 decision 5. A.2 shipped the middleware composable and unmounted, reasoning that a global mount "would make every current API call return 409 for a stale user: too aggressive." H.1 reverses that: a user who hasn't accepted the current policy version now gets POLICY_ACCEPTANCE_REQUIRED (409) on business routes, API tokens included. The refusal carries its own business code rather than a bare HTTPException(409, ...): the same treatment the impersonation refusal got, and for the same reason: HTTP_409 resolves to the generic "this action conflicts with the current state", which tells a user with stale terms nothing about what to do. The front handles the code globally (apps/app/src/shared/api/errors/policy-refusal.ts, wired where the router meets the query client): it refetches the policy status and invalidates the router, so _shell's existing shouldRedirectToLegalAccept issues the redirect. Without it, a terms version published mid-session left whoever stayed on a settings page clicking Save against a wall: beforeLoad does not re-run on the page you are already on. It is an allowlist, not a global mount with exclusions: a distinction the first write-up of this phase got wrong, claiming "four exclusion classes" while three whole surfaces (uploads, consents, /admin/*) sat ungated and undeclared. The gate is declared route by route on the 19 mutating business routes of profile, webhooks, api-token, organization, notifications, billing and uploads, plus PATCH /api/v1/me: 20 mount sites in all. What is not gated, and why, is now enumerated in full: POST /me/policies/accept and GET /me/policies (the gate would otherwise block the only route that clears it); RGPD Art. 17/20 routes (a data-subject right cannot be conditioned on accepting terms); sign-out and session reads (a user must always be able to leave); /consents, which has no requireAuth at all: the cookie banner records for anonymous visitors, so the gate is structurally unmountable there, and withdrawing consent is the same family of right as RGPD anyway; the whole /admin/* surface, deliberately, because an operator whose own acceptance went stale would be 409-walled out of the console: POST /admin/impersonation/stop included, which is exactly the route they would need; impersonated sessions (session.impersonatedBy set: C.3 already skips the legal gate on the front during impersonation; the server had to agree or an operator would get 409-walled out of the account they're inspecting); and every read-only route. uploads was the one genuine omission rather than a deliberate exclusion: uploading is product usage, not a data-subject right, and nothing on /legal/accept uploads, so it could not deadlock the unblocking route. It is now gated on all four of its routes. Mounting it on /api/v1 needed a typing change, not a second middleware: that surface runs on ApiTokenVariables, not AuthVariables, so requireCurrentPolicies was retyped against the minimal contract it actually reads: { user: SessionUser; session?: { impersonatedBy?: string | null } }: making it mountable on both surfaces. A token request has no session, so the impersonation bypass is simply never taken there, which is correct: a token cannot impersonate.
The E.1b plan's justification rested on a false premise: corrected, not the conclusion. docs/superpowers/plans/2026-08-31-e1b-i18n-extraction.md:67 claimed requireCurrentPolicies "blocks every protected route until the user accepts the current policy version," and used that claim to justify the UntranslatedBodyBanner. It was false when written (zero call sites) and would still be too broad today (RGPD rights, the acceptance route, and impersonated sessions are excluded). The line is rewritten to state the exclusion list, with a dated note pointing here. The plan's second reference (:863, "requireCurrentPolicies makes accepting the document mandatory") was equally false when written but is true as of this phase, and is left standing. The banner's justification survives either way (acceptance really is mandatory on the paths that matter) but a conclusion resting on a false premise is a defect even when the conclusion itself is right.
One new event type: the first catalog movement since E.1a. notification.read (internal, operational retention) is emitted when a user marks notifications read: the transition writes read_at, which is persistent state, and cross-cutting rule 6 admits no "small enough to skip" tier. Catalog now 82 total / 35 public / 47 internal, verified against packages/events/src/visibility-map.ts. The event pulls double duty: it is also the signal the unread badge converges on, so the count stops waiting for the next poll to become right.
Auditing a finished surface finds what a plan written before the audit could not list. Everything below was found while executing the eleven planned tasks, or by Axel exercising the result in a browser, and every one of them was live on dev.
2FA was dead, and it took the whole /admin/* console with it. packages/drizzle/src/schema/auth.ts declared the two_factor table without two columns better-auth@1.6.30 writes: failed_verification_count and locked_until, its verification-lockout state. Every 2FA verification 500'd, and since requirePlatformAdmin demands a fresh MFA challenge, nothing under /admin/* was reachable on an account with 2FA enabled. The subagent that found it correctly refused to patch product code under a translation-scoped brief; that refusal was overruled deliberately: a boilerplate shipping a dead security feature is worth more than a scope line. The other nine BetterAuth plugins were then audited for the same class of drift and are clean. db:push fixing the dev box is not the fix: the first review caught that the schema change shipped without a migration, so a fresh production database would have recreated the bug on day one: packages/drizzle/migrations/0021_great_tana_nile.sql is what actually closes it.
Every modal could overflow, because no modal primitive declared a height. The reported symptom was one broken screen (the webhook creation dialog, whose event-type picker lists the whole catalog). The cause was one level down: Dialog, AlertDialog and Sheet in @packages/ui set a width bound and no height bound, so any content taller than the viewport simply ran off the top and bottom of the screen with no way to scroll to it: 16 modals one long list away from the same bug. Fixed at the primitive (max-h-[calc(100dvh-2rem)] + overflow-y-auto on dialog and alert-dialog content, the same on the sheet's top/bottom sides and overflow-y-auto on left/right), which then made delivery-sheet.tsx's own overflow-y-auto redundant and it was removed. A layout bound that every call site must remember is a bug waiting for the next call site: the same reason apps/app/CLAUDE.md keeps colors in the theme rather than at the point of use.
The signature-verification example we hand to integrators was replayable. verify-snippet.tsx is copy-paste code: it is what a customer's webhook endpoint will actually run. It parsed t=<ts> out of the header, folded the timestamp into the signed string, and never checked it. Since the signature covers the timestamp, a captured request stayed valid until the secret rotated. Now rejected outside a 300-second window. The Number.isFinite guard in front of the comparison is load-bearing, not defensive noise: Number("abc") is NaN, and NaN > 300 is false, so without it a garbage timestamp passed the check that exists to stop it.
/api/v1 had no reference document. The public surface is deliberately outside AppType (third parties do not get the Hono RPC client) which means it has no generated contract either, and its only description lived in docs/FEATURES.md as an inventory line. docs/PUBLIC-API.md is now the document a token holder reads: authentication, scopes, every route, the error-code table, and the webhook signature scheme. Every example in it was executed against a running instance rather than transcribed from the route handlers.
Seven org-scoped queries shared a single cache entry across organizations. The reported symptom was that a webhook created in Personal vanished when switching organization and needed a manual refresh to come back. The cause is that these endpoints are requireOrg: the server answers for the active organization, so a query key that does not name the organization asks TanStack Query to serve one organization's response for another's. With staleTime: 30_000 and refetchOnWindowFocus: false, the stale entry is not merely shown: it is shown and kept. Fixed across webhooks, api-tokens, current-membership, subscription, notifications' org preferences and the rest, with useActiveOrgId() as the single place the id is read. A cache key is a claim about what a response depends on; if the server scopes a response by something the key omits, the cache is not a cache, it is a bug with a TTL.
The notification rail carried two identical connection leaks, an inert selector, unreachable history, and a payload that shipped an invitation token to the browser. The leaks are the same defect written twice: notification-stream-hub.ts and outbox-dispatcher.service.ts both registered error and end handlers that each called scheduleReconnect(), and neither ever disposed the dying pg.Client: no removeAllListeners, no end(), the field just overwritten. One dropped connection therefore produced two reconnects, and every previous client stayed alive holding a LISTEN. Both now share one in-flight reconnect timer, an idempotent disposer that removes listeners before ending the client, a field cleared only if it still points at the client that died, and a backoff reset only on a real LISTEN: plus, on the hub, instrumentation.capture on the failure path, which is how anyone would have seen this in production. The front got the other half: a stream that goes silent without erroring is now detected and reconnected. NOTIFICATION_MAP gained a payloadFields allowlist per event, because the SSE frame was forwarding the raw event payload: org.member.invited carries invitationId, which is the acceptance token, to every connected client of the organization. The email-frequency selector offered immediate/hourly/daily and did nothing with the answer; it now drives a digest window in SQL, with a preference flipped after a digest was scheduled honoured at flush time rather than at schedule time. The panel showed the most recent page with no way to reach any earlier one; it now loads more. And a read targeting someone else's notification answered success: it answers not-found now, which is the right shape for the same reason ownership scoping never answers 403: 403 confirms the row exists.
Personal API tokens were invisible, and therefore unrevocable. routes.ts read session.activeOrganizationId ?? null, but this stack always has an active organization (Personal is one), so the ?? null branch was unreachable and the list only ever showed the active organization's tokens. A user's org-less tokens existed, authenticated, and could not be seen or revoked from the UI. TokenOwner is now a closed union ({kind:"personal"} | {kind:"orgAndPersonal"}) so the two cases cannot collapse into each other again, and visibleTokensFilter builds the WHERE from it. The filter is verified by check:api-token-visibility against a real database, not a mock, for the same reason as check:fanout: an and/or/eq/isNull tree is a value until Postgres evaluates it, and a mocked transaction asserts the shape of a query nobody ran.
Ten tests were not running, and which ten depended on file order. bun test shares one module registry across the whole run, so mock.module leaks process-wide: a stub registered in one file replaces the real module in every file that imports it afterwards. It bit three separate subagents in three unrelated disguises: hand-copied @packages/events catalogs blanking 17 event types in other files' assertions, an SQL-text assertion held hostage by another file's stubs, and a cross-suite SyntaxError that predated the branch. Measured rather than argued: bun test src/modules/admin src/modules/api-token ran 63 of 73 tests with 3 failures; the same command with --isolate runs 73 and passes. --isolate --parallel is now the default for every api test script, and apps/api/src/shared/__TESTS__/module-isolation* is a named canary over a module nothing else imports: one file replaces it, its sibling asserts it still sees the real one. Verified to fail without the flag. One consequence had to be paid for: pino ships pino-pretty to a worker thread, and a per-file registry spawns and tears one down per file, racing into "the worker thread exited", so the logger is silenced under test.
Final gates, at 834aaa9. pnpm ci:check (Biome): 912 files, 0 fixes. pnpm --filter api test: 867/867 across 105 files, and green on three --isolate --randomize seeds (3, 11, 99), which is the assertion that matters after an isolation fix: order-independence is not provable by one ordering. pnpm --filter app test: 328/328 across 62 files. @packages/i18n: 21/21. pnpm type-check: 13/13 turbo tasks. pnpm check:unused: exit 0, 4 configuration hints, none introduced here. pnpm check:duplication: 35 clones / 517 duplicated lines / 1.15%, against E.1b's 34 / 483 / 1.13%: one clone and 34 lines added across roughly 9,200 new lines, which is the shape you want: the percentage moved by two hundredths, not the direction.
Seven executable checks now run against a real Postgres in CI, not one: check:sweep-lock, check:fanout, check:digest, check:api-token-visibility, plus check:wipe-rollback, check:enqueue and check:marksent, which existed on this rail but had never been wired into the workflow: a check nothing runs is documentation with a shebang. They share a single rationale: each one's logic lives inside SQL (a lease's FOR UPDATE SKIP LOCKED, a preference cascade, a due-window filter, an ownership WHERE tree), and a mocked transaction can only assert that a query was built, never that Postgres agrees with what it means. check:sweep-lock refuses to run against a non-local database, because a check that acquires and releases leases is not something to point at production by accident.
Why: the G.1d refresh left the better-auth family pinned to 1.6.30 because @better-auth/scim 1.7 is a rewrite, not a bump. This pass moves the whole family to 1.7.6 and takes every other outdated package with it, except the three pins below, each kept for a measured reason.
What changed with better-auth 1.7:
- SCIM moved to managed connections. The legacy
scim_providertable and itsgenerate-token/delete-provider-connectionendpoints are gone (migrations0022drops the table,0023creates the ten tables the plugin now owns). The plugin knows no organizations:ScimConnectionService(shared/services/) creates one connection per organization, with the organization id as its provisioning domain, andGET/POST/DELETE /settings/organization/scim-connection(owner only, newscim:["manage"]statement,ssoentitlement) read, issue or rotate, and decommission it. Membership is a projection the plugin calls inside its own transaction (shared/auth/scim-membership.ts): the seat cap now rolls the whole provisioning back instead of being checked inhooks.before, and an existing account is linked only when its email is verified on a verified SSO domain of the organization. Every connection issued before this change stops working: directories have to be reconnected with a new token. - The plugin requires native transactions, hence
drizzleAdapter(..., { transaction: true }). Sign-up, the personal organization hook, org creation and every SCIM write were exercised live against Postgres with it on. - New secret
SCIM_CREDENTIAL_HASH_SECRET(≥ 32 chars, required in production): the HMAC key the plugin stores directory tokens under. Rotating it invalidates every issued token. - SAML:
samlConfig.idpMetadata.entityIDis now how the IdP is identified, the top-levelissueris the SP entity ID, and the ACS is fixed at/sso/saml2/sp/acs/:providerId(the old/sso/saml2/callback/:providerIdpath is gone). The registration form asks for the IdP entity ID accordingly. - Two-factor enable returns a union on
method; the front narrows on"totp"before readingtotpURI. - A latent billing bug surfaced: 1.7 checks at boot that every model its plugins write is in the adapter schema, and
subscriptionwas not (it was exported by@packages/drizzlebut missing from the schemadbis built with), so every Stripe subscription write through the plugin would have failed at runtime. Fixed by adding the billing tables to that schema. - New event
scim.connection.token_rotated(internal): catalog now 85 events, 35 public, 50 internal.
Review round (before merge), each fix proven by a check that failed first:
- First SSO sign-in of a new user failed. The SSO plugin creates the user and its session in one transaction, and
session.create.beforebootstrapped the Personal org throughdb, on another connection: thememberinsert referenced a user not committed yet (foreign key violation). The adapter now receivesdbthroughwithPublishedTransactions, which publishes each transaction it opens, andensurePersonalOrgFor,emitand the other in-hook writes join it (check:auth-transaction). - Concurrent SCIM provisionings passed the seat cap (five at once on a 3-seat plan gave 6 members): the count ran on another connection without a lock. It now runs on the plugin transaction behind a per-organization advisory lock (
check:scim-seats). - Concurrent token issues created one connection each (four at once gave four connections):
ScimConnectionServicewrites are serialised per organization (check:scim-connection). A failed emit after the plugin committed no longer turns into an error, since the returned token is the only working copy. - A downgraded organization could not disconnect its directory (402 on
DELETE): only issuing a token requiressso; provisioning refuses additions once the plan is gone, removals still go through. - Any organization could reserve an address it does not own: provisioning outside the organization's verified SSO domains is refused with a SCIM 403.
Live verification (local API on Postgres 18, curl): sign-up, sign-in, TOTP enable, passkey option generation; SCIM POST/GET/PATCH/PUT/DELETE with the expected member rows and outbox events (actor = connection creator), seat cap refusal as a SCIM 402 with nothing persisted, 409 on an unverified email match, token rotation (old token 401, new 200), disconnect removing provisioned members but not the owner; SAML registration, SP metadata and ACS URL. Not covered: a full browser round trip against an IdP. SAML sign-in without an SP private key is refused by the plugin because this codebase forces authnRequestsSigned (already the case on 1.6.30, docs/SSO-LOCAL.md documents the key pair).
What stays pinned, and why:
@better-auth/utilsstays at0.4.2:better-auth1.7.6 depends on exactly that version and every plugin peers on it.0.5.0would install a second copy next to the one the TOTP verifier uses, anddrizzle-rgpd.repository.tsmust compute the same TOTP astwoFactor.@simplewebauthn/serverstays on13: it is a type-only anchor for TS2883 (the inferredauthtype names its types), and@better-auth/passkey1.7.6 depends on^13.3.1, no passkey release (beta included) moves to 14. Measured: removing the anchor or moving it to14.0.3both bring TS2883 back onauth.conventional-changelog-conventionalcommitsstays on9:10.x(latest10.4.0) only renders withconventional-changelog-writer@9, whilesemantic-release25.0.9's stablecommit-analyzer13.0.1 andrelease-notes-generator14.1.1 still bundle writer 8 (only the15.0.0-betagenerator moved). Measured by running both plugins with.releaserc.json's own options: on 10.4.0 the analyzer still answers butgenerateNotesthrows "requires conventional-changelog-writer@9"; on 9.3.1 notes render, a wrapped prose line starting with "BREAKING" stays a minor, and a realBREAKING CHANGE:footer is a major.
Why: after the feature phases, every file was reviewed against the house rules (Option for absence, typed errors, one shared helper per repeated shape, no casts, spans on every I/O method) and the guides were refreshed. Delivered as PR #70 into dev: 149 commits, 626 files, mostly mechanical. This entry is reconstructed from that PR's commit log.
Behaviour fixes the pass surfaced (the rest was refactoring with no observable change):
- Units of work return failed results instead of throwing, and a callback that returns a failed
Resultnow rolls the transaction back. Aborted transactions, rejected SSO calls, SSO redirects and 4xx API failures stopped being reported to Sentry as unexpected errors. - A failing first drain no longer crashes the API: the outbox dispatcher, the email worker and the webhook worker report it and keep running.
- Webhook responses stopped exposing the previous secret cipher.
- Client IP: policy acceptance (sign-up included) and new sessions record the trusted-proxy client IP instead of the proxy address.
- Audit gaps closed: profile name changes made through an API token are audited and committed with their event; an organization auto-deleted by SCIM deprovisioning is attributed to its owner; webhook delivery replays record who replayed (
webhook.delivery.replayed); pre-login cookie consent attached to an account is recorded (user.cookie_consent.linked). Catalog 82 to 84 (85 withscim.connection.token_rotatedfrom the better-auth 1.7 move). - Smaller defects: plan tiers no longer accept prototype names such as
constructor; a failed lease release no longer fails a committed digest flush; a scan payload that is not a list answers 400; a stored API token expiry parses back into a date; TOTP secret decryption failures reach telemetry; copied recovery codes are confirmed only after the clipboard write succeeds.
Em and en dashes were removed from every guide, inventory and the roadmap in the same pass.
Why: a month after the better-auth 1.7 pass, Node 26 became the active LTS, pnpm 12 and Bun 1.4.4 shipped, and eight packages had a new major. Staying one LTS and several majors behind turns the next refresh into a migration. This pass moves the runtimes, takes every minor and patch, then every major in its own commit, so each one can be reverted alone. Drizzle 1.0 and Postgres 19 are out on purpose: both are release candidates, not stable releases.
Runtimes:
- Node 26.13.0 LTS (
.nvmrc,mise.toml,engines.node), Bun 1.4.4 (also pinned inci.yml, which ranlatest), pnpm 12.16.1 (packageManager). - Node 25+ ships without corepack, so
corepack enable && corepack preparewould fail in anode:26image. The api dev, app dev and app prod images now install pnpm withnpm install -g pnpm@<version>, as the api prod image already did. npm 11 warns that the global install has its scripts blocked; pnpm runs correctly in every image (checked). - pnpm 12 lockfile: regenerated without a single resolution change. The only diff is a new leading document (
packageManagerDependencies, the pinned pnpm and its platform binaries). Unknown workspace settings are now an error;pnpm-workspace.yamlhas none. - The four Dockerfiles build on
node:26.12.1-alpineuntil the26.13.0-alpinetag is published: Docker Hub answered 404 for it on 28/10 (Node 26.13.0 shipped that day). 26.12.1 is the latest published Node 26 image, the same LTS line; move theFROMlines to 26.13.0 once the tag exists. - Keycloak (SSO profile)
26.0→26.8.1, the latest 26.x on quay.io.
Minors and patches (one commit): better-auth and every @better-auth/* plugin 1.7.6 → 1.7.14 (exact pins kept), hono, zod, drizzle-orm 0.45.4, the AWS and Sentry SDKs, React 19.3.1, TanStack, Vite 8.3.5, Vitest 5.0.5, Biome 2.6.3, Turbo 2.11.10, knip, jscpd and the rest of the outdated list. Two things came with it:
@better-auth/utils0.4.2 → 0.5.1 in the same commit. better-auth 1.7.14 depends on exactly 0.5.1, so keeping 0.4.2 installed two copies and two peer variants of@better-auth/core, and the api scripts stopped type-checking (theDBAdapterof one variant is not assignable to the other). Folding the bump in keeps every commit of the series compiling;pnpm whynow shows one copy. Apnpm dedupein the same commit removes a stale@types/nodethat split stripe into two peer variants.- Biome 2.6 formats Markdown and YAML by default:
ci:checkwent red on 35 files (every doc,ci.yml, the lockfile).biome.jsondisables both formatters, which restores the 2.5 behaviour; reformatting the docs and a generated lockfile does not belong in a dependency PR. lint-staged stops sending.md/.ymltobiome formataccordingly.
Fixes found on the way, each in its own commit:
- Every Docker build was red on the base. The images pruned with
npx turbo@2.9.6, which rejects theagentGuidancekey ofturbo.json(the repo resolves a newer turbo). The Dockerfiles now read the version from the rootturbodevDependency and run that exact version, so the pruner follows each bump. - Account deletion rejected valid TOTP codes for imported secrets. better-auth 1.7.14 decodes a stored
base32:<secret>(a secret imported from another provider) into bytes before computing the code;drizzle-rgpd.repository.tsused the raw string, so the deletion step-up would have refused a codetwoFactoraccepts. It now applies the same decoding. The new test, built on frozen bytes, fails on the old repository code (2 pass, 1 fail) and passes with the fix. - TOTP settings in one place.
shared/auth/totp-options.tsis passed totwoFactorand read by the deletion step-up, which kept its own copy of the plugin defaults. - Account deletion failed under secret rotation (predates the refresh). better-auth encrypts the TOTP secret with the config built from
BETTER_AUTH_SECRETS; the step-up decrypted withBETTER_AUTH_SECRETalone.env.tsvalidatesBETTER_AUTH_SECRETS,auth.tspasses it tobetterAuth, andauthSecretKeybuilds the key the repository decrypts with. - An unconfirmed TOTP enrolment confirmed an account deletion (predates the refresh). better-auth refuses a
verified = falserow at sign-in; the step-up did not. TheWHEREnow keeps confirmed rows and legacyNULLones, as better-auth does, proven on a real database bycheck:totp-enrolment(wired in CI).
Majors, one commit each:
- stripe 22 → 23: the SDK pins API version
2026-09-30.endive, now set instripe-client.ts.@better-auth/stripe1.7.14 peers on^23. The removed surfaces (payment_method_typeson several params,constructEventWithoutVerification,ErrorType) are used neither here nor in the plugin. - inwire 3 → 4: a container is single use after
dispose()and local-mode modules with missing prerequisites are refused; neither applies. 4.0 lets a container be assigned toRecord<string, unknown>, which removes a double cast inapps/api/src/index.ts. Inference checked: no binding typedany, an unknown key is a type error. - @hookform/resolvers 5 → 6: the only break is the effect-ts resolver; every form uses
zodResolver.packages/ui's optional peer moves to^6.0.1, else a 5.x copy stays installed. - @sentry/vite-plugin 5 → 11: plugins now release with the SDK and drop
@sentry/cli(itsallowBuildsentry is gone). A build against a local mock Sentry injected debug ids and left no.mapindist/. - tsup 8 → 9 (
packages/ddd-kit): the emitteddist/is byte-identical to the tsup 8 output.
What stays pinned, and why (measured again):
@simplewebauthn/serverstays on13:@better-auth/passkey1.7.14 (and 1.8.0-beta.3) still depends on^13.3.2. Moving the anchor to14.0.3, or removing it, brings back TS2883 onauth(AuthenticationResponseJSON,PublicKeyCredentialCreationOptionsJSON,PublicKeyCredentialRequestOptionsJSON).conventional-changelog-conventionalcommitsstays on9: semantic-release 25.0.9's stable analyzer and notes generator still bundleconventional-changelog-writer8. On 10.4.1 the analyzer answers butgenerateNotesthrows "requires conventional-changelog-writer@9"; on 9.3.1 a footerBREAKING CHANGE:is a major and a wrapped prose "BREAKING" stays a minor.- drizzle-orm stays on
0.45and Postgres on18: 1.0 and 19 are not stable yet.
Checks on the final series: ci:check 1006 files clean; turbo run build type-check test 21 tasks green, 1652 tests (api 922, app 364, ddd-kit 287, events 50, i18n 21, access-control 5, emails 3); jscpd 0.57 %; knip exit 0 (4 configuration hints, none new); the twelve real-database check:* scripts green; every commit of the series type-checks on its own. The four images build (api and app, dev and prod) on the published node:26.12.1-alpine, pulled fresh.
Live verification (local API on Postgres 18, curl): /readyz 200 (database pass, storage non-critical fail with the storage profile off); email sign-up 200, sign-in refused with EMAIL_NOT_VERIFIED until the link from the queued verify_email message is followed, then sign-in 200 and a valid session; the outbox holds org.created, org.member.joined, user.created and user.signed_in, all dispatched.
Not covered:
- No real Stripe call: the
2026-09-30.endiveAPI version has to be checked against the webhook endpoint in the Stripe dashboard, whose API version is set there. - Source map upload to a real Sentry project: proven only against a mock, never with a real auth token.
- Status: shipped, I.1 to I.5 merged into
devone lot per PR. - Date: Oct 2026.
- Why: OpenUp, a sister project on the same stack, hardened five foundations after clean-stack diverged from the shared Afterlive lineage. Today clean-stack has a thinner kit, layering enforced only by convention, telemetry that can carry PII, a transaction parameter threaded through every port, and an outbox that holds locks during handlers and loses user handlers on a throw. Each lot below buys one architectural property.
- Scope: five ordered lots, each its own branch off
dev(refactor/phase-i-1-kittorefactor/phase-i-5-outbox) and its own PR merged as a merge commit. Not ported because they are OpenUp-specific: a per-module outbox,RouteGuards,HttpResponse,PgSingleFlight,packages/audit,distbuilds, typed entity ids, theDomainEventHandlerclass, admin replay of dead events. - Rules for every lot: each lot ends on the full chain (
pnpm ci:check,turbo run build type-check test,check:duplication,check:unused, pluscheck:boundariesfrom I.2) and its real-database checks, then the 5-agent end-of-lot review (Opus for I.4 and I.5). Doc debt moves in the same commit as the code that changes the rule. - References: root
CLAUDE.mdrules 6 to 8,docs/EVENTS.md,docs/EVENT_PIPELINE.md, theevents-outboxskill.
- Why: a kit with
map,flatMap,match,Option.toResult, a monotonicuuidv7, event ids and JSON-based value-object equality removes hand-rolled glue in every module, and typed test doubles (mockPort) replace per-filemock.modulewiring. - Scope:
Resultcombinators and a typedResult.all;Option.toResult,tryMap,equals;UUID.parseand monotonicuuidv7; optionaleventIdon domain events (reused as the outbox row id);sameJsonfor value objects; an error type parameter onBaseRepositoryandScopedRepository;mockPortandmockInstrumentationbehind a@packages/test/bunsubpath. Purely additive. - Depends on: Dependency refresh merged.
- Done when: every new primitive has a named scenario in the
ddd-kitvitest suite,pnpm --filter api type-checkandpnpm --filter api testpass with no existing caller touched, and the 4 existingBaseDomainEventsubclasses compile unchanged.
- Why: layering that lives only in
CLAUDE.mdrots; a rule that fails the build does not. Orphan events (declared, never emitted) are the recurring trap of root rule 6. - Scope:
dependency-cruiserrules (no cross-module import, pure domain and application layers, packages never import apps, no circular dependency) wired into pre-push and CI ascheck:boundaries; a framework-freesource-scanhelper in@packages/test; four layering tests (no throw in domain and application, domain import allowlist, routes never write to the database, environment read only throughshared/env); a catalog parity test that fails on any event type nobody emits. - Depends on: I.1 (
source-scanlives beside the new test helpers). - Done when:
pnpm check:boundariesexits 0 with0 dependency violationslocally and in CI; two deliberate violations (a cross-module import, a@packages/drizzleimport inapplication/) each make it exit 1 naming the rule, shown in the PR text; the 4 layering tests and 2 parity tests pass; every allowlist entry carries a reason;no-circularis an error, or the remaining cycles are listed here.
- Why: a Drizzle failure message embeds the query parameters, so a swallowed email address can reach Sentry through the exception text; and a process killed mid-drain loses in-flight work and the last telemetry batch.
- Scope: scrub exception text, messages and breadcrumbs (cut after
params:, strip query strings, mask emails, drop console breadcrumbs);flush(timeoutMs)onIInstrumentation;runShutdownrunning ordered steps (grace, HTTP stop, webhook delivery, email delivery, outbox dispatcher, notification hub, database pool, telemetry flush) under a global deadline, with an explicitBun.servehandle; anHttpHeartbeatpinging<url>/start,<url>and<url>/failaround the cron sweep. - Depends on: I.1 (
mockPort), I.2 (boundaries gate). - Done when: the scrub tests show no
params:text and no email inexception.values,messageor breadcrumbs; the shutdown tests prove order, exit 1 on a failed or timed-out step, exit 1 with a flush on the deadline and exit 0 only when every step finished; a manualSIGTERMagainstbun run devshows the steps in order and exit 0 in under 5 s idle;bun --hotstill reloads.
- Why: threading a
txparameter through every port and repository makes a forgotten parameter a silent write outside the transaction. An ambient unit of work removes the parameter, so the transaction cannot be bypassed by omission. - Scope: an
AsyncLocalStoragescope carrying the transaction and collected events; nestedrunjoins the open transaction (no savepoint), a failedResultat any depth rolls everything back and returns the sameResult, events raised without an outbox roll back, a promise outliving its run reads the pool and never a closed transaction; repositories readresolveExecutor();mockUnitOfWorkwith the same nesting rules. Migration is total and in batches (shared ports, then billing, consents, notifications and policies, then api-token, profile, quotas, rgpd and webhooks, then the BetterAuth bridge, then the remainingdb.transactionsites), ending withIUnitOfWork.run(work: () => Promise<T>)and notxanywhere. - Depends on: I.1 (
mockPort, event collection), I.2 (a layering test enforces the end state). - Done when: a grep for
\btx\??:andITransactionovermodules/andshared/returns nothing;db.transaction(appears only inpackages/drizzleandscripts/;check:uow-rollback,check:auth-transaction,check:wipe-rollback,check:enqueue,check:marksent,check:scim-seatsandcheck:scim-connectionall pass against Postgres; everydb.transactionremoved has a recorded decision in its commit body.
- Why: today the dispatcher holds
FOR UPDATE SKIP LOCKEDlocks while the built-in subscribers run, and user handlers run after commit and are lost on a throw. A lease, handlers outside any transaction and per-handler tracking give at-least-once delivery without long locks. - Scope:
dead_atanddeliveries jsonbcolumns with partial indexes (migration before code, the old dispatcher stays safe); named handlers (subscribe(type, name, fn),deliverskips handlers already delivered); a claim by lease (FOR UPDATE SKIP LOCKEDplusUPDATE ... RETURNING, SQL clock, settle guarded byattemptsso a late settle writes nothing); a relay with bounded concurrency and a per-handler time budget, dead after 10 attempts then replayed every 6 h; listener reconnect with backoff and keepalive; audit, webhook fan-out and notification fan-out rewritten as idempotent named handlers;emitEventwithouttxand a new internaloutbox.event_deadevent emitted once; retention that never purges undelivered dead rows. - Depends on: I.4 (ambient enqueue, flush in the outer
run). - Done when:
check:outbox-leasepasses its scenarios (disjoint concurrent claims, lease expiry, stale settle writes nothing,deliveriesmerge, dead replay, rolled-back enqueue leaves no row, retention keeps dead rows) and runs in CI;EXPLAIN ANALYZEof the claim shows the partial indexes used; an end-to-end auth event showsdeliveriesnaming the three built-in handlers withattempts = 1, and a handler made to throw leaves a partialdeliveriesand alast_errornaming it; the catalog moves from 85 / 35 / 50 to 86 / 35 / 51 anddocs/EVENTS.mddescribes lease delivery. - As built (I.5): six handlers, each with its idempotence argument in
docs/EVENTS.md(audit-log,webhook-fanout,notification-fanout,revoke-api-tokens-on-membership-lost,notify-impersonated-user,notify-backup-code-used);emitEventreturns the id stored as the row id; the outbox sweep keeps readingdispatched_atalone, now proven on Postgres to keep a dead undelivered row. End to end, a sign-up withwebhook-fanoutmade to throw leftuser.created,org.createdandorg.member.joinedatattempts = 1,deliveriesholdingaudit-logandnotification-fanout,last_errornamingwebhook-fanout; once the throw was removed the retry ranwebhook-fanoutalone and all three rows were delivered with the three names. - Debt left open: the email queue and webhook delivery keep their own claims (process clock, no settle guard, no replayable dead state); closed by Queue lease alignment.
- Status: shipped on
refactor/queue-lease-alignment, four code commits then this one. - Date: Oct 2026.
- Why: after Phase I the outbox claimed by lease, but the email queue and webhook delivery still claimed with the process clock, settled on the id alone and had no replayable dead state. A worker whose lease expired could overwrite the one that reclaimed the row, double-count an attempt and announce a death twice, and a message that exhausted its retries was lost to the operator.
- Scope: shared SQL fragments in
packages/drizzle/src/lease.ts(dbNow,leasedBy,claimedSet,releasedSet) and a purefailureScheduleandretryAfterSecondsinshared/jitter.ts, outbox moved onto them; email queue on the lease model with a newdeadstatus replayed everyEMAIL_DEAD_RETRY_HOURSinsideEMAIL_DEAD_REPLAY_WINDOW_HOURS; webhook delivery on the same model,dead_letterreplayable whennext_attempt_atis set and terminal when null; the process-clock retry helpers removed fromjitter.ts; two migrations (0025,0026, index only, no data migration). - Decisions: (a) shared fragments, not a generic table-descriptor primitive and not three copies, since the dead markers, the settled markers and the claim filters diverge and a descriptor fights the typed builder (root rule 5) while copies are what let the process-clock bug spread (rule 2); (b)
failedstays the email permanent-rejection terminal and every existing webhookdead_letterstays terminal, so a deploy replays no old backlog; (c) the replay window keeps a stale sign-in or reset link from going out late; (d) the exhausted events fire once, on the first death, in the settle's unit of work, and root rule 6's exception now covers delivery queue bookkeeping (attempts, lease, dead state); (e) cadence replay plus manual webhook replay can deliver twice with different idempotency keys, documented rather than prevented; (f)db:pushdoes not detect a changed partial index predicate, so the migration is the source of truth; (g) the retry ceiling keeps the pre-lease count: a row dies on its 4th failed send and the exhausted event carriesattempts: 4, as before, so the per-queue ceiling is 4 now that the claim counts the attempt; (h) every network step is bounded (Resend batch 30 s, webhook lookup 5 s and request 30 s, all retryable) and a worker checks its remaining lease before each chunk or delivery and releases the rest, so a slow provider cannot outlive the lease and get the same rows sent twice; (i) the shared fragments stop at predicates (isDue,isDeadDue,deadShareOf): a genericclaimDueover the CTE union does not type-check on drizzle 0.45 without casts. - As built: claim sizes derived from the lease, 300 (email, 3 chunks at 120 s) and 8 (webhook at 330 s, capped at 10); boot refuses
EMAIL_DEAD_REPLAY_WINDOW_HOURS <= EMAIL_DEAD_RETRY_HOURS,EMAIL_LEASE_SECONDSunder the 30 s send timeout + a 15 s margin andWEBHOOK_LEASE_SECONDSunder the 5 s lookup + 30 s request timeouts + a 30 s margin; an email that fails to render is settledfailedalone; fan-out schedules new deliveries on the database clock; a stale settle writes no counter, event or endpoint bump (the webhook attempt row stays, a request was made); unstarted chunks and deliveries are released on stop; the webhook worker no longer holds a transaction across the request. Known quirk kept: a thrown webhookprocessDeliverymarks the deliveryfailedwith no organization. - Proof:
check:email-leaseandcheck:webhook-lease(new, with CI steps),check:marksentrewritten, the three lease checks on one shared harness, unit suites for the queues, the workers,env-boundsandjitter. Event catalog counts unchanged. - Rolling deploy: an old process settles blindly for about one lease, so an in-flight row can end with
attemptsoff by one; accepted.