# BFF & Gateway

The backend-for-frontend (BFF) tier is the runtime seam between a product
surface and the domain orchestration behind it. A surface — the consumer web
shell, the mobile app, an Unreal client, an admin console — never talks to a
domain library or a database directly; it talks to a BFF, and the BFF is the one
place that authenticates the session, decides what that principal is allowed to
see, fans the request out across however many domains a screen needs, folds the
results into one shaped envelope, and emits the cross-cutting governance
(idempotency, residency, abuse, budget) every write must pass through. It is
shared rather than per-product for the same reason the rest of the platform is:
the rules for "is this caller authenticated, entitled, in-region, within budget,
and replay-safe" are identical across nine products, so they live in one gateway
codebase instead of nine. The primary gateway is `@oshun/bff`
(`apps/oshun/bff`), a Fastify TypeScript service of **~1,780 TypeScript files**
(~1,055 of them non-test); its `src/app.ts` is an 83 KB composition root that
decorates a shared adapter bundle and wires roughly **460 `app.register(...)`
route-module plugins** over the ~490 files in `apps/oshun/bff/src/routes/`.

This page is the BFF slab of the layered model in
[the platform overview](./overview.md): the box labelled "BFF & gateway" that
sits directly under the product surfaces and directly above
[domain orchestration](./oshun-domain-libraries.md). It documents what a BFF is
responsible for, the real routing and module structure of `apps/oshun/bff/src`,
and — candidly — how the four gateways differ in maturity. Three are real, of
very different depth (`@oshun/bff` at ~1,780 files, `@lilith/bff` at ~300,
`@kalika/bff` at 8), and one — `apps/urania/bff` — is a scaffold with no source
yet. The page labels those distinctions rather than implying uniform depth, in
keeping with the repository's no-stub culture and the docs center's implemented
/ spec-only / provider-gated convention.

## What ships, honestly

The Oshun gateway is **real and load-bearing**. Auth resolution, domain-scope
authorization, the typed domain-adapter bundle with circuit breakers, the
cross-domain aggregators with partial-failure envelopes, server-side
idempotency, residency enforcement, abuse protection, entitlement evaluation,
and the agentic governance gate are all implemented TypeScript with sibling
tests — not placeholders. The composition is deliberately built around
**injectable seams that default fail-soft**: `createApp(options)` accepts a
durable admin store bundle, an audit-events store, a Redis idempotency store, an
LLM synthesizer, and an account-deletion runner; when any is omitted the gateway
falls back to an in-memory or extractive default so dev and test boot without a
database, and `apps/oshun/bff/src/server.ts` (the process entry) is where the
real Redis/Postgres-backed implementations are injected. That is an honest seam,
not a fabricated success: an absent dependency degrades loudly or stays
in-memory, it does not pretend.

Two honest nuances matter. First, the domain adapters are a **hybrid**: `tara`,
`veritas`, and `arete` front real downstream HTTP services (`/v1/oshun/*`
facades) with retry and circuit-breaker policy, while `nyx`, `nisaba`, and
`metis` default to **in-process adapters** because their real sources
(ephemeris, the passage corpus, the tutor stores) run inside the BFF — each is
flippable to HTTP with an env var (`OSHUN_NYX_ADAPTER=http`, etc.). Second,
gateway maturity is genuinely uneven across products, covered below. Where a
behaviour is environment-gated (signed JWTs in production, durable stores when a
database is present) the page says so.

## The gateway tier at a glance

| Gateway       | Path              | Source `.ts` | Shape                                                                                          | Maturity              |
| ------------- | ----------------- | -----------: | ---------------------------------------------------------------------------------------------- | --------------------- |
| `@oshun/bff`  | `apps/oshun/bff`  |   **~1,780** | The deep, production-shaped gateway: ~460 route plugins, typed adapters, full middleware stack | **Implemented, deep** |
| `@lilith/bff` | `apps/lilith/bff` |     **~300** | Response-shaping + synthesis gateway fronting the Isis/Sophia capability domains               | **Implemented**       |
| `@kalika/bff` | `apps/kalika/bff` |        **8** | Small domain-scoped gateway: compute/agents/notebooks proxy + realtime workbench               | **Implemented, thin** |
| `urania/bff`  | `apps/urania/bff` |        **0** | Directory exists with `node_modules` only — no `src`                                           | **Scaffold**          |

The rest of this page is mostly about `@oshun/bff`, the gateway every V1 surface
depends on; the others are characterized honestly at the end.

## What a BFF is responsible for

### Session and auth enforcement

Auth is the gateway's first job and lives in
`apps/oshun/bff/src/middleware/authz.ts`. `createAuthPreHandler()`
(`authz.ts:99`) is a Fastify `preHandler` that requires an
`Authorization: Bearer <token>` header (else `401 missing_bearer_token`),
resolves the token, and decorates the request with an `OshunAuthContext`
(`{ userId, scopes, exp, sessionId?, homeZone?, tenantId? }`). Token resolution
in `resolveBffAuthToken` (`authz.ts:192`) accepts three credential shapes:

- **Dev tokens** — `dev.<base64url-json>` carrying `{ sub, scopes, exp }`.
  Allowed only outside production and only when `OSHUN_BFF_ALLOW_DEV_TOKENS` is
  not `false`.
- **Tenant session tokens** — `tenant.<base64url-json>` with a mandatory `tid`
  claim, sharing the dev gate until signed tenant sessions land.
- **Signed HS256 JWTs** — verified in `verifySignedJwtAuthToken`
  (`authz.ts:323`) with a constant-time HMAC comparison (`timingSafeEqual`,
  `authz.ts:573`), issuer/audience matching, and `exp`/`nbf` checks against a
  configurable clock tolerance. The secret must be at least
  `MIN_HMAC_SECRET_LENGTH = 32` chars.

The production posture is fail-closed by construction: `isProductionRuntime`
(`authz.ts:522`) keys off `NODE_ENV` / `OSHUN_ENV` / `RUNTIME_ENV`, and in
production unsigned `dev.`/`tenant.` tokens are rejected (`dev_tokens_disabled`)
and a missing signed-JWT secret yields `signed_auth_not_configured` rather than
a silent downgrade. The full identity/session/consent model these tokens ride on
is detailed in [Auth & Identity](./auth-identity.md).

### Domain-scope authorization

Authentication answers "who"; authorization answers "what". A protected domain
route additionally requires the principal to hold either the wildcard `domain:*`
scope or the specific `domain:{domainId}` scope.
`createDomainAuthorizationPreHandler()` (`authz.ts:124`) validates the route's
`domainId` against `isKnownDomain` from `@oshun/domain-registry` (`400` on an
unknown domain), then checks scope membership and returns
`403 domain_scope_missing` when absent. Per-domain route files inline the same
check — `routes/tara.ts`'s `requireTara` returns the `userId` or replies
`401 missing_auth_context` / `403 domain_scope_missing` before any adapter is
touched. This is the same fail-closed shape the
[Sophia grounding routes](../../V1/features/sophia-grounding.md) use; nothing
reaches a domain library until both gates pass.

### Composing domain calls for a surface

The defining BFF responsibility is composition: a single screen needs several
domains, and the gateway is what calls them in parallel and assembles the
result. The composition substrate is the typed adapter bundle decorated onto the
Fastify instance as `app.domainAdapters` (`app.ts:565`), built by
`createDomainServiceAdapters` in the 78 KB
`apps/oshun/bff/src/adapters/domain-service-adapters.ts`. Each HTTP adapter
wraps a `DomainServiceCircuitBreaker` (`domain-service-adapters.ts:946`) and a
`fetchWithTimeout` with a per-domain retry policy; `probeDomainHealth` (`:2470`)
normalizes heterogeneous upstream `/healthz`-style payloads into one
shell-facing `ok | degraded | down` model and short-circuits when the breaker is
open. Every outbound call injects the tenant header
(`injectTenantHeaderIntoHeaders`) and residency-routing headers
(`injectResidencyRoutingHeadersIntoHeaders`) so downstream services inherit the
caller's tenant and home zone.

The cross-domain aggregators are where composition is most visible.
`routes/home.ts` builds a `HomeRoutePayload` by fanning `fetchDomainHighlights`
/ `fetchDomainContinue` / `fetchDomainFavorites` across only the domains the
principal is scoped for (`resolveAuthorizedDomains`), then records a
**per-domain status**
(`domainStatus: Record<DomainId, 'ok' | 'degraded' | 'forbidden'>`) and a
structured `errors` list. A failure in one domain does not fail the request: the
envelope carries `partial` and `partialFailure` booleans, and the route emits
`x-oshun-partial-response`, `x-oshun-partial-failure-count`, and
`x-oshun-degraded-domains` response headers (per the BFF README) so the surface
can render what succeeded and badge what degraded. The same pattern backs
`/continue`, `/activity`, `/library`, and `/search`.

A concrete read path, `GET /v1/tara/recommended`, shows the whole shape in one
handler (`routes/tara.ts`):

```ts
app.get('/v1/tara/recommended', { preHandler }, async (request, reply) => {
  const userId = requireTara(request, reply); // 401/403 fail-closed
  if (userId === null) return;
  const q = (request.query ?? {}) as {
    limit?: string;
    category?: string;
    intensity?: string;
  };
  await guarded(reply, async () => ({
    // 502 domain_unavailable on adapter throw
    recommended: await app.domainAdapters.tara.getRecommendedSessions({
      userId,
      limit: optionalInt(q.limit),
      category: q.category,
      intensity: q.intensity,
    }),
  }));
});
```

`guarded()` wraps the adapter call, sets `cache-control: no-store`, and turns
any thrown adapter error into `502 domain_unavailable` rather than leaking an
upstream stack trace. The domain library it calls — `domain-tara` in this case —
is the next layer down, documented in
[Domain Orchestration](./oshun-domain-libraries.md).

### Response shaping

Composition produces raw domain results; shaping makes them a surface's
contract. In `@oshun/bff` shaping means cursor pagination envelopes
(`highlightsLimit`, `continueCursor`, …), short-lived private response caching
with `x-oshun-cache: hit|miss` and `cache-control` headers, revisioned profile
and preferences envelopes that clients patch incrementally, and the
partial-failure `trace` envelope (correlation id, route, degraded domains and
stages) attached to every aggregator response. `@lilith/bff` takes shaping
further with a dedicated `response-shaping.ts` service that strips and reformats
fields **per client type** (`web | ios | android | desktop | unknown`) detected
from headers — the same domain payload arrives leaner on a phone than on the
web. Shaping is also where the BFF refuses to over-serve: an adapter read is
`no-store`, an aggregator read is short-TTL private cache, and a write is never
cached.

### Governance and budget gates

A write must clear several cross-cutting gates the gateway owns. These are the
"governance/budget" layer, and they are real:

- **Idempotency** (`middleware/idempotency.ts`) — server-side, per ARCHITECTURE
  §5, opt-in via an `Idempotency-Key` header on unsafe methods. The first keyed
  write takes a **tenant-scoped lock**; a replay with the same key and request
  fingerprint returns the stored envelope verbatim with
  `Idempotent-Replayed: true` (the handler never re-runs); a concurrent
  duplicate gets `409 idempotency_in_progress` (`idempotency.ts:324`); the same
  key with a different body gets `422 idempotency_key_reuse` (`:330`).
  `server.ts` injects a `RedisBffIdempotencyStore` for cross-instance replay
  safety; dev/test use a per-instance in-memory store.
- **Residency** (`middleware/residency-guard.ts`, V1-PRIV-018) — wraps the
  canonical `ResidencyEnforcementService`. A route declares its `artifactType`
  and target zone; the guard resolves the principal's `homeZone` claim and
  either admits the transfer or replies `403` with the policy's
  `acceptableMechanisms`, emitting a canonical audit event either way. It runs
  **after** auth (it refuses a request with no `authContext`). The residency,
  migration, and deletion machinery underneath is detailed in
  [Persistence & Data](./persistence-data.md).
- **Entitlements** (`middleware/entitlements.ts`) — resolves the authoritative
  tier from the principal's **persisted plan** (`free | pro | premium`), falling
  back to the `x-oshun-tier` header only outside production and to lowest
  privilege otherwise, so a production client can no longer self-assert `pro`.
  Suspensions (`x-oshun-suspended-domains`) only ever restrict and are honored
  everywhere; per-domain overrides can elevate and are dev-only.
  `evaluateDomainAccess` from `@oshun/auth-client` turns that into a per-domain
  access decision.
- **Abuse protection** (`middleware/abuse-protection.ts`) — a fixed-window /
  block limiter (default 60 requests / 60 s, 5-minute block) returning `429`
  with `retry-after`, applied to entitlement, bootstrap, and per-domain routes.
- **Agentic budget gate** (`agentic/agentic-governance-gate.ts`) — the budget
  control plane for orchestrated content runs.
  `createAgenticStudioGovernanceGate` delegates to the **literal**
  `@oshun/agentic-studio` primitives: `admit(node)` calls `admitToolCall` (real
  kill-switch `decideExecution` + real budget `checkBudget`) and
  `recordDispatch(node)` calls `consumeBudget` (the meter mutation threaded
  across dispatches). Admission outcomes are
  `admit | killed | throttled | run-terminal`, so a kill-switch armed on a
  content family stops both the BFF's generator-tool catalog and the
  orchestrator's routing.

High-risk routes additionally read device-integrity headers
(`x-oshun-device-platform`, `x-oshun-device-integrity`,
`x-oshun-device-attested-at`).

## The routing and module structure of `apps/oshun/bff/src`

The gateway is organized as a composition root plus a flat route surface plus a
typed adapter bundle plus a middleware stack:

- **`app.ts`** — `createApp(options)` is the factory. It builds (or accepts) the
  domain adapters via `createDefaultDomainAdapters()` (`app.ts:1149`), decorates
  `domainAdapters`, `bffProbes`, and `sophiaAnswerSynthesizer`, registers CORS
  with the explicit verb set, then installs the request-lifecycle middleware in
  order — `registerRequestTracing`, `registerTenantContext`,
  `registerResidencyRoutingContext`, `registerBffIdempotency` (`app.ts:591-596`)
  — before the ~460 route plugins. The middleware order is deliberate:
  idempotency runs after tenant/residency resolve so keys are tenant-scoped.
- **`server.ts`** — the process entry (not the factory). It builds the durable
  admin stores (`buildDurableAdminStores`, `server.ts:259`), the durable-backed
  audit-events store, the Redis idempotency store, and the real account-deletion
  runner, passes them into `createApp(...)` (`:542`), and calls
  `app.listen(...)` (`:1143`). This is the seam where in-memory dev defaults
  become production-grade backends.
- **`routes/`** (~490 files) — the HTTP surface: per-domain customer routes
  (`tara.ts`, `nisaba.ts`, `veritas.ts`, `nyx.ts`, `arete.ts`, `metis.ts`,
  `sophia.ts`, …), the cross-domain aggregators (`home.ts`, `continue.ts`,
  `activity.ts`, `library.ts`, `search.ts`), the large admin and Studio surface
  (`admin-*.ts`, `admin-studio-*.ts`), V6 catalog/tier-routing, and the
  consumer-shell envelopes (profile, preferences, notifications). Across all of
  `src` these declare on the order of **1,400 HTTP handlers**.
- **`adapters/`** (16 files) — `domain-service-adapters.ts` (the typed HTTP
  clients + circuit breakers), the in-process adapters' read-adapter registries
  (`tara-read-adapters.ts`, `sophia-read-adapters.ts`, …), and
  `mock-domain-service-adapters.ts` for `USE_MOCK_ADAPTERS=true`.
- **`middleware/`** — `authz`, `entitlements`, `idempotency`,
  `abuse-protection`, `residency-guard`, `residency-routing`, `tenant`,
  `tracing`, `device-integrity`, with Redis-backed variants for the stateful
  ones.
- **Per-domain subdirectories** — `sophia/`, `nisaba/`, `metis/`, `tara/`,
  `agentic/`, `isis/`, `nyx/`, etc., hold the BFF-resident logic that is not a
  thin proxy (the Sophia extractive answer composer, the in-process Nisaba
  corpus retriever, the agentic governance gate and tool catalog).

## A request, traced end to end

```mermaid
sequenceDiagram
  participant S as Surface (web/mobile/UE)
  participant M as BFF middleware (auth · tenant · residency · idempotency)
  participant R as Route handler (routes/*.ts)
  participant A as app.domainAdapters
  participant D as Domain orchestration (libs/oshun)
  S->>M: GET /v1/tara/recommended  (Bearer token)
  M->>M: resolve token → authContext, tenant, residency-routing
  M->>R: preHandler chain passes (else 401/403/429)
  R->>R: requireTara → scope check (domain:tara | domain:*)
  R->>A: app.domainAdapters.tara.getRecommendedSessions({ userId })
  A->>D: HTTP (tara/veritas/arete) or in-process (nyx/nisaba/metis)
  D-->>A: domain result (or throw)
  A-->>R: typed result (or 502 via guarded())
  R-->>S: shaped envelope + cache-control: no-store
```

No step in that trace reinvents identity, validation, or partial-failure
handling: auth and tenancy come from middleware, the contract shape is the same
Zod type the domain library validates against (see [Contracts](./contracts.md)),
and the failure mode is a typed `502`/partial envelope rather than a leaked
upstream error.

## The other gateways, honestly

**`@lilith/bff` (~300 files) — implemented, different lineage.** This is a real
Fastify gateway built on `@lilith/service-lib` (its own
`integratedSecurityPlugin`

- `setupSecurityAndAuth` + `PERMISSION_LEVELS`), with 40 route files under
  `src/routes/`. Its specialty is not breadth of customer domains but **response
  shaping and synthesis**: `response-shaping.ts` tailors payloads per client
  type, `synthesis-engine.ts` does consensus/theme extraction for comparative
  views (and routes ComfyUI generation jobs), and `capability-clients.ts`
  proxies the **Isis** (generation) and **Sophia** (knowledge) capability
  domains so a browser never receives their credentials — the client constructs
  real `@isis/client` / `@sophia/client` SDK instances from env and returns
  `null` (degrade to `503`) when no endpoint is configured. It is a genuine
  gateway, simply scoped to a different product than `@oshun/bff`.

**`@kalika/bff` (8 source files) — implemented, thin.** `buildKalikaBffApp`
(`apps/kalika/bff/src/app.ts`) is a coherent but small gateway for a math /
research-notebook product: an auth hook (dev tokens + JWT via
`KALIKA_BFF_JWT_*`), three upstream service clients (`compute` / `agents` /
`notebooks`), a realtime WebSocket hub, an in-memory session store, and a
`/api/v1/workbench` aggregator that fans out with `Promise.all` + a
`settleForWorkbench` partial-failure wrapper. Its error handler maps an
`UpstreamServiceError` to `502`. It is real, but its in-memory sessions and
three-upstream scope make it scaffold-tier next to the Oshun gateway — the right
honest label.

**`apps/urania/bff` — scaffold.** The directory contains only `node_modules` and
**no `src`**: zero TypeScript source files. It is a placeholder for a future
gateway, not a running one, and the overview's layered diagram should be read
with that in mind.

## Edge cases and failure modes

- **A degraded domain never fails the screen.** Aggregators return
  `partial: true` with a per-domain `domainStatus` and `x-oshun-partial-*`
  headers; a single-domain read returns `502 domain_unavailable` via `guarded()`
  rather than a `500` or a leaked upstream error.
- **An open circuit breaker short-circuits.** `probeDomainHealth` returns a
  built `unavailable` health without an outbound call when the breaker is open,
  and reports `circuitState` so readiness can reflect it.
- **A replayed write happens exactly once.** Same idempotency key + fingerprint
  → the stored envelope; same key mid-flight → `409`; same key, different body →
  `422`. Keyless writes are unchanged (opt-in).
- **Cross-zone transfer is refused, not silently routed.** The residency guard
  replies `403` with acceptable mechanisms and emits an audit event; a request
  with no `authContext` is `401`, never an unscoped transfer.
- **Production refuses unsigned credentials.** Dev/tenant tokens are rejected
  and a missing JWT secret yields `signed_auth_not_configured` — fail-loud over
  fake-trust.
- **A client cannot self-elevate entitlements.** In production the tier comes
  from the persisted plan; the `x-oshun-tier` header is ignored for elevation
  and honored only for suspension (restriction).

## How this connects to the rest of the platform

The BFF is the only layer that touches all the others. It authenticates with the
identity model in [Auth & Identity](./auth-identity.md), composes the service
layer in [Domain Orchestration](./oshun-domain-libraries.md), validates every
payload against the Zod schemas in [Contracts](./contracts.md), reads and writes
through the residency-aware foundations in
[Persistence & Data](./persistence-data.md), and emits logging, tracing, and
metrics through the `@oshun/*` packages catalogued in
[Shared Libraries](./shared-libraries.md) — including `@oshun/traefik-config`,
which generates the gateway config this runtime tier sits behind. Read top-down
it is the surface's single door; read bottom-up it is the one place the
platform's shared rules are enforced before a domain ever runs.

## Related

- [The Shared Platform](./overview.md) — the platform framing and layered model
  this page fills in.
- [Domain Orchestration](./oshun-domain-libraries.md) — `libs/oshun`, the domain
  libraries the BFF composes behind every surface.
- [Contracts](./contracts.md) — the Zod vocabulary the gateway validates
  payloads against at the boundary.
- [Auth & Identity](./auth-identity.md) — the identity, session, consent, and
  authorization model behind the BFF's auth middleware.
- [Persistence & Data](./persistence-data.md) — the residency,
  idempotency-store, and deletion-runner backends `server.ts` injects.
- [Shared Libraries](./shared-libraries.md) — the `@oshun/*` infrastructure
  (`@oshun/http-client`, `@oshun/traefik-config`, `@oshun/data-residency`) the
  BFF composes. </content> </invoke>
