Shared Platform · BFF & Gateway

BFF & Gateway

The Oshun gateway is real and load-bearing.

9sections13 minread1diagrams

On this page

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: the box labelled "BFF & gateway" that sits directly under the product surfaces and directly above domain orchestration. 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 tokensdev.<base64url-json> carrying { sub, scopes, exp }. Allowed only outside production and only when OSHUN_BFF_ALLOW_DEV_TOKENS is not false.
  • Tenant session tokenstenant.<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.

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 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.

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.
  • 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.tscreateApp(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 subdirectoriessophia/, 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#

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), 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, composes the service layer in Domain Orchestration, validates every payload against the Zod schemas in Contracts, reads and writes through the residency-aware foundations in Persistence & Data, and emits logging, tracing, and metrics through the @oshun/* packages catalogued in Shared Libraries — 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.

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