Source: ddx-documentation/adr/0017-bff-layered.md
- **Status:** Accepted (plan `bff-layered`, 2026-10-24). - **Refines:** [ADR 0006](/en/docs/adr/0006-factory) (API clients), [ADR 0015](/en/docs/adr/0015-entity-kernel) (kernel routes), [ADR 0016](/en/docs/adr/0016-sse-bus) (`/events`). - **Extended by:** [ADR 0018](/en/docs/adr/0018-cluster-node-registry) (node pool, node identity). - **Plan:** `plans/bff-layered/` (architecture `01-architecture.md`, auth surface `04-auth-surface.md`, naming `10-naming.md`). - **Security evidence:** `findings/bff-security/re-review.md` (SHIP-WITH-FIXES), `plans/bff-layered/security-fold.md`. ## Context - The old forwarder relayed browser headers to ddx-api. Every new route needed a security review of what the browser could smuggle through. - The RSC server client read `headers()` for forwarding, so a browser `user-agent` and `sec-fetch-*` reached ddx-api (the tripwire `BROWSER_HEADERS_UPSTREAM` now warns on that). - Org data (active org, members, permission checks) came from the better-auth organization plugin (`/auth/organization/*`), outside `AccessPolicy`, projection and audit. - Hand-written controllers (media, ai, threads, notifications, settings, webhooks) lacked RBAC, projection or audit. ## Decision Two layered pipelines joined by ONE typed route table (`packages/ddx-contracts/src/bff-routes.ts`). The browser talks only to ddx-web `app/api/v1/**`. ddx-web server code (RSC, DAL, Route Handlers) talks only to ddx-api, always with `Authorization: Bearer <jwt>`. Rejected: a generic header pass-through proxy. It relays browser identity and makes every route a security review. ### Route classes | Class | Methods | Deadline | Cache | |---|---|---|---| | `json-read` | GET, HEAD | 10 s | RSC `'use cache: private'` + tag; browser relays ETag / 304 | | `json-mutation` | POST, PUT, PATCH, DELETE | 10 s | no-store; 2xx revalidates the class tag | | `stream-sse` | GET | headers only | no-store, `no-transform` | | `stream-ndjson` | POST | headers only | no-store | | `media-read` | GET, HEAD | headers only | `private`, Range / 206, `?disposition=attachment` | | `media-upload` | POST, PUT | 10 min | no-store | | `auth-browser` | per row | 10 s | no-store, relays `set-cookie` | | `server-only` | — | — | never browser (404): `/auth/token`, `/auth/organization/*` reads | The table is data. Kernel entity rows derive from the descriptor list, so a new descriptor gets json-read and json-mutation rows with no BFF code. ### ddx-web layers (`ddx-web/src/lib/bff/<layer>/`, request order) Route table (`routes/`) → same-origin gate (`origin/`: non-GET/HEAD needs own `Origin`, else 403 `CSRF_ORIGIN`; no CORS; OPTIONS answered by the BFF) → rate limit (`rate/`, fixed window per session and class, 429 + `Retry-After`) → header allow-list (`headers/`, per class) → node pool and bridge (`bridge/`, `pool/`, ADR 0018) → identity (`session/`: cached `ddx_api_jwt`, single-flight mint at `/auth/token`, refresh at exp − 30 s) → context (`x-request-id`, `x-locale`) → transport (`forward.server.ts`: `no-store`, `redirect:'manual'`, class deadline) → response relay (drop hop-by-hop and `set-cookie` except `auth-browser`) → error map (`errors/`: house envelope `{statusCode,message,code,requestId}`) → cache revalidation (`cache/`). `lib/api/forward.server.ts` and `lib/api/server.ts` stay thin adapters; the server client sends an EMPTY inbound header set. ### ddx-api layers (existing chain, gaps filled) Controller → `CredentialGuard` → `ClientThrottlerGuard` → `OrgContextGuard` → `OrgSlugGuard` → `RolesGuard` → `EntityAccessGuard` + `AccessPolicy` → service → `ProjectionService` → `ProofChainService` audit → `TenantPrismaService`. New: ETag / `If-Match` 412 interceptor, `PUT :id`, HEAD, `?disposition=attachment`, `ApiAuthzHeaderInterceptor` (`x-ddx-authz`), request-log tripwire. ### Read models replace the organization plugin | Route | Returns | |---|---| | `GET /orgs/:orgSlug/overview` | `{ org, me: { memberId, role, portalRoles }, can }` — `can` is the AccessPolicy-derived permission map; `requirePermission` reads it | | `GET /me/orgs` | the caller's orgs (`summary` projection) | | `GET /orgs/:orgSlug/dashboard` | dashboard counters | | `GET /orgs/:orgSlug/membership` | `{ org, member }` for the JWT caller; foreign and unknown slug → one 404 | | `PATCH /orgs/:orgSlug/members/:memberId/role` | role change with audit row and CAS | `/auth/organization/*` is `set-active` only. A non-member slug returns 404 (no existence oracle). ### Browser auth allow-list Browser may call only: `sign-in/email`, `sign-up/email`, `sign-out`, `get-session`, `request-password-reset`, `reset-password`, `verify-email`, `magic-link/*`, `email-otp/*`, `organization/set-active`. Everything else under `/auth/*` is 404 in the route table AND in ddx-api `AuthController`. Only `/auth/token` carries a cookie (session cookie names only). ## Consequences - Adding a route is a route-table row, not a security review. - Invariants 33-37 in `context/_invariants.md` bind the layers; gate `./ddx-manage.sh bff-layers`. - Open items from the re-review (S1 TOFU key pin, S3, S8, S11, INFO-3) are tracked in `findings/bff-security/re-review.md`. Last reconciled: 2026-10-24 · plan bff-layered task 016
bff-layered, 2026-10-24)./events).plans/bff-layered/ (architecture 01-architecture.md, auth surface 04-auth-surface.md, naming 10-naming.md).findings/bff-security/re-review.md (SHIP-WITH-FIXES), plans/bff-layered/security-fold.md.headers() for forwarding, so a browser user-agent and sec-fetch-* reached ddx-api (the tripwire BROWSER_HEADERS_UPSTREAM now warns on that)./auth/organization/*), outside AccessPolicy, projection and audit.Two layered pipelines joined by ONE typed route table (packages/ddx-contracts/src/bff-routes.ts).
The browser talks only to ddx-web app/api/v1/**. ddx-web server code (RSC, DAL, Route Handlers) talks only to ddx-api, always with Authorization: Bearer <jwt>.
Rejected: a generic header pass-through proxy. It relays browser identity and makes every route a security review.
The table is data. Kernel entity rows derive from the descriptor list, so a new descriptor gets json-read and json-mutation rows with no BFF code.
ddx-web/src/lib/bff/<layer>/, request order)Route table (routes/) → same-origin gate (origin/: non-GET/HEAD needs own Origin, else 403 CSRF_ORIGIN; no CORS; OPTIONS answered by the BFF) → rate limit (rate/, fixed window per session and class, 429 + Retry-After) → header allow-list (headers/, per class) → node pool and bridge (bridge/, pool/, ADR 0018) → identity (session/: cached ddx_api_jwt, single-flight mint at /auth/token, refresh at exp − 30 s) → context (x-request-id, x-locale) → transport (forward.server.ts: no-store, redirect:'manual', class deadline) → response relay (drop hop-by-hop and set-cookie except auth-browser) → error map (errors/: house envelope {statusCode,message,code,requestId}) → cache revalidation (cache/).
lib/api/forward.server.ts and lib/api/server.ts stay thin adapters; the server client sends an EMPTY inbound header set.
Controller → CredentialGuard → ClientThrottlerGuard → OrgContextGuard → OrgSlugGuard → RolesGuard → EntityAccessGuard + AccessPolicy → service → ProjectionService → ProofChainService audit → TenantPrismaService. New: ETag / If-Match 412 interceptor, PUT :id, HEAD, ?disposition=attachment, ApiAuthzHeaderInterceptor (x-ddx-authz), request-log tripwire.
/auth/organization/* is set-active only. A non-member slug returns 404 (no existence oracle).
Browser may call only: sign-in/email, sign-up/email, sign-out, get-session, request-password-reset, reset-password, verify-email, magic-link/*, email-otp/*, organization/set-active. Everything else under /auth/* is 404 in the route table AND in ddx-api AuthController. Only /auth/token carries a cookie (session cookie names only).
context/_invariants.md bind the layers; gate ./ddx-manage.sh bff-layers.findings/bff-security/re-review.md.Last reconciled: 2026-10-24 · plan bff-layered task 016
adr/
deploy/
research/