Features
July 3, 2026 · View on GitHub
Inventory of what ships in clean-stack. Everything below is wired, tested, and used in the codebase — clone, configure env, ship business logic.
For the as-built rationale (decisions, alternatives ruled out, security notes), see HISTORY.md. For what's planned, see ../ROADMAP.md.
Privacy policy / Terms versioning ✅ Phase A.2
RGPD Art. 7 demonstrability — records which version each user accepted and when. Foundation for A.4 (consent stamps the policy version) and A.5 (privacy dashboard shows acceptance history).
Shared SSOT (@packages/policies): POLICY_TYPES, POLICY_VERSIONS (both currently "2026-01-15"), POLICY_CHANGELOG. Source-only package imported by api, app, and @packages/drizzle. Bump a version string here → all users re-prompted automatically.
DB (packages/drizzle/src/schema/policies.ts): append-only policy_acceptance table — userId, policyType, policyVersion, ipAddress, acceptedAt. Index on (userId, policyType, acceptedAt DESC) for fast gate lookups. Durable 7-year trail lives in audit_log via the compliance event.
Backend module (apps/api/src/modules/policies/): compliance infra, not DDD.
PolicyAcceptanceService—acceptwrites N rows + emits Nuser.policy.acceptedevents in oneuow.runTX.getStaleTypesis the gate predicate.- Routes:
POST /me/policies/accept(body{ types?: PolicyType[] }— omit to accept all stale),GET /me/policies. requireCurrentPoliciesmiddleware (shared/middleware/policy.middleware.ts) — returns 409 when any policy is stale. Composable, not mounted globally — the_shellredirect is the live enforcement; this is opt-in defense-in-depth for future business routes.
Sign-up acceptance: recorded server-side at the BetterAuth /verify-email after-hook (idempotent via getStaleTypes), not at /sign-up/email. Reason: /sign-up/email has no session yet and returns a synthetic user on duplicate-email; /verify-email has a reliable session userId. See HISTORY.md for the full deviation note.
Frontend (apps/app/src/features/legal/):
- Sign-up
acceptedPoliciescheckbox (z.boolean().refine) linking to the policies via<PolicyLink>(new tab, so a misclick doesn't wipe the form). - Public
/legal/privacy-policy+/legal/termspages — placeholder content keyed by version,PolicyDocViewcomponent,policies.config.tsx+getChangesSincehelper. - Acceptance gate
/legal/accept(under_protected, outside_shell) — adapts: first-time user (magic-link/social, no checkbox shown) sees a "Before you get started" welcome; a returning user with a stale version sees the changelog diff. One Accept button._shellbeforeLoadredirects here when any policy is stale (fail-open if the policies endpoint errors). - Hosting-agnostic content: the full policy text ships in-app as placeholder, but every link resolves
POLICY_URLSfrom@packages/policies. Hosting the real policies on a marketing site/CMS is a one-line swap there (point the URL external, delete the in-app pages) — the versioning + acceptance machinery is untouched.
Event: user.policy.accepted — self-actor, compliance retention.
Profile editing + NIST 800-63B-4 password baseline ✅ Phase A.1
GDPR Art. 16 rectification surface + SOTA-2026 password policy, both wired into the existing /settings/account page.
Profile editing (features/account/account.page.tsx — ProfileCard):
- Edit display name (max 80 chars) + email (re-verification via BetterAuth
user.changeEmail, confirmation sent to the current address) + avatar (three-step presign→PUT→confirm viacreateUploadMutationOptions, with client-sideimage/*+ 5 MB guard). - Pending email change badge visible until the new address is verified.
ChangePasswordCard— standalone card for password update, below the profile fields. Passkeys/2FA/Sessions/DataExport cards remain unchanged.
Password baseline (NIST SP 800-63B-4):
- Min 15 chars everywhere (
emailAndPassword.minPasswordLength: 15). No MFA exception — 15 is universal. - No complexity rules —
strongPasswordSchema(shared/auth/auth.schema.ts) ismin(15).max(128)only; uppercase/digit/symbol regexes removed. Applied to sign-up + password-reset flows. - HIBP breach screening at sign-up / password-change / reset — k-anonymity SHA-1 prefix (
api.pwnedpasswords.com/range/<sha1[:5]>,Add-Paddingheader). PortIPasswordBreachService+HibpPasswordBreachService(shared/services/). Timeout configurable viaHIBP_TIMEOUT_MS(default 3000 ms). Fail-open — HIBP outage never blocks auth. - Contextual ban-list (
shared/password-policy.ts,findPasswordViolation()) — bans email local-part, display name, and app name. Zero I/O, pure-compute. Common passwords are left to HIBP (no redundant inline list). The full policy is wrapped in a testablevalidatePassword()(length-guard → ban-list → HIBP); the BetterAuthhooks.beforeis a one-line caller. - Field UX (NIST-aligned) —
FormTextFieldships a show/hide reveal toggle + a per-field hint on every new-password input (sign-up / reset / change). Server policy errors (breach / ban / wrong current password) render inline on the field, not as a toast. - Validation via
auth.tshooks.beforeon/sign-up/email,/reset-password,/change-password(returnsAPIError422).
Auth — BetterAuth ✅
End-to-end authentication on Bun + Hono, no hacks.
- Email + password with required verification + password reset (forgot-password flow → token via app URL).
- Magic link (passwordless email).
- Passkeys (
@better-auth/passkey, WebAuthn) — registered & managed from/settings/account(passkeys-card,add-passkey-form). - Two-factor (TOTP, backup codes) — enable / disable from
/settings/account(two-factor-card,enable-two-factor-form,disable-two-factor-form). - Active sessions — list & revoke from
/settings/account(sessions-card). - Bearer tokens alongside cookies — web stays cookie-based (httpOnly, XSS-safe), Capacitor uses bearer.
- Session cookie cache (5 min signature-only check; DB is source of truth at expiry → instant revoke).
- Cross-tab sync via
BroadcastChannel(shared/auth/auth-broadcast.ts) — sign-in / sign-out / verify / 2FA / org change refetch live in every tab. - Token-consuming routes outside the auth gate (
/verify-email,/reset-password,/magic-link,/two-factor,/accept-invitation/$invitationId) with StrictMode-safeuseRefguard against single-use token re-fire. - Layout route gates (
_protected/_guest) inline inapps/app/src/router.tsx— auth state read once viaensureQueryData(sessionQueryOptions)inbeforeLoad.
Pages shipped: sign-in, sign-up, verify-email, forgot-password, reset-password, magic-link, two-factor.
Multi-tenant — BetterAuth organization plugin ✅
Org-scoped from the very first migration. Migrating single-user → multi-tenant later is hell; the reverse is free.
- Personal org auto-created on signup (
ensurePersonalOrgForself-heal indatabaseHooks.user.create.afterandsession.create.before). Slug patternpersonal-${uuid}, never deletable, never leavable. - Team orgs with slug auto-gen, invitations (email-based with
@better-auth/organization), role-based members, transfer ownership, leave. - Auto-cleanup —
afterRemoveMemberdeletes empty non-Personal orgs;beforeDeleteOrganizationrejects Personal deletion. - Pages shipped:
/organization/new,/settings/general(rename + leave/delete danger zone),/settings/team(members + invitations + role updates),/dashboard(org-scoped landing),/invitations(incoming list). - Mutations shipped:
create-org,update-org,delete-org,leave-org,transfer-and-leave,set-active-org,invite-member,accept-invitation,cancel-invitation,remove-member,update-member-role. - Capability-based authorization —
@packages/access-controlis the single source of truth (ac,roles,OrgRole/OrgPermissions,authorizeRole). Three layers, same predicate:- Server:
requireOrgPermission({ resource: ["action"] })middleware. - Route gate:
ensureOrgPermission(perms)inbeforeLoad. - UI:
<Can requires={...} fallback={...}>+useAuthorization().can().
- Server:
- Owner transfer —
transferAndLeaveMutationOptionsfor last-owner-leaves flow. - Dev-only
<AuthorizationDevTool>— live capability matrix per role (mounted by the app shell, tree-shaken in prod). NO_ACTIVE_ORGANIZATION→nullat the query layer (transient state, not error).
Email — Resend ✅
Dashboard-managed templates with retry + idempotency. Provider-side suppression guards IP reputation (hard bounces & complaints auto-blocked).
- Typed templates (
EmailTemplatestype,TemplateVariablesper template). - Idempotency keys per (template, token) — token reuse never re-sends.
- DNS hardening required before production: SPF, DKIM (3 CNAMEs from Resend), DMARC. Gmail/Yahoo/Outlook reject unauthenticated bulk senders since 2024-2025. See
README.mdfor records. - Boundary-only:
EmailServiceadapter implementsIEmailServiceport; failure logs atwarnif transport not configured (dev),throwonly on hard provider failure.
Storage — S3-compatible (Cloudflare R2 prod / SeaweedFS dev, opt-in) ✅
Server is blind during the upload — three-step flow presign → PUT direct to provider → confirm.
- Provider-agnostic S3 SDK config (
region: "auto",forcePathStyle: true). Boot-time fail-hard on localhost endpoint or default creds in production. - Owner-scoped keys —
<userId>/<scope>/<uuid>-<filename>. Download + confirm reject keys without<requestingUserId>/prefix (STORAGE_FORBIDDEN). - Confirm mandatory — server
HeadObjectvalidates size/contentType, deletes on mismatch, returns server-verified{ key, size, contentType, publicUrl }. - Validation at controller (
modules/uploads/application/dto/*.dto.ts): filename regex, scope regex, size cap, max TTL. - Multi-step factory —
createUploadMutationOptionsresolves only afterconfirmsucceeds; UI never sees "maybe uploaded" intermediate state. - Why three steps: providers like R2 don't support Presigned POST policies (no
content-length-range, verified 2026). PUT presigned +confirmis the correct shape. - Use-cases shipped:
create-upload-url,confirm-upload,create-download-url. Routes:POST /uploads/presign,POST /uploads/confirm,POST /uploads/download.
RGPD / CCPA — erasure (Art. 17) + portability (Art. 20) ✅
Deletion + export cascade built before Billing/Audit so every future feature inherits the contract. A clone deployed to EU users is compliant day one.
- Export —
POST /me/export, auth-gated, sync (walks the user's tables, uploads JSON to R2, emails a signed 7-day URL via Resend). Rate-limited 1/24h per user. - Delete —
POST /me/delete, 2FA-required + server-side sole-owner preflight re-check + 7-day soft-delete grace. Cron/internal/rgpd/process-pending-deletions(HMAC-signed) wipes personal data (email, name, sessions, passkeys, MFA, R2 avatars) and anonymizesmemberrows (userId → null, tombstone email) so org audit trails stay intact. - Pre-flight gate —
GET /me/delete/preflightlists sole-owner non-personal orgs blocking deletion; UI shows per-row Transfer/Leave CTAs, Delete button disabled until cleared. No implicit auto-transfer (consent). - Cancel UX — sign-in during the grace window prompts cancel/continue; clears
pendingDeletionUntil. - Soft-delete confined to RGPD —
deletedAt+pendingDeletionUntilare the only soft-delete columns; everything else hard-deletes. - Public
/legal/data-rights— lists what's deleted vs anonymized vs retained per legal basis. - Events —
user.deletion.{requested,cancelled},user.deleted,user.export.{requested,completed}→complianceaudit trail.
Frontend cards (features/rgpd/): DataExportCard, RgpdDeletionCard (+ preflight blocking list), cancel dialog. See HISTORY.md for decisions.
API — Hono on Bun ✅
- Native
Bun.serve()(no@hono/node-server) —bun buildfor prod (~7 ms cold),bun --hotfor dev. - Hono RPC end-to-end types via
hcWithType(one client instance,tscresolves once). Custom fetch slot forX-Request-Id, future 401 handler / token refresh / Capacitor Bearer. - Pipeline (in order):
requestId→httpLogger(pino) →secureHeaders+cors→sessionMiddleware(oneauth.api.getSession()per request) →auth.handlerfor/api/auth/*→app.onError(single error envelope). - CQRS: Commands route through Use Cases; Queries hit Drizzle directly (no use case ceremony).
- DI via
inwire— type inference, no declared interfaces,AppDeps = typeof di. - Logging:
pino(JSON in prod,pino-prettyin dev), every line carriesrequestId, status-driven log level.
App — Vite + React 19 + TanStack ✅
- TanStack Router code-based — features own their routes via
<name>.route.tsx(route definition) +<name>.page.tsx(page component, code-split chunk vialazyRouteComponent). Layouts/gates exported fromapps/app/src/router/layouts.tsx. Routes assembled in a single hand-writtenapps/app/src/router.tsx. Noroutes/folder, norouteTree.gen.ts, no Vite plugin watcher. TanStack Start migration is near-zero refactor. - Route-level code-splitting — each
<name>.page.tsxships as a lazy chunk (current floor: ~588 KB initial bundle, individual route chunks 1-43 KB).defaultPreload: "intent"triggers prefetch on hover/focus before the click — perceived latency near zero. - Devtools wired in
app-providers.tsxbehindimport.meta.env.DEV(TanStack Router devtools + React Query devtools, tree-shaken in prod). - TanStack Query for all server state — session, active org, current membership, orgs list. Mutations via
mutationOptionsfactories (call-site owns side-effects); hook wrappers only when side-effects always fire. - Forms:
react-hook-form+@hookform/resolvers/zod+ shadcnForm. MandatorydefaultValues, never manual submit handlers. - Schema split loose vs strict — same field validated differently in capture (sign-in) vs creation (sign-up / reset).
- Theme:
next-themes(attribute="class",defaultTheme="system") + View Transitions API circle reveal withprefers-reduced-motionfallback. - Toasts:
sonner.
App shell — top-nav + ⌘K palette ✅
- Sticky header with org switcher, theme toggle, user menu.
- Contextual settings tabs filtered by capability (
SETTINGS_TABSdeclaresrequires+requiresOrg). - Command palette (⌘K) —
NAVIGATION_ROUTESfiltered by capability. - Logo mark — custom shadcn-pure primitive.
UI — shadcn/ui (@packages/ui) ✅
Full shadcn/ui registry pre-installed, shadcn-pure rule enforced (use real slots, no pt-6 / space-y-4 patches).
- Typography exports —
TypographyH1/H2/H3/H4/P/Lead/Large/Small/Muted/InlineCode/Blockquote/List. Never raw<h1 className="text-5xl">. - Custom primitives (all
asChild-compatible, all in@packages/ui/components/ui/):NavLink— variantsplain/pill/underline+activeflag. Primitive owns style, router owns navigation:<NavLink asChild variant="pill" active={isActive}><Link to="/x">…</Link></NavLink>.BrandLink— logo wordmark slot.TextLink— inline underline-on-hover anchor.DestructiveActionDialog— confirm-text dialog for irreversible actions.ListRow— list-item primitive.FormTextField— RHFController+ shadcnInputwrapper (label + error + description).
- Theme tokens in
packages/ui/src/styles/globals.css@theme.classNamereserved for layout (flex,gap-*,mx-auto); colors / typography / radius live in theme.
DDD-kit (@packages/ddd-kit) ✅
Primitives for the business domain only (rule: never DDD for billing / auth / gating).
Result<T, E>— no throw in domain or application.Option<T>— nonull/undefinedfor absence.Entity,Aggregate,ValueObject(zod-validated viaprotected validate()),UUID,DomainEvent,BaseRepository,UseCase,QueryHandler.- Events added in aggregate methods (
this.addEvent(...)), dispatched in use cases AFTER successful persistence.
Database — Drizzle + Postgres 17 ✅
- Postgres on
localhost:5433(dedicated port, no clash with other local instances) viadocker compose up postgres -d. - Schemas in
packages/drizzle/src/schema/*.ts— auth tables, organization tables. TransactionService— controllers manage transactions and pass to use cases.withOrg(table, orgId)helper for org-scoped list queries (rule: org-scoped tables NEVER queried without it).
Tooling — zero-warning pipeline ✅
- pnpm 10 + Turborepo (TUI, daemon-managed,
globalDependenciesbust caches onbiome.json/pnpm-workspace.yaml/.env*). - Biome — lint + format, single source.
- knip — unused exports / files.
- jscpd — duplication detection.
- Husky + commitlint — Conventional Commits enforced (lower-case subject).
- lint-staged — Biome on staged files only.
- Pre-push — full
pnpm ci:check. - semantic-release —
dev→mainmerge commit triggers bundled bump + changelog.feat→ minor,fix/perf/refactor/build→ patch,BREAKING CHANGE:→ major. bun test(api) +vitest(packages, app) — BDD style, mock at port level, testResult/Optiontransitions.
Observability ✅
pino+hono-pino— JSON in prod,pino-prettyin dev.infoprod /debugdev. Status-driven HTTP log level (5xx→error,4xx→warn).- Single
app.onError(errorHandler)—HTTPException→{ error: { code, message, requestId } }. No per-route try/catch. - Request ID propagated via
X-Request-Idheader; every log line carries it. - Sentry error tracking (Phase 0.4) —
@sentry/bun(api) +@sentry/react(app), NoOp withoutSENTRY_DSN.errorHandlercaptures>= 500automatically withrequestId/userId/orgId/path/methodtags ;<Sentry.ErrorBoundary>wraps the app provider tree. RGPD-clean scrubbing (whitelist drop ofCookie,Authorization, request body, query string,email,username,ip_address).pinoIntegrationturns everylogger.warn|errorinto a breadcrumb.@sentry/vite-pluginuploads source maps in CI whenSENTRY_AUTH_TOKEN+SENTRY_ORG+SENTRY_PROJECTset. OTel + Prometheus/metricsdeferred to Phase D.1 (Bun OTel manual + no Grafana consumer yet). See./OBSERVABILITY.md.
Disaster recovery ✅ (doc-only)
PITR delegated to the managed Postgres provider (Neon/Supabase/RDS/Railway one-click) — primary defense. clean-stack ships no backup code on purpose: SOTA 2026 closed the case (pgBackRest unmaintained, providers ship PITR sub-minute RPO).
docs/DISASTER-RECOVERY.md— RPO/RTO targets, 3-2-1 rule applied, PITR setup per provider, restore runbook, lifecycle + versioning snippets.- Weekly portable
pg_dumpexport — copy-paste recipes for GitHub Actions, Railway Cron, and K8s CronJob. Streamspg_dump | gzip | aws s3 cp -(no OOM). Targetsbackups/postgres/<ISO>.sql.gzin the existing S3 bucket. Read-only Postgres role mandated. - Monthly automated restore-test — GitHub Actions workflow recipe spawns Postgres
:17-alpine, downloads latest, restores, runs inlinepsql count(*)smoke check, fails loud.
Event-driven foundation ✅
Transactional outbox + dispatcher + audit/webhook subscribers. Zero plumbing post-clone — the dev declares an event in packages/events, calls addEvent() in their aggregate, runs the use case via uow.run() and the rest is automatic (audit log row, webhook fanout to subscribed clients, in-process handlers via onEvent(...) auto-discovered through inwire).
- Outbox:
outbox_eventtable, UUID v7 PK, partial index on pending rows,pg_notifytrigger ensured idempotently at boot (CREATE OR REPLACE TRIGGER). - Dispatcher: in-process Bun worker, dedicated
pg.ClientLISTEN + 30s poll fallback +SELECT ... FOR UPDATE SKIP LOCKEDdrain (multi-instance safe). Built-in subscribers run inside the dispatch TX (atomic), useronEventhandlers post-commit (isolated). - Audit log (
audit_log, SOC2 §CC7.2 / ISO 27001) — append-only, retentionoperational(90d) vscompliance(7y) driven byRETENTION_MAP. Tamper-evidence columns posed (prev_hash/hash), calc gated by env flag. - Outbound webhooks (
webhook_endpoint+webhook_delivery) — HMAC-SHA256 signed (t=<ts>,v1=<hex>Stripe-style), AEAD-encrypted secrets at rest (@noble/ciphersXChaCha20-Poly1305 + HKDF per org). Decorrelated jitter retry (1m/5m/30m/2h/12h paliers), dead-letter after 5 attempts, replay endpoint. Claim window pattern in delivery worker — fetch HTTP outside TX, no lock starvation. - BetterAuth bridge (
auth.ts) emits 23 unique events automatically (15 user + 8 org) via 4 voies: user/session lifecycle (databaseHooks), MFA/passkey/email-verified/password-changed/profile-updated/email-change-requested/link-social (hooks.afterwithcreateAuthMiddleware,APIErrorfilter), password reset / magic link (native callbacks), org/member/invitation (organizationHooks, with bothafterAddMemberANDafterAcceptInvitationforORG_MEMBER_JOINEDto cover direct adds + invite acceptance). RGPD service emits 5 more, UploadService emits 3, WebhooksService emits 3, PolicyAcceptanceService emits 1 (user.policy.accepted), SecurityMiddleware emits 3 (security.rate_limit.exceeded,security.csp.violation,security.csrf.rejected) → 38 events total. - Catalog
@packages/events— 38 events with Zod payloads +RETENTION_MAP, shared api+app+future workers. - Request correlation — every event carries the originating request's
X-Request-Idinoutbox_event.metadata.requestId(captured via anAsyncLocalStoragecontext, works inside BetterAuth hooks too), copied intoaudit_log.request_idso audit rows join to their logs + Sentry event on one key.
Security & hardening ✅ Phase C.1
Deploy-safe perimeter — rate-limit, strict CSP, and stateless CSRF protection, all wired before any business feature.
Rate-limit (apps/api/src/shared/middleware/rate-limit.middleware.ts):
- Unified Hono middleware (
rate-limiter-flexible); BetterAuth built-in rate-limit disabled — one policy wins, no double-counting. - Policies:
global(all routes) + 8 auth-burst policies (/sign-in/email,/sign-up/email,/magic-link,/reset-password,/change-password,/verify-email,/two-factor,/passkey) with tighter windows. - IETF
RateLimit/RateLimit-Policy/Retry-Afterheaders on every rate-limited response. - Fail-closed on auth — a store outage throws 503 on auth routes rather than silently skipping the guard (OWASP A10:2025). Global routes fail-open.
- Trusted-proxy IP resolution:
TRUSTED_PROXIES=private(Railway/Fly), CIDR, or exact IP. OWASP rightmost-non-trusted algorithm — first untrusted IP wins. - Store progression: memory (dev / single-replica) → Postgres (multi-replica, dedicated pool isolated from app queries). Redis is not yet implemented — envisaged if multi-replica pressure on the DB justifies it (would require a new factory + enum value).
- Front:
sonnertoast on 429 with countdown fromRetry-After.
CSP (Caddy + Vite, not a Hono middleware):
- Per-request nonce via Caddy
{http.request.uuid}injected into theContent-Security-Policyheader; same value forwarded to Vite viahtml.cspNoncemeta tag. 'strict-dynamic'policy — no'unsafe-inline', no host allowlist.- Public
POST /csp-reportendpoint: IP-rate-limited,Cross-Origin-Resource-Policy: cross-origin(browsers need it for report delivery), document-uri origin filter (drops 3rd-party extension noise). - Emits
security.csp.violationaudit event (operationalretention). - Trusted Types deferred (no eval/DOM-sink usage today — land when needed).
CSRF (apps/api/src/shared/middleware/csrf.middleware.ts):
- Origin-allowlist on unsafe HTTP methods (
POST,PUT,PATCH,DELETE). Stateless — no token, no cookie, no endpoint to maintain (the Next.js Server Actions / SvelteKit model). - Allowlist is the CORS origin list (
CORS_ORIGIN) — single source of truth, zero drift. - Bearer-skip: requests with
Authorization: Bearer …bypass the check (Capacitor mobile, PATs, internal HMAC-signed calls). - Emits
security.csrf.rejectedaudit event (operationalretention).
Hardened headers (Caddy, no Hono duplication):
Strict-Transport-Security(HSTS, 1 year, includeSubDomains).Content-Security-Policy: frame-ancestors 'none'(clickjacking).X-Content-Type-Options: nosniff.Referrer-Policy: strict-origin-when-cross-origin.Permissions-Policy(camera, microphone, geolocation off by default).
Prod boot guard: api fails hard (process.exit(1)) on missing CORS_ORIGIN — a silent empty-string allowlist would make CSRF protection a no-op.
Events (3 new, operational retention): security.rate_limit.exceeded · security.csp.violation · security.csrf.rejected. Brings the catalogue to 38 events (23 BetterAuth + 5 RGPD + 3 uploads + 3 webhooks + 1 policy + 3 security).
See ./EVENTS.md for the full DX guide (how to add an event, build a handler, multi-tenant safety, BetterAuth bridge specifics, HMAC verification, known limitations).
Roadmap (not yet shipped)
See ../ROADMAP.md for the full plan with constraints + extension points.
- Billing — Stripe via
@better-auth/stripe(customer portal, subscriptions, signed webhooks, customer = per organization). - Feature & quota gating — config + middleware layer (no DDD).
- Admin & impersonation — BetterAuth
adminplugin. - Front UI for audit log + webhooks — API ready, app-side pages remain.
- Tamper-evidence audit hash chain — columns posed, calc deferred until SOC2 audit demands.
- Domain-event → telemetry subscribers (Sentry breadcrumb / OTel span attr / Prom counter per event-type) — trivial
onEvent(...)additions. Sentry capture on 5xx errors already wired viaIInstrumentation(Phase 0.4); OTel + Prom subscribers land with Phase D.1 Grafana consumer. - i18n — TanStack Router locale routes + typed message catalogs.