---
title: "@octabits-io/server"
summary: "The HTTP cores of a multi-tenant API with no HTTP framework in them — request scope with the disposal triangle, bearer auth, rate limiting, error mapping by key convention, security headers, client-IP trust walk, the request span and metrics, env config, response schemas, an OpenAPI options builder, a test-request harness, Stripe-style idempotency keys with a plain-SQL store, and ./auth — the JWKS JWT validator, the API-key format and the bearer-strategy dispatcher, with the Better Auth claim shape at ./auth/better-auth and Microsoft Entra ID's at ./auth/entra — ./authz, the one permission gate over roles kept as rows (revoked, suspended, time-boxed) with its store halves — ./sessions, server-side sessions: an opaque host-only cookie whose token is stored only as a hash, idle and absolute timeouts, one app per manager on a shared table, list, revoke and revoke-all, with its store halves — ./impersonation, support acting as a customer under a grant: a required reason, read-only by default, time-boxed with no extension, a single-use handoff token to another host, the session that dies with the grant, with its store halves — ./contract, a JSON endpoint described once as data, with ./client, a typed browser client that returns a Result — and their bindings: ./fetch composes a Next.js route handler, a Bun serve or a Worker from the same wrappers Hono's middleware is built from, ./node runs those handlers on Node's http (Express, Connect, NestJS, http.createServer), ./mcp serves MCP on the SDK's web-standard transport, and ./outbound/node is a guarded fetch for a URL a user supplied — public addresses only, checked after DNS resolution, redirects re-checked, size and time bounded."
install: "pnpm add @octabits-io/server"
availability: "Available from November 2026"
version: "0.23.0"
license: "MIT"
related:
  - name: "@octabits-io/disclosure"
    url: "https://octabits.io/platform/disclosure.md"
  - name: "@octabits-io/models"
    url: "https://octabits.io/platform/models.md"
  - name: "@octabits-io/postgres"
    url: "https://octabits.io/platform/postgres.md"
  - name: "@octabits-io/result"
    url: "https://octabits.io/platform/result.md"
  - name: "octaflow"
    url: "https://octabits.io/platform/octaflow.md"
canonical_url: "https://octabits.io/platform/server"
---

# `@octabits-io/server`

The HTTP cores of a multi-tenant API with no HTTP framework in them, and their
bindings. What a Hono middleware stack gives you — request scope with the
disposal triangle, bearer auth, rate limiting, error mapping by key convention,
security headers, the request span and metrics — composed onto
`(request: Request, ctx) => Response`, so a Next.js route handler, a Bun
`serve`, a Cloudflare Worker or a Deno server gets the same behaviour from the
same code, and `./node` puts those same handlers on Node's `http` for Express,
Connect, NestJS and `http.createServer`. `./auth` is the API side of sign-in
that feeds `withBearerAuth`: the JWKS JWT validator, the API-key format and the
bearer dispatcher. `./authz` is what comes after it: the one permission gate a
route calls, roles as data and their assignments as rows that can be revoked,
suspended or time-boxed. `./impersonation` is support acting as a customer under
a grant — read-only, time-boxed, recorded. `./sessions` is the cookie side of sign-in: an
opaque session cookie backed by a row, so a host can list a user's sessions,
end one, sign them out everywhere, and end every session at offboarding.
`./mcp` serves an MCP endpoint on the same shape. `./contract` describes a
JSON endpoint once — the route (`endpointHandler`), the typed browser client
(`./client`), an MCP tool and the OpenAPI document all read that one object.
`./outbound/node` is the other direction: a `fetch` for a URL a user typed
that refuses internal addresses after DNS resolution (SSRF), and
`./outbound/browser` the same for a page that only exists once its JavaScript
has run — the host's Chromium with every request it makes answered through
that fetch and no network of its own. `./csp` is the
Content Security Policy for the HTML an app serves — a nonce per response or
the hashes of a built page, no `'unsafe-inline'` — and `./csp/testing` the
browser test that fails on the first violation. The logger
and the tracer are structural seams the host fills; `zod` is the only required
peer.

```bash
npm i @octabits-io/server zod
```

```ts
// app/api/contacts/[id]/route.ts
import { compose, withBearerAuth, withErrorMapping, withRateLimit, withRequestScope, withSecurityHeaders, withTelemetry } from '@octabits-io/server/fetch';

export const POST = compose(
  withTelemetry({ tracer, meter, route: '/api/contacts/:id' }),
  withSecurityHeaders(),
  withErrorMapping({ logger, statusOverrides: { proposal_drift: 409 } }),
  withRateLimit({ max: 60, trustedProxies: ['10.0.0.0/8'] }),
  withBearerAuth({ authService }),
  withRequestScope({ createScope: () => container.createScope() }),
)(async (request, { params, principal, scope }) => {
  const contact = await scope.resolve('contacts').get((await params).id);
  return contact; // a Result — an `err` becomes the standard `{ key, message }` body with the status its key implies
});
```

| Subpath | Deps | Contents |
|---|---|---|
| `.` | `@octabits-io/result`, `zod` | the cores: env config, response schemas, `buildSwaggerOptions`, `openApiDocument` (OpenAPI 3.1 from endpoint contracts), the error mapping (`ApiError`, `resolveErrorResponse`, the key conventions), the request-scope contracts, bearer auth, `buildSecurityHeaders`, the client-IP trust walk, the rate-limit cores, `createHttpTelemetry`, the MCP scope conventions, the structural `Logger` and tracer types, `attachSpan` / `spanOf` |
| `./contract` | `zod` | an endpoint described once: `defineEndpoint` (method, path, params, query, body, output, status, error keys), `fillPath`, `matchPath`, the `InputOf` / `OutputOf` / `ErrorKeyOf` types. Browser-safe |
| `./client` | `@octabits-io/result`, `zod` | `createClient().call(endpoint, input)` → a `Result` typed by the contract, the output parsed, `EndpointError` with key, status and fields. Browser-safe |
| `./fetch` | the root | `compose` and the wrappers — `withTelemetry`, `withRequestScope`, `withBearerAuth`, `withSession`, `withPermission`, `withRateLimit`, `withErrorMapping`, `withSecurityHeaders`, `withJsonBody` — plus `endpointHandler` (a route served from its contract), `createHealthHandler`, `toResponse`, `errorResponse`, `validationErrorResponse` |
| `./auth` | `@noble/hashes`, `jose` (optional peer) | the API side of sign-in: `createJwtValidationService` (JWKS, issuer and audience, an injected `claimMapper`, `jwksUri` for an issuer with no discovery document), `createApiKeyFormat` (`<prefix><keyId>.<secret>`, SHA-256 at rest), `createBearerAuthService` + `extractBearerToken` (the ordered dispatcher) |
| `./auth/better-auth` | `zod` | the Better Auth claim shape both sides agree on: `defineOctabitsJwtPayload` for the `jwt` plugin's `definePayload`, `parseOctabitsClaims` / `betterAuthClaimMapper` / `betterAuthJwksUri` for the validator, `membershipOf` / `activeMembershipOf` over a principal, `SCHEMA_ORG_MEMBERSHIP` and `SCHEMA_OCTABITS_CLAIMS`. Never imports `better-auth` |
| `./auth/entra` | `zod` | Microsoft Entra ID v2.0 access tokens: `entraIssuer` / `entraJwksUri` for one directory, `parseEntraClaims` / `entraClaimMapper` to an `EntraPrincipal` keyed by `oid` (roles, delegated scopes, user or app, the groups overage flagged), `SCHEMA_ENTRA_CLAIMS`. Talks to nothing but the key set the validator fetches |
| `./authz` | the root | what the caller may do: `definePermissions` (roles as data, permission keys typed), `createAuthorization` (per-request resolution through a cache bounded by 60 s and by the earliest expiry; grant, revoke, suspend, resume on the caller's transaction), `requirePermission`, the guards `noSelfRoleChange` / `keepLastHolder` / `timeBoxed`, the `RoleAssignmentStore` row contract, the in-memory reference |
| `./authz/postgres` | none (structural executor) | the role assignments over plain SQL with `roleAssignmentDdl()`, serialised per scope value by an advisory lock, and `createPostgresAuthorization` for both in one call |
| `./authz/drizzle` | `drizzle-orm` (optional peer), `@octabits-io/postgres` (peer) | `roleAssignmentColumns`, `createDrizzleRoleAssignmentStore`, `createDrizzleAuthorization` |
| `./sessions` | `@noble/hashes` | server-side sessions: `createSessions` (one app per manager; `create` returns an opaque token once and stores only its SHA-256; `resolve` reads the row on every request and enforces idle and absolute timeouts, writing `lastSeenAt` at most once per interval; `list`, `revoke` and `revokeAll` on the caller's transaction; `purgeEnded`), `sessionCookie` (host-only, `HttpOnly`, `Secure`, `SameSite`), the `SessionStore` row contract, the in-memory reference |
| `./sessions/postgres` | none (structural executor) | the sessions over plain SQL with `sessionDdl()`, and `createPostgresSessions` for both in one call |
| `./sessions/drizzle` | `drizzle-orm` (optional peer), `@octabits-io/postgres` (peer) | `sessionColumns`, `createDrizzleSessionStore`, `createDrizzleSessions` |
| `./impersonation` | `@noble/hashes` | support acting as a customer under a grant: `createImpersonation` (`grant` with a required reason, `read` by default, time-boxed with no extension; `end` on the caller's transaction, revoking the session it opened; `issueHandoff` / `redeemHandoff`, a single-use token to another host over a signing seam; `openSession` under the grant; `markerFor`, the session dying with the grant), `requireWrite`, the `ImpersonationGrantStore` row contract, the in-memory reference |
| `./impersonation/postgres` | none (structural executor) | the grants over plain SQL with `impersonationGrantDdl()`, and `createPostgresImpersonation` for both in one call |
| `./impersonation/drizzle` | `drizzle-orm` (optional peer), `@octabits-io/postgres` (peer) | `impersonationGrantColumns`, `createDrizzleImpersonationGrantStore`, `createDrizzleImpersonation` |
| `./idempotency` | `@noble/hashes` | the `Idempotency-Key` service and its outcomes, `hashRequest`, `isValidIdempotencyKey`, the `IdempotencyKeyStore` row contract, the in-memory reference |
| `./idempotency/postgres` | none (structural executor) | the key store over plain SQL (`pg`, PGlite, postgres.js) with `idempotencyKeyDdl()`, and `createPostgresIdempotencyService` for both in one call |
| `./idempotency/drizzle` | `drizzle-orm` (optional peer), `@octabits-io/postgres` (peer) | the same store as a Drizzle table: `idempotencyKeyColumns` to spread into your `pgTable` (timestamps read back as ISO-8601 in UTC), `createDrizzleIdempotencyKeyStore`, and `createIdempotencyService({ db, table, … })` |
| `./octaflow`, `./fetch/octaflow` | `octaflow` (optional peer) | the octaflow read/control operations as `Result`s, and the same six routes as one fetch handler |
| `./fetch/models` | `@octabits-io/models` (optional peer) | the door a model runner connects out to (`createModelRunnerHandler`) |
| `./node` | `node:http` (types), `node:stream` | the same routes on Node's `http` — Express, Connect, NestJS, `http.createServer`: `toNodeHandler`, `toRequest`, `sendResponse`, `directIp`. The one subpath that names its host |
| `./mcp` | `@modelcontextprotocol/server`, `@octabits-io/disclosure` (optional peers) | the MCP endpoint as a fetch handler: `createMcpFetchHandler`, a fresh `McpServer` per request on SDK v2, the 2026-07-28 protocol and 2025-era clients alike, over the root's scope-key conventions; `createMcpDisclosure`, the disclosure gate every tool result passes; `createMcpTokenVerifier`, a JWT access token from the app's issuer as the SDK's `AuthInfo`; `toolFromEndpoint`, a tool's schemas from an endpoint contract; `proposeActionTool`, a `propose_action` tool over an action register that proposes and never runs |
| `./mcp/postgres` | the root | the tool-call log over the structural executor: `createPostgresMcpToolCallStore`, `mcpToolCallDdl` |
| `./mcp/drizzle` | `drizzle-orm` (optional peer) | `mcpToolCallColumns`, the log's table for drizzle-kit (no Drizzle store — the SQL store is the store) |
| `./outbound` | the root | for a URL somebody else chose: `isPublicAddress` (no loopback, private, link-local, CGNAT, unique-local or reserved address, IPv4 embedded in IPv6 included) and `checkOutboundUrl` (https, no credentials, an IP literal judged) as a `Result` — portable |
| `./outbound/node` | `undici` (optional peer), `node:dns` | `createOutboundFetch`: a `fetch` (and a `request` returning a `Result`) that checks every resolved address inside the connection's own lookup — DNS rebinding has nothing to race — re-checks each redirect hop, supports `redirect: 'manual'`, bounds the time and the body, and reports every response to an optional `onResponse` (URL, method, status, time, bytes) so a host meters paid hosts in one place |
| `./outbound/browser` | `playwright-core` (optional peer), `undici` (optional peer), `node:http` — and the host's own Chromium | `createOutboundBrowser`: `render(url)` for a page that only exists once its JavaScript has run — one headless process per call, every request the page makes (the document, each sub-resource, each redirect hop) intercepted and answered by `./outbound/node`'s guarded fetch, the browser's own network dead-ended at a proxy sink in this process, service workers and storage off, one deadline, text and HTML capped, a concurrency gate; the result `{ url, html, text, title, blocked, refused, escaped }` as a `Result` |
| `./csp` | `@noble/hashes` | the Content Security Policy of an HTML app: `buildCsp` (strict defaults, a nonce with `'strict-dynamic'` or the hashes of a page built once, every list `'self'` plus what the host names, the switches a real app needs — `allowStyleAttributes`, `unsafeEval` for a dev server — and a refusal of any value that would end a directive), `createNonce`, `cspHashOf`, `inlineScriptHashes` / `inlineStyleHashes` over a built `index.html` — portable |
| `./csp/testing` | none | `watchCspViolations(page)`: the browser's `securitypolicyviolation` events collected into a list the test asserts empty — against a Playwright `Page` by shape, no Playwright import; `cspViolationListenerScript` for any other harness |
| `./testing` | the root | `testRequest`, `testAuthenticatedRequest`, `decodeResponseBody` — drive any `{ handle(Request) }` without binding a port; `createTestAuthorization` and `testRequestAs` — a request as a subject with given roles; test-runner agnostic |

No HTTP framework is imported anywhere and the boundary lint refuses one by
name. IP parsing and the constant-time compare are hand-rolled, the env
accessors read `globalThis.process` structurally, and every core type-checks a
second time with no Node typings at all (`tsconfig.portable.json`) — the gate
that proves they run on a Worker or on Deno. `./node` is the one exception and
is excluded from it on purpose: a binding that names its host in its subpath is
outside the claim the gate exists to prove. `apps/demo-server` binds the same
cores to Hono with its own glue; `apps/starter-next` in the repository runs this binding on
Next.js route handlers against a real Postgres.

## When not to use it

- **You want a router.** There is none. `compose` wraps one handler; the host's
  framework (Next, SvelteKit, TanStack Start, Hono) matches the route and hands
  the request over. `./fetch/octaflow` dispatches six workflow routes because
  it has to, and says so.
- **Fastify.** It does not speak `Request` and `Response`, and its
  `req`/`res` are not Node's either. The cores on the root are usable from it —
  they take plain values — but no binding ships. Express, Connect, NestJS and
  `http.createServer` are served: that adapter is `./node`.
- **The browser side of sign-in.** `./auth` says who a bearer is; it never
  issues one, keeps a cookie or redirects anybody. Signing a user in, holding
  the token and guarding a route are `@octabits-io/session` (`./oidc` or
  `./better-auth`), and minting the token is your identity provider's job —
  Better Auth inside your own API, or Zitadel.

## The wrappers

A wrapper takes a handler and returns one. Each may add to the context object
the handler receives, and `compose` nests them outermost-first, so the order
above is the order that works: security headers stamp every response, error
mapping catches anything inside it, the rate limit runs before the token
check, the scope is created only for an authenticated request, and a body
validation failure never costs a scope.

- **`withTelemetry({ tracer, meter?, route?, skip? })`** — the request span
  (`{method} {route}`, kind `server`, parented on the incoming `traceparent`)
  and the two semantic-convention metrics, `http.server.request.duration` and
  `http.server.active_requests`. The span rides on the request (`spanOf`) and
  on `ctx.span`; a returned keyed error's mapped status is what it records, a
  throw marks it failed with a 500. A fetch host has no router to ask, so
  `route` names the pattern per handler; without it the span is the bare
  method. Outermost, ahead of `withRequestScope`, so the scope's logger can
  carry the trace id.
- **`withRequestScope({ createScope, guard?, logger? })`** — the disposal
  triangle as one try/finally: the scope is created from `{ request, path,
  params }` (a host's `params` promise is awaited), handed to the handler as
  `ctx.scope`, disposed `{ commit: true }` when the handler returns (a returned
  keyed error is still a response) and `{ commit: false }` when it throws or
  `guard` rejects. Exactly one dispose per request. The scope is anything with
  `dispose({ commit })`.
- **`withBearerAuth({ authService, authorize?, statusOverrides?, onUnauthorized? })`**
  — the validated principal lands on `ctx.principal`; 401, 403 and 503
  (`service_unavailable`, for a validator that cannot reach its issuer) bodies
  by key.
- **`withSession({ sessions, cookie, required?, csrf?, trustedOrigins?, failures? })`**
  — the session cookie resolved through `./sessions`, on `ctx.session`; no open
  session is 401 (or `null` with `required: false`), and a cookie that did not
  resolve is cleared on the way out. A cookie-carrying write from another site
  is 403 before anything is read; see [`./sessions`](#sessions--the-cookie-that-ends-when-you-say-so).
- **`withImpersonation({ impersonation })`** — inside `withSession`: the grant a
  session was opened under, on `ctx.impersonation` (`null` for a session of
  one's own); 401 once the grant is ended or past its end. `withPermission`'s
  `write: true`, or `requireWrite(ctx)` in the handler, refuses a write under a
  read-only grant as `403 impersonation_read_only`; see
  [`./impersonation`](#impersonation--support-acting-as-a-customer).
- **`withRateLimit({ max, windowMs?, trustedProxies?, getDirectIp?, keyBy?, skipCidrs?, internalSecret?, store?, onStoreError? })`**
  — a fixed-window counter keyed on the resolved client IP (`ctx.clientIp`),
  429 with `Retry-After`. The IP is resolved by a rightmost-untrusted walk of
  `X-Forwarded-For` over `trustedProxies`; without `getDirectIp` or a trusted
  proxy every request keys to one bucket, and the wrapper warns once. The
  counters live in `store` — per process by default, so behind N replicas a
  client gets N × `max`; see [Rate limits across replicas](#rate-limits-across-replicas).

### Rate limits across replicas

`RateLimitStore.hit(key, { max, windowMs })` is the seam both limiters count
through — `withRateLimit` here and any middleware a host builds on
the same cores. `createMemoryRateLimitStore()` is the default.
`@octabits-io/server/rate-limit/postgres` is the store every replica shares:

```ts
import { createPostgresRateLimitStore, rateLimitDdl } from '@octabits-io/server/rate-limit/postgres';

await pool.query(rateLimitDdl());                                  // UNLOGGED by default; idempotent
const store = createPostgresRateLimitStore({ db: pool, namespace: 'api' });
compose(withRateLimit({ max: 60, getDirectIp, store }))(handler);

await store.purgeExpired();                                        // from a scheduled job; returns the count
```

A hit is one statement, `INSERT … ON CONFLICT DO UPDATE … RETURNING`: the row
lock serialises concurrent hits on a key, so exactly `max` of a burst get
through, and windows run on the database's clock. The table is `UNLOGGED` —
counters are worth a crash's loss, not a WAL write per request, and they stay
off physical replicas (`unlogged: false` when that matters). `namespace` keeps
two limiters on one table apart. Every request becomes a round-trip, so keep
the in-memory store where one process is the whole deployment. When the store
throws, requests pass by default and the failure is logged once per outage;
`onStoreError: 'reject'` answers 503 `rate_limit_unavailable` instead.

A host on Drizzle declares the table in its own schema from
`@octabits-io/server/rate-limit/drizzle`'s `rateLimitColumns` (plus the
`reset_at` index the purge reads) and lets drizzle-kit own the migration; the
store stays the plain-SQL one, pointed at that table by name. `pgTable` cannot
say `UNLOGGED`, so add `ALTER TABLE rate_limit SET UNLOGGED` to the generated
migration when you want what `rateLimitDdl()` creates. A test holds the columns
and the DDL equal, column for column.

### When the shared store is unreachable

Both of those answers are bad: `'allow'` drops the protection at the moment the
system is already unwell, and `'reject'` turns a degraded dependency into an
outage. `createFallbackRateLimitStore` gives the limiter a third answer —
count in memory until the shared store comes back:

```ts
import { createFallbackRateLimitStore } from '@octabits-io/server';

const store = createFallbackRateLimitStore({
  primary: createPostgresRateLimitStore({ db: pool, namespace: 'api' }),
  retryAfterMs: 5_000,   // how long before one request probes the primary again
  timeoutMs: 1_000,      // a primary hit slower than this counts as a failure (the default)
  logger,                // warned once when it fails, once when it recovers
});
```

While degraded, hits cost no round-trip and a client gets one allowance per
replica — the guarantee the in-memory store gives on its own. When
`retryAfterMs` has passed, one request probes the primary while the others
keep taking the fallback; and every primary hit, probe or not, is held to
`timeoutMs`, so a store that hangs rather than fails — a pool waiting without
limit for a connection — fails over at the deadline instead of holding every
request. Windows start at
failover and counts are never carried back, so a client mid-window gets a fresh
allowance when the store changes under it: a limiter bounds abuse, it is not a
ledger. `isDegraded()` reports which store is counting, for a health endpoint
or a metric.

This matters most where a migration and a rollout race: a replica that starts
before the `rate_limit` table exists counts in memory rather than not at all.
- **`withErrorMapping({ statusOverrides?, production?, logger? })`** — the
  global error handler: a thrown `ApiError`, a thrown `Response`, a database
  connection error (503), anything else (500, message redacted in production,
  logged); a returned `err(error)` is mapped through the key conventions —
  `*_not_found` → 404, `already_*` → 409, `validation_*` → 400, `forbidden` →
  403 — plus your overrides.
- **`withSecurityHeaders(options?)`** — `buildSecurityHeaders` on every
  response, error bodies included.
- **`withJsonBody(schema)`** — the zod-parsed body on `ctx.body`, or the
  standard `{ key: 'validation_error', message, fields }` 400.
- **`createHealthHandler({ checkReady, logger? })`** — the health trio on one
  catch-all: `…/ready` runs `checkReady` (503 on failure), anything else
  answers `{ status: 'ok' }`.

`toResponse` and `errorResponse` are the finisher's pieces for a hand-written
route; `validationErrorResponse(zodError)` is that same 400 body for a route
that parses its own query or params.

## The cores

Everything on the root is a plain function over plain values, which is why
two bindings can share it:

- **Env config** — `getEnv`, `getEnvOptional`, `getEnvNumber`,
  `getEnvNumberOptional`, `getEnvBoolean`, `isProduction`, `parseCsv`,
  `parseCorsOrigins`, and `assertNotInProduction(name, value?)`, which fails
  startup when a dev-only escape hatch is set in production.
- **Errors** — `ApiError` and its subclasses, `resolveErrorResponse`, the
  `ErrorStatusOverrides` map, the api-error response types
  (`ValidationApiError`, `NotFoundApiError`, …) that every `@octabits-io`
  server emits and every client helper understands. These are wire vocabulary
  and change when the API surface does; only `OctError` is the frozen base,
  from `@octabits-io/result`.
- **Request scope** — `RequestScope` (`dispose({ commit })`),
  `RequestScopeContext`, `unwrapCreateScopeResult`, `disposeScopeQuietly`.
- **Bearer auth** — `BearerTokenValidator`, the failure shape, the built-in
  status overrides.
- **Security headers** — `buildSecurityHeaders(options)`.
- **Client IP** — `createClientIpResolver`, `normalizeIp`, `isIP`.
- **Rate limit** — `createFixedWindowLimiter`, `createCidrMatcher`,
  `timingSafeStringEqual`, and the store seam: `RateLimitStore`,
  `createMemoryRateLimitStore`; the Postgres store is `./rate-limit/postgres`.
- **Telemetry** — `createHttpTelemetry({ tracer, meter?, skip? })`, the seam
  both bindings are thin over, and the request → span registry.
- **Responses and OpenAPI** — the zod response schemas, `errorResponses(...)`,
  `successResponses(status, schema)`, `buildSwaggerOptions({ … })` as a plain
  object with no spec-generator dependency.
- **MCP scope conventions** — the scope-key URL convention, the JSON-RPC error
  response and the structural scope contracts an MCP endpoint needs, shared
  by the Hono harness and the fetch-handler harness.

## The seams

Two things the cores use but never create:

- **`Logger`** — `debug`, `info`, `warn`, `error(message, error?, attrs?)`,
  `child(context)`. `@octabits-io/telemetry/logger` satisfies it as-is; so
  does a five-line adapter over pino or `console`. Every wrapper that takes
  one takes it as an option and falls back to `console`.
- **`Tracer` and `Meter`** — the subset of the OpenTelemetry API a server
  touches: `startSpan` / `withSpan` and `counter` / `upDownCounter` /
  `histogram`. `@octabits-io/telemetry`'s implementations satisfy
  them; a host on the real SDK adapts in a dozen lines. `noopTracer` is the
  default wherever telemetry is optional.

One thing on the root is not a seam: `attachSpan` / `spanOf`. The request span
rides on the `Request` object itself, so any layer holding the request finds
it without a context variable. There is exactly one such registry per process
and it lives here, with the glue that writes to it; `@octabits-io/telemetry`
re-exports it rather than keeping a second map.

## `./contract` and `./client` — an endpoint described once

A route, the page that calls it, an MCP tool over the same operation and the
OpenAPI document need the same facts. `defineEndpoint` holds them in one frozen
object; each side reads it.

```ts
import { z } from 'zod';
import { defineEndpoint } from '@octabits-io/server/contract';

export const applyRun = defineEndpoint({
  method: 'POST',
  path: '/api/runs/:id/apply',
  params: z.object({ id: z.coerce.number().int().positive() }),
  body: z.object({ decision: z.enum(['apply', 'reject']) }),
  output: z.object({ id: z.number(), status: z.string() }),
  status: 200,                                   // the default
  errors: ['run_not_found', 'run_invalid_status', ['run_locked', 423]],   // a pair: a status no convention gives
});

// the route — ./fetch
export const POST = compose(withErrorMapping())(
  endpointHandler(applyRun, ({ params, body }) => runs.apply(params.id, body.decision)),   // ok(value) | err(keyed)
);

// the caller — ./client
const api = createClient({ headers: async () => ({ authorization: `Bearer ${await token()}` }) });
const result = await api.call(applyRun, { params: { id: 7 }, body: { decision: 'apply' } });
// Result<{ id: number; status: string }, EndpointError<'run_not_found' | 'run_invalid_status' | 'run_locked' | …>>
```

| Export | Where | What |
|---|---|---|
| `defineEndpoint(spec)` | `./contract` | `{ method, path, params?, query?, body?, output, status?, errors, summary?, description?, tags? }` → a frozen `Endpoint`; throws when a `:name` in the path and the `params` keys disagree, on a `body` on a GET (fetch cannot send one; a DELETE may), on a 204 whose `output` is not `z.undefined()` or `z.void()`, on an error key declared twice, and on an error status outside 400–599 |
| `ErrorDecl` | `./contract` | one entry of `errors`: a bare key (its status by the key conventions) or `[key, status]` |
| `errorKeys(endpoint)`, `errorStatuses(endpoint)` | `./contract` | the declared keys in order, typed; the `{ key: status }` of the pairs — the shape of `statusOverrides` |
| `fillPath`, `matchPath`, `pathParamNames` | `./contract` | the path filled (URI-encoded), a pathname matched to its params or `null` |
| `InputOf`, `ParsedInputOf`, `OutputOf`, `ResponseOf`, `ErrorKeyOf` | `./contract` | what a caller sends, what a handler receives, what a caller gets, what a handler answers, the declared keys |
| `COMMON_ERROR_KEYS` | `./contract` | the keys any endpoint may answer: `validation_error`, `unauthorized`, `forbidden`, `rate_limit_exceeded`, `internal_server_error` |
| `createClient({ baseUrl?, headers?, fetch?, credentials? })` | `./client` | `credentials` is every request's fetch mode (`'include'` for a session cookie on another origin); `.call(endpoint, input)` → `Result<OutputOf, EndpointError>`; an array in `query` goes as repeated keys (`tag=a&tag=b`); a 204 is `ok(undefined)`, its body not read; never throws: `network_error` (status 0) when fetch rejects, `unexpected_response` for a body that is not JSON, not a keyed error, or not the `output` |
| `EndpointError<K>` | `./client` | `{ key, message, status, fields? }` — `fields` on a `validation_error`, paths joined by `/` |
| `endpointHandler(endpoint, fn)` | `./fetch` | a `FetchHandler`: params from the pathname (the host's `ctx.params` under a mount prefix), query (a field whose schema is `z.array()`, optional or defaulted too, always gets an array: `?tag=a` is `['a']`), JSON body, each parsed — a failure is the `validation_error` 400 before `fn` runs; `ok(value)` → `Response.json(value, { status })`, and a 204 (or a value of `undefined`) answers with no body; an `err` whose key the contract pairs with a status is answered with it (over the host's `statusOverrides`), any other `err` passes to `withErrorMapping` |
| `toolFromEndpoint(endpoint, { description, title? })` | `./mcp` | `registerTool`'s config: params, query and body as one `inputSchema`, an object `output` as `outputSchema` |
| `proposeActionTool({ actions, propose, name?, description? })` | `./mcp` | `{ name, config, handler }` for `registerTool`: `actionCatalog(register)` as the input's enum and the description, `proposeAction(…)` as `propose`; a `proposingTool`, its result the proposal and the pending row, nothing run |
| `openApiDocument(endpoints, { title, version, … })` | `.` | OpenAPI 3.1 from the same schemas; each declared key listed under the status the contract or the key conventions give it (the contract over `statusOverrides`); a query field with a default is not required; a record's keys become `operationId`s; throws on a method and path defined twice and on two paths that differ only by a param name (`/a/:id`, `/a/:slug`) — OpenAPI forbids the pair, and a router sees one route |

`./contract` and `./client` import `zod`, `@octabits-io/result` and each other
(plus the response schemas) and nothing else — the boundary lint holds them to
that — so a browser bundle that imports a contract carries no server code. An
endpoint that does not answer JSON (a download, an event stream) gets no
contract. The skill `define-an-endpoint` walks through it.

## `./fetch/octaflow`

The `octaflow` read/control routes as one `(request, ctx) => Response`:
`createFlowWorkflowHandler({ engine, prefix?, authorize?, errorOverrides?,
listLimit?, includeChildSteps?, extendWorkflow? })` serves list, active-probe,
get, status snapshot, cancel and resume over the engine's public wire view,
with the standard `validation_error` 400 and the key → status mapping. With
`prefix` the handler is strict about what follows it (404 and 405 otherwise);
without it, for a mount under a path with its own params, it dispatches on
the pathname's tail. The trigger route stays the app's. `./octaflow` is the
core underneath, the operations as `Result`s, which the Hono binding shares.

The two subpaths were `./flow` and `./fetch/flow` until 2026-09-11 — `flow`
was the engine's name before it merged into this monorepo as `octaflow`. Both
old spellings were kept as re-export shims for one minor and removed on
2026-09-12.

### The model runner's door — `./fetch/models`

`createModelRunnerHandler({ hub, prefix?, errorOverrides? })` is
`@octabits-io/models/runner`'s hub as five routes a runner — a
process that serves a model from a machine the app cannot reach, and connects
*out* for its work — talks to with `createHttpRunnerClient`:

| Route | Body → answer |
|---|---|
| `GET /` | who the token belongs to (`{ id, label, tags, concurrency, models }` — the models the provider rows its tags serve declare), the runner's check at start |
| `POST /claim` | `{ wait?, limit? }` → `{ requests }`, held open up to `wait` ms (the hub caps it at 25 s) until there is work |
| `POST /heartbeat` | `{ requests }` → `{ held }`, the leases extended, what the runner should keep working on |
| `POST /requests/:id` | `{ result } \| { error }` → `{ delivered }` |
| `POST /requests/:id/chunks` | `{ seq, parts }` → `{ accepted }`: one batch of a `stream` request's parts; `false` when the runner no longer holds the request, which tells it to stop |

The bearer token is the runner's, minted by `hub.registerRunner` and shown
once; anything else is `unauthorized` 401. Mount it behind `withRateLimit`
and `withErrorMapping`; it needs no session and no scope, since runners are
the deployment's. The hub may be a function of the host's context, as the
workflow handler's `engine` may.

## `./node` — Express, Connect, NestJS, `http.createServer`

`./fetch` binds the cores to web standards: a route is
`(request: Request, ctx) => Response`. Node's `http` speaks `IncomingMessage`
and `ServerResponse` instead, and Express is a thin layer over those. `./node`
is the bridge, both ways, in one middleware. It was the separate package
`@octabits-io/express` until 2026-09-11.

```ts
import express from 'express';
import { compose, withErrorMapping, withRateLimit, withRequestScope, withSecurityHeaders } from '@octabits-io/server/fetch';
import { directIp, toNodeHandler } from '@octabits-io/server/node';

const route = compose(
  withSecurityHeaders(),
  withErrorMapping(),
  withRateLimit({ max: 60, getDirectIp: directIp }),
  withRequestScope({ createScope: () => container.createScope() }),
);

const app = express();
app.get('/api/contacts/:id', toNodeHandler(route(async (_request, { params, scope }) => {
  return scope.resolve('contacts').get(params.id); // a Result — a keyed error becomes the standard JSON body
})));
app.listen(3000);
```

| Export | What |
|---|---|
| `toNodeHandler(handler, options?)` | The middleware: `(req, res, next?)`. Builds the `Request`, calls the handler with a `NodeContext`, writes the `Response`. A throw goes to `next(error)` when the host passed one (Express's error pipeline), else answers the standard 500 body; `withErrorMapping` in the composition means it never throws. |
| `toRequest(req, options?)` | The web `Request` for an incoming message: method, the **full** URL (`req.originalUrl` under a router mount, where Express strips the mount path from `req.url`), every header, and the body — as the stream, or as what a body parser already read (`req.body`: an object re-serialised as JSON, a string or Buffer as is). |
| `sendResponse(res, response)` | The web `Response` written back: status, every header, every `Set-Cookie` as its own header, the body **streamed** — a `ReadableStream` that stays open (server-sent events) reaches the client chunk by chunk, and a client that goes away cancels it. |
| `directIp` | `withRateLimit`'s `getDirectIp` over this context: the socket's peer address, so the limiter keys on the client and not on one shared `unknown` bucket. |
| `NodeContext` | What the handler's context carries beyond what the wrappers add: `params` (Express's, `{}` without a router), `remoteAddress`, and `node: { req, res }` for what only Node can do. |

`options.context(req, res)` adds the app's own context; `options.origin(req)`
sets the scheme and host the URL is built on — behind a proxy that terminates
TLS, from `X-Forwarded-Proto`, when the proxy is yours.

**Express is never imported.** The middleware returned is `(req, res, next?)`,
which Express, Connect and `http.createServer` all accept, and NestJS on the
Express platform reaches through `@Req()` / `@Res()`. That shape is the whole
contract; `node:stream` is the only runtime import and `node:http` supplies the
types. The handler is typed structurally too, so anything
`(Request, ctx) => Response` fits — the composed routes from `./fetch`, the
workflow handler from `./fetch/octaflow`, the MCP handler from `./mcp`, the
event stream from `@octabits-io/events`, the storage serve handlers.

The four things a naive bridge gets wrong, and what this one does:

- **The URL under a router mount.** Express strips the mount path from
  `req.url`; `toRequest` reads `req.originalUrl` when it is there, so a handler
  that dispatches on the pathname sees what the client asked for. Bare `http`
  has no `originalUrl` and `req.url` is already full.
- **A body parser spends the stream.** When `req.body` is set the message
  cannot be read again, so the body is rebuilt from it — JSON for an object,
  as-is for a string or a Buffer. `express.json()` leaves `{}` for an empty
  body; that becomes no body, not `{}`. A streamed body carries
  `duplex: 'half'`, which Node's `Request` requires.
- **Streaming and client disconnect.** `sendResponse` flushes the headers
  before piping, or a server-sent-events body that holds its first chunk back
  leaves the client waiting on headers; multiple `Set-Cookie`s go through
  `getSetCookie()`, never `Headers.forEach`, which joins them. `res` closing
  destroys the piped readable, which cancels the web stream, so the SSE
  handler's cleanup runs.
- **A throw reaching the host's pipeline.** With `next`, the error goes to
  Express's error handler; without one, the standard keyed 500 body.

The rate limit needs the peer: without `getDirectIp: directIp` it keys every
request to `unknown` and warns once. The adapter puts `req.socket.remoteAddress`
on the context and does not read `X-Forwarded-For` — that is the wrapper's
`trustedProxies` decision.

The tests boot Express 5 and a bare `http.createServer` on an ephemeral port
and drive them with `fetch`: routes with params, a router mount, `express.json()`
before the handler and no parser at all, every header and two `Set-Cookie`s
back, a streaming body arriving before it is finished, the peer address feeding
`withRateLimit` to a 429, a keyed error to its status by convention and by
override, a throw landing in Express's error handler, and the standard 500 when
there is no `next`. No mocks of the request or the response.

## `./mcp` — an MCP endpoint as a fetch handler

`createMcpFetchHandler` serves the Model Context Protocol on the official SDK
v2 (`@modelcontextprotocol/server`), so a Next.js route handler, a Bun `serve`
or any `(Request) => Response` host is an MCP server with no HTTP framework
involved. The lifecycle is per request:

1. `parseScopeKey(url)` — the invalid-scope response when it yields nothing.
   There is deliberately no default convention:
   `createPathSegmentScopeParser('scope')` for `/scope/:scopeKey/`, or
   `() => 'default'` for a single-scope deployment.
2. `resolveScope({ scopeKey, url, request })` — auth and scope, inline, before
   the SDK sees the request; an early `{ response }` (a `jsonRpcError`, say) is
   returned verbatim.
3. a **fresh** `McpServer`, with `registerTools(server, getScope)` binding the
   tools to *this* request's scope — no module-level server, no context
   smuggling.
4. one JSON response per request, so it is fully materialised before the
   handler resolves and disposing the scope in a `finally` cannot cut a tool
   invocation short.

Both protocol eras are served. A 2026-07-28 request — the per-request `_meta`
envelope, `server/discover` instead of `initialize` — goes to the SDK's
`createMcpHandler`; a 2025-era client (`initialize`, then calls) to the SDK's
web-standard transport, stateless. `legacy: 'reject'` answers the 2026-07-28
protocol only. The principal `resolveScope` verified reaches the tools as
`ctx.http.authInfo` through `authInfo: (scope) => ({ token, clientId, scopes })`;
its `extra` also carries `MCP_REQUEST_KEY`, the harness's own reference to the
request — leave it alone.

```ts
// app/api/mcp/[scope]/route.ts
import { createMcpFetchHandler, createPathSegmentScopeParser } from '@octabits-io/server/mcp';

const handler = createMcpFetchHandler<MyScope>({
  parseScopeKey: createPathSegmentScopeParser('mcp'),
  resolveScope: async ({ scopeKey }) => ({ scope: container.createScope(scopeKey) }),
  registerTools: (server, getScope) => {
    server.registerTool('whoami', { annotations: { readOnlyHint: true } }, async (ctx) => ({ content: [{ type: 'text', text: ctx.http?.authInfo?.clientId ?? getScope().id }] }));
  },
  serverInfo: { name: 'my-server', version: '1.0.0' },
});
export const POST = handler; export const GET = handler; export const DELETE = handler;
```

Declare the scope type as extending `DisposableScope` even when it has nothing
to dispose: an object type with no member in common fails TypeScript's
weak-type check against the optional `dispose`, and a bare `{ db }` scope is
rejected at the call site. `apps/starter-next` is the consumer: `/api/mcp` behind
an API key checked through `./auth`, three tools over its contacts, and
`src/mcp.test.ts` driving the handler with plain `Request`s — the shape a
Next.js app on the offer tier copies.

### Signing an MCP client in

An MCP client signs in the way the protocol describes: it calls the endpoint,
gets a 401 whose `WWW-Authenticate` names the endpoint's protected resource
metadata (RFC 9728), finds the authorization server there, runs OAuth with the
person at the keyboard, and comes back with an access token issued for the
endpoint. The SDK carries the HTTP half — `requireBearerAuth` (the header, the
resource, the scopes, the expiry, the challenge) and `oauthMetadataResponse`
(the discovery documents). `createMcpTokenVerifier` is the half it leaves to
the host: a JWT access token from the app's own issuer, verified by its key
set through `./auth`'s validator, its audience the endpoint's URL. A token
minted for the app's API has another audience and is refused here, and the
reverse.

```ts
import { getOAuthProtectedResourceMetadataUrl, oauthMetadataResponse, requireBearerAuth } from '@modelcontextprotocol/server';
import { createMcpFetchHandler, createMcpTokenVerifier, mcpTokenClaims } from '@octabits-io/server/mcp';

const resource = new URL('https://app.example.com/api/mcp');
const gate = requireBearerAuth({
  verifier: createMcpTokenVerifier({
    issuerUrl, jwksUri, resource, logger,
    claims: (payload) => (typeof payload.org === 'string' ? { ok: true, value: { orgId: payload.org, userId: payload.sub! } } : { ok: false, message: 'no organisation' }),
  }),
  expectedResource: resource,
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resource),
});
const mcp = createMcpFetchHandler<Scope>({
  parseScopeKey: () => 'default',
  resolveScope: async ({ request }) => {
    const auth = await gate(request);
    if (auth instanceof Response) return { response: auth };            // the 401 challenge, verbatim
    const { orgId } = mcpTokenClaims<{ orgId: string }>(auth)!;
    return { scope: await scopeFor(orgId, auth) };
  },
  authInfo: (scope) => scope.auth,                                       // tools: ctx.http.authInfo
  registerTools, serverInfo,
});
export const handler = (request: Request) => oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: resource }) ?? mcp(request);
```

The verifier reads the client from `client_id`, `azp` or `appid` and the
scopes from `scope` or `scp`; every refusal is an `OAuthError` the gate turns
into the challenge (`invalid_token`; an unreachable key set `server_error`).
What the token says about the host — its organisation, its roles — comes
through `claims`, the mapper shape `./auth` takes, and back out of
`mcpTokenClaims(authInfo)`.

Which issuer:

- **Better Auth** (`@better-auth/mcp`) is an authorization server for MCP
  clients out of the box: client ID metadata documents (`@better-auth/cimd`),
  dynamic registration when enabled, consent, and access tokens signed by the
  `jwt` plugin's keys with the endpoint as their audience — `jwksUri` is
  `betterAuthJwksUri(baseUrl)` from `./auth/better-auth`, the same key set the
  API's tokens are verified by.
- **Zitadel** and **Microsoft Entra ID** issue such tokens to a client
  registered by hand: neither takes a client ID metadata document, so the
  client is given its client id by hand, where the MCP client takes one.
  Entra puts its application ID URI in `aud` — pass it as
  `audience` — and its scopes in `scp`; `issuerUrl` and `jwksUri` are
  `entraIssuer(directoryId)` and `entraJwksUri(directoryId)` from
  `./auth/entra`. Zitadel signs JWT access tokens when the client's token
  type is set to JWT; its `aud` carries the project id.

`jose` is an optional peer of the package; the verifier needs it, as `./auth`
does.

### The write discipline

Agents propose, humans decide — and the harness holds every tool to it. A tool
is one of three things, and says which at registration:

```ts
registerTools: (server, getScope) => {
  // read: the SDK's own hint
  server.registerTool('contacts_search', { inputSchema: z.object({ query: z.string() }), annotations: { readOnlyHint: true } }, …);
  // proposing: the result carries the Proposal, or the run that will end in one
  server.registerTool('contacts_propose', proposingTool({ inputSchema: z.object({ id: z.string() }) }), async ({ id }) => ({
    content: [{ type: 'text', text: 'queued for review' }],
    structuredContent: { pending: { id: String(run.id), status: 'awaiting_review' } },   // or { proposal }
  }));
  // direct: only under writes: { policy: 'allow' }, and never without a reason
  server.registerTool('cache_purge', directTool({ inputSchema: z.object({}) }, { reason: 'the operator runs this under their own permissions' }), …);
},
```

A tool that is not read-only and declares nothing does not register. Under the
default policy, `writes: { policy: 'propose' }`, neither does a `directTool`;
`{ policy: 'allow' }` admits it, recorded as `direct`. A proposing tool whose
successful result carries neither `structuredContent.proposal` nor
`structuredContent.pending` is answered with an error result, recorded as
`unproposed`, and the request's scope is disposed **without commit** — a
transactional scope rolls the write back; a scope that commits as it goes has
already kept it, and the record is the violation to look at. The Proposal
check is structural by default (`scope`, `operations`); pass
`writes: { isProposal: (v) => proposalSchema.safeParse(v).success }` with
`@octabits-io/proposal` for the full contract. `guardMcpServer(server, getScope,
{ audit?, writes?, onViolation? }, logger?)` is the wrapper itself, for a host
that builds its own `McpServer`.

What `registerTools` receives is not the `McpServer` but its registration
surface, `McpRegistrar` (`GuardedMcpServer<McpServer>`): `registerTool`,
`registerResource`, `registerPrompt`, the four
`send*` notifications and `isConnected` — the list is `GUARDED_MCP_SERVER_MEMBERS`
(v2 dropped the deprecated `tool()`, `resource()` and `prompt()`).
Everything else the SDK's class carries is withheld, because each is a way to
register or swap a handler the guard would never see: the low-level `server`
(whose `setRequestHandler` replaces the whole `tools/call` dispatch),
`experimental` (`tasks.registerToolTask`), the SDK's tool table and factory
(`_registeredTools`, `_createRegisteredTool`), `connect` and `close`. A tool
file that takes the server as a parameter types it as `McpRegistrar`. The
`server/mcp` suite enumerates the real `McpServer` and asserts that nothing
outside the list reaches the registrar.

### What a result discloses

A tool's result goes to the calling agent, and the agent hands it to whatever
model it runs — a payload leaving the house, like any model call. Pass
`disclosure` and every result goes through `@octabits-io/disclosure`'s gate
first, as a `tool-result` operation:

```ts
import { createMcpFetchHandler, disclosedAs } from '@octabits-io/server/mcp';

createMcpFetchHandler<MyScope>({
  // …
  disclosure: {
    destination: { id: 'mcp-client', region: 'third-country' }, // the client's model, as far as you know it
    redact: (text) => pseudonymise(text),                       // internal results leave only redacted
    sink: (record, scope) => scope.disclosureLog.record(record),
    client: (scope) => scope.clientName,
  },
  registerTools: (server) => {
    server.registerTool('handbook', disclosedAs({ annotations: { readOnlyHint: true } }, 'public'), …);
    server.registerTool('contact', disclosedAs({ inputSchema, annotations: { readOnlyHint: true } }, 'internal'), …);
    server.registerTool('propose_brief', disclosedAs(proposingTool({ inputSchema }), 'internal'), …);
  },
});
```

Each tool declares its class with `disclosedAs` — `public`, `internal` or
`confidential` — and one that declares nothing is `confidential`, so a
forgotten declaration fails closed. For a destination outside the house a
`confidential` result is never sent and an `internal` one only redacted (every
text it carries, the Proposal inside it too; structure — `type`, ids, `op`,
`path` — is kept). A refused result becomes an error result that says why, and
nothing of the content leaves. Every decision — sent, redacted, refused — is a
disclosure record with this request's scope, and the tool-call record says
which (`disclosure: { class, outcome }`). `region: 'self-hosted'` only for an
agent whose model runs in your house: then every class goes. Where the
redaction depends on what a tool read — the names of the people in its result
— pass `redactFor: (scope) => redactor` instead of `redact`: the tool collects
the names on its scope while it runs, and the gate redacts with them.

### Auditing tool calls

Pass `audit` and every `registerTool` call is recorded: one `McpToolCallRecord`
per invocation — `tool`, the `scopeKey` the harness parsed, the `actor`, the
arguments after redaction, the tool's declared `readOnlyHint`, what it did
(`writes`: `read`, `proposal`, `pending`, `direct` or `unproposed`), `outcome`
with the error text, `startedAt` and `durationMs` — handed to your `sink` with
this request's scope, after the handler finished and before the response
leaves. The package ships no store: the sink writes where your records already
live — a table you migrate, the structured logger, a ledger — and a sink that
throws is logged, never surfaced to the agent.

The table, when you want one: `mcpToolCallSink(storeOf)` is the sink over a
`McpToolCallStore` — `createPostgresMcpToolCallStore({ db, scope })` from
`./mcp/postgres` (its table is `mcpToolCallDdl()`, or `mcpToolCallColumns` from
`./mcp/drizzle` for drizzle-kit, spread beside your scope column), or
`createMemoryMcpToolCallStore()` for tests. `list({ since, until, tool, limit })`
reads it back newest first. A record never holds the result — that is what left
the house; the disclosure log holds its digest.

```ts
createMcpFetchHandler<MyScope>({
  // …
  audit: {
    sink: (record, scope) => scope.auditLog.append(record),
    actor: (scope) => scope.userId,
    redact: (args) => redactToolArguments(args, { keyPattern: /email|name|token|password|secret|api[-_]?key|authorization/i }),
  },
});
```

`actor` names who called: `resolveScope` authenticated the request, so the
scope already knows — a user id, an API key's id, a service name. Return an
identifier, never the token; the harness never reads the auth info itself. A
resolver that throws is logged and the record goes out without an actor. The
sink runs on the request path, so the call waits for it at most `timeoutMs`
(default `DEFAULT_AUDIT_SINK_TIMEOUT_MS`, five seconds): past it the timeout is
logged, the agent gets its answer, and the sink's promise settles on its own —
a late failure is logged too.

`redactToolArguments` is the default: credential-shaped keys replaced, strings
capped at 500 characters, arrays at 20 items, nesting at six levels. It removes
credentials, not personal data — widen `keyPattern` or pass your own `redact`
when agents search by name or e-mail, as `apps/starter-next` does. The request
extra the SDK hands a tool registered without an `inputSchema` (auth info, the
session) is never recorded.

The wrapper is a plain object, never a `Proxy`, that binds the registration
surface to the real `McpServer` (so `this` is always the real instance whatever
the SDK keeps private) and exposes nothing else — see the write discipline
above for what is withheld and why. The handle `registerTool` returns is
guarded too: the SDK runs the handle's `executor` at call time, so an
`executor` assigned to the handle directly, or rebuilt by `update({ callback })`, is re-wrapped,
and the deprecated `tool()` throws. A call the SDK refuses before the handler
runs — an unknown or disabled tool, arguments that fail the input schema — is
not recorded; the error response is its only trace. `auditMcpServer(server,
getScope, audit, logger?)` is `guardMcpServer` with the audit and the default
write policy.

`@modelcontextprotocol/server` (v2) is an optional peer, confined to this subpath by
the boundary lint. The scope-key conventions it builds on —
`createPathSegmentScopeParser`, `SCOPE_KEY_PATTERN`, `MAX_SCOPE_KEY_LENGTH`,
`jsonRpcError`, `DisposableScope` — are on the root and re-exported here.

## `./testing`

`testRequest(app, method, path, { body?, headers?, query?, token?, decodeBody? })`
and `testAuthenticatedRequest(app, method, path, options, authHeader)` drive an
app through its `handle(Request)` and return `{ status, data, headers }`. The
app is the structural `TestableApp`; a Hono instance and any fetch-style
handler satisfy it. JSON is parsed, `204`/`301`/`302` decode to `null`, PDF and
octet-stream to bytes, everything else to text; override with `decodeBody`.
No vitest import, so it works under any runner. `createTestAuthorization` and
`testRequestAs` run a request as a subject with given roles — see
[`./authz`](#authz--what-the-caller-may-do).

## `./idempotency`

The `Idempotency-Key` header is an HTTP concern, so the store that honours it
lives here. `begin({ key, requestHash })` classifies a request as one of three
outcomes:

| Outcome | Meaning | What you do |
|---|---|---|
| `cached` | a previous successful response for this key | replay `cached.status` and `cached.body` |
| `fresh` | no live record | run the operation, then `commit(status, body)` — only on success |
| `conflict` | the same key with a *different* request signature | answer 409 |

```ts
import { hashRequest, isValidIdempotencyKey } from '@octabits-io/server/idempotency';
import { createPostgresIdempotencyService, idempotencyKeyDdl } from '@octabits-io/server/idempotency/postgres';

const idempotency = createPostgresIdempotencyService({ db: pool, scope: { column: 'org_id', value: orgId } });

export async function POST(request: Request) {
  const body = await request.json();
  const key = request.headers.get('idempotency-key');
  if (key && !isValidIdempotencyKey(key)) return Response.json({ key: 'idempotency_key_invalid' }, { status: 400 });
  const outcome = key ? await idempotency.begin({ key, requestHash: hashRequest('POST', '/contacts', body) }) : null;
  if (outcome?.kind === 'cached') return Response.json(outcome.cached.body, { status: outcome.cached.status });
  if (outcome?.kind === 'conflict') return Response.json({ key: 'idempotency_key_conflict' }, { status: 409 });
  const created = await createContact(body);
  if (outcome?.kind === 'fresh') {
    const committed = await outcome.commit(201, created);
    if (committed.kind === 'raced') return Response.json(committed.cached.body, { status: committed.cached.status });
  }
  return Response.json(created, { status: 201 });
}
```

`hashRequest(method, pathTemplate, body)` is the signature: SHA-256 over the
method, the path and a key-sorted serialisation of the body, so a re-serialised
retry still matches and a different body does not. `isValidIdempotencyKey` is
Stripe's rule, 1–255 printable ASCII. Records carry a TTL (24 h by default);
an expired record is fresh again, and every successful commit sweeps the
scope's expired rows, so there is no cron.

`commit()` never throws for a race. When two requests with one key were both
classified `fresh`, the commit that lands second gets `{ kind: 'raced', cached }`
if the winner had the same signature — replay it, so every caller sees one
canonical response — or `{ kind: 'race_conflict' }` if it did not. `begin()` is
check-then-act, so *concurrent* duplicates may both run the operation;
sequential duplicates are fully deduplicated. If the operation itself cannot
tolerate a double run, serialise it in your database before reaching for a key
store.

`./idempotency/postgres` takes `db: SqlExecutor` — `query(text, params)`
resolving to `{ rows }` — which a `pg` `Pool` or `Client` and a PGlite instance
satisfy as-is, and postgres.js through
[`postgresJsExecutor`](https://octabits.io/platform/postgres.md). The type is declared in this
package: the HTTP cores never grow a database dependency. `idempotencyKeyDdl()`
emits the table for a migration-managed schema; with `scope: { column, value }`
every read is filtered by it and every write stamps it, over a shared
`(scope, key)` table, and without one the key alone is the primary key. The
Drizzle adapter over the same contract is `./idempotency/drizzle`:

```ts
import { createIdempotencyService, idempotencyKeyColumns } from '@octabits-io/server/idempotency/drizzle';

export const idempotencyKey = pgTable('idempotency_key', {
  ...idempotencyKeyColumns,
  orgId: text('org_id').notNull(),           // your scope column
}, (t) => [primaryKey({ columns: [t.orgId, t.key] }), index('idempotency_key_expires_at_idx').on(t.expiresAt)]);

const idempotency = createIdempotencyService({ db, table: idempotencyKey, dateProvider, scope: { column: 'orgId', value: orgId } });
```

One conformance suite runs the in-memory, the SQL and the Drizzle store against
the same scenario on an embedded PGlite. `drizzle-orm` is an optional peer
loaded by the two Drizzle subpaths alone — this one and `./rate-limit/drizzle` —
and those are the two files of the package outside the portability gate: the
package's identity is "no ORM, no HTTP framework", and a
binding that names what it binds to is outside the claim the gate proves, the
same reasoning `./node` already carries.

The store lived in `@octabits-io/postgres/idempotency` until 2026-09-10; that
subpath was a re-export shim for one minor and was removed on 2026-09-12. The
Drizzle adapter was `@octabits-io/framework/drizzle/idempotency` until
2026-09-12, when it joined the capability it adapts; the shim left behind there
was removed in framework 0.50.0.

## `./auth` — who the bearer is

The API side of sign-in, the piece `withBearerAuth` validates a token with. It
issues nothing and stores nothing; it answers "is this token real, and who does
it name". Three pieces, and each satisfies the root's structural
`BearerTokenValidator`, so they drop straight into the wrapper.

**`createJwtValidationService({ issuerUrl, audience, logger, claimMapper, jwksUri?, algorithms?, authBypassSecret?, bypassToken? })`**
verifies signature, issuer and audience against a JWKS through `jose` (an
optional peer, loaded lazily on the first validation — the rest of the subpath
works without it installed), then hands the verified payload to your
`claimMapper`. All provider-specific claim knowledge lives in that mapper.
Errors are a taxonomy, not exceptions: `missing_token`, `invalid_token`,
`expired_token`, `missing_claims`, and `jwks_unavailable` — the provider is
unreachable, a 503 rather than a 401, which `withBearerAuth` maps for you.
Asymmetric algorithms only by default (RS/PS/ES/EdDSA), because HMAC against a
public key set is an algorithm-confusion attack.

`jwksUri` names the key set directly, for an issuer that publishes keys but no
OIDC discovery document — Better Auth's `jwt` plugin is the shipped case. Unset,
`{issuerUrl}/.well-known/openid-configuration` is fetched once and its
`jwks_uri` used, with a 30-second cooldown after a discovery failure.
`authBypassSecret` + `bypassToken` are the E2E hatch, and are neutralised
whenever `isProduction()` holds — a leaked variable cannot enable them.

```ts
import { createJwtValidationService } from '@octabits-io/server/auth';

const validator = createJwtValidationService({
  issuerUrl: ISSUER, audience: CLIENT_ID, logger,
  claimMapper: (payload) => payload.sub ? { ok: true, value: { userId: payload.sub } } : { ok: false, message: 'no sub' },
});
```

**`createApiKeyFormat({ prefix })`** shapes and checks
`<prefix><keyId>.<secret>` tokens — `keyId` a 12-character base64url lookup id
so a row is fetched in O(1) before any compare, `secret` 256 bits of entropy,
only its lowercase-hex SHA-256 persisted. `generateKeyId`, `generateSecret`,
`formatToken`, `deriveKeyPrefix` (what a list shows), `isApiKeyToken`,
`parseToken`, `hashSecret`, `verifyHash` (constant time). No I/O: key storage is
yours. Web-platform crypto throughout — `crypto.getRandomValues` and
`@noble/hashes` — so it runs where `node:crypto` does not.

**`createBearerAuthService({ strategies })`** is the ordered dispatcher: the
first strategy whose `matches(token)` claims the token owns it, and its
`Result` is returned verbatim. That is how one `/api/protected` accepts both an
API key and a JWT, told apart by shape. `no_matching_strategy` and
`missing_token` are the dispatcher's own two errors; `extractBearerToken` is the
single `Authorization: Bearer …` parse everything here shares.

### A new password against the breach corpus

**`checkBreachedPassword(password, { fetch?, rangeUrl?, timeoutMs?, maxBytes?, minCount?, failClosed? })`**
asks the Have I Been Pwned range API whether a password is in a known breach,
by k-anonymity: the first five hex characters of the password's SHA-1 go on
the wire, the matching suffixes come back, and the compare happens here. The
password and its full hash never leave the process, and `Add-Padding: true`
makes every answer the same shape, so the length of the response tells an
observer nothing either. The call is bounded in time (3 s) and in size (1 MiB),
follows no redirect, and goes to one fixed host — `rangeUrl` points it at a
mirror of the same API for an install that must not reach the public one. An
air-gapped install does not call it at all; that switch is yours.

It only matters for a host that still lets people sign in with a password.
Where to run it is yours too — sign-up, a password change, a reset — and so
is what to do when the range API is down. By default the check **fails open**:
the result is `ok` with `checked: false` and the error beside it under
`unavailable`, because a down range API must not block every sign-up.
`failClosed: true` returns the error instead (`breach_check_unavailable`,
`breach_check_timeout`, `breach_check_malformed`; the messages are fixed and
never carry the password, the hash or the prefix).

```ts
import { checkBreachedPassword } from '@octabits-io/server/auth';

const check = await checkBreachedPassword(password, { minCount: 1 });
if (!check.ok) return err(check.error);                       // failClosed only
if (!check.value.checked) log.warn('breach check skipped', check.value.unavailable);
if (check.value.breached) return err({ key: 'password_breached', message: 'Choose another password: this one is in a known breach.' });
```

On Better Auth, a `before` hook covers sign-up, a password change and a reset
in one place (Better Auth also ships its own `haveibeenpwned` plugin, which
rejects on every path it hashes on; use this helper when you want the
fail-open default, a mirror, or the same check outside Better Auth):

```ts
import { createAuthMiddleware, APIError } from 'better-auth/api';
import { checkBreachedPassword } from '@octabits-io/server/auth';

const PASSWORD_PATHS: Record<string, string> = {
  '/sign-up/email': 'password',
  '/change-password': 'newPassword',
  '/reset-password': 'newPassword',
};

hooks: {
  before: createAuthMiddleware(async (ctx) => {
    const field = PASSWORD_PATHS[ctx.path];
    const password = field ? ctx.body?.[field] : undefined;
    if (typeof password !== 'string') return;
    const check = await checkBreachedPassword(password);
    if (check.ok && check.value.breached) {
      throw new APIError('BAD_REQUEST', { message: 'This password appears in a known breach. Choose another.' });
    }
  }),
},
```

### `./auth/better-auth` — the claim shape

Better Auth's `jwt` plugin lets the server decide a token's payload, and the
validator above hands a verified payload to a mapper. This subpath is the
contract between those two callbacks, written once and `zod`-checked:
`defineOctabitsJwtPayload({ memberships, extend? })` for the Better Auth side
(the user, **every** organisation membership as `{ id, slug, name, role }`, and
the session's active organisation), `parseOctabitsClaims` and
`betterAuthClaimMapper(project?)` for the API side, `betterAuthJwksUri(baseUrl)`
for where the keys are, `membershipOf` / `activeMembershipOf` for the two
lookups a route makes. Every membership, because listing a user's tenants and
checking a grant per request then cost no second call.

The token also carries the **proof** `./authz`'s `requireFresh` reads:
`auth_time`, the Better Auth session's `createdAt` (Better Auth opens a new
session on every sign-in, a passkey re-authentication included), and `amr`
when the host says how the session was opened — `proof: (session) =>
({ authenticatedAt?, methods? })` in `defineOctabitsJwtPayload`'s options. The
principal keeps both as `proof: { authenticatedAt, authMethods }`.

```ts
import { betterAuthClaimMapper, betterAuthJwksUri } from '@octabits-io/server/auth/better-auth';

const validator = createJwtValidationService({
  issuerUrl: BASE_URL, jwksUri: betterAuthJwksUri(BASE_URL), audience: BASE_URL,
  claimMapper: betterAuthClaimMapper(), logger,
});
```

`better-auth` is never imported: the payload callback is a plain function over
`{ user, session }`, so any version fits and the boundary lint refuses the
import. The browser half of the same bridge is
`@octabits-io/session/better-auth`, which reads these claims structurally.

### `./auth/entra` — Microsoft Entra ID

For an organisation whose people already sign in with Microsoft. The validator
above does the cryptography; this subpath is the two URLs it needs and the
mapper behind it. `entraIssuer(directoryId)` and `entraJwksUri(directoryId)`
are the v2.0 endpoints of one directory (the "Directory (tenant) ID" GUID; a
national cloud passes its host). `parseEntraClaims(payload, { requiredScope?,
allowApplications? })` turns a verified payload into an `EntraPrincipal`:
`objectId` from `oid` — the same in every app of the directory, where `sub` is
pairwise per application — `directoryId` from `tid`, the app `roles`, the
delegated `scopes`, and `kind`: `user` when a person is calling through a
client, `app` for a client-credentials token, refused unless
`allowApplications` is set. A token with neither `scp` nor `roles` is refused,
as Microsoft says to. When the directory left the groups out for size,
`groupsOverage` is true instead of an incomplete list passing as complete.

```ts
import { entraClaimMapper, entraIssuer, entraJwksUri } from '@octabits-io/server/auth/entra';

const validator = createJwtValidationService({
  issuerUrl: entraIssuer(DIRECTORY_ID), jwksUri: entraJwksUri(DIRECTORY_ID),
  audience: API_CLIENT_ID,   // a v2.0 token's aud is the API registration's application id
  claimMapper: entraClaimMapper({ requiredScope: 'access_as_user' }), logger,
});
```

**v2.0 tokens only.** An API registration issues v1.0 access tokens until its
manifest says `"requestedAccessTokenVersion": 2`; those carry the issuer
`https://sts.windows.net/<id>/` and fail here as `invalid_token`. The browser
half is `@octabits-io/session/oidc`'s `entraAuthority`, `entraScope` and
`entraUserMapper`; the registration, end to end, is the
`configure-entra-for-an-app` skill.

Both subpaths were `@octabits-io/framework/auth` and
`@octabits-io/better-auth/server` until 2026-09-11; the framework subpath was a
one-minor re-export shim, retired on 2026-09-12, and the old package name is
deprecated on npm.

## `./authz` — what the caller may do

`./auth` says who the caller is; `./authz` says what that caller may do, so a
route asks one question and never inspects an identity itself — no
email-suffix check, no allow list in a route, nothing to forget to lowercase.
Permissions are string keys, roles are data, and who holds which role is a row.

```ts
import { createAuthorization, definePermissions, keepLastHolder, noSelfRoleChange, requirePermission, timeBoxed } from '@octabits-io/server/authz';
import { createPostgresRoleAssignmentStore } from '@octabits-io/server/authz/postgres';

export const permissions = definePermissions({
  roles: {
    support: ['users.read'],
    operator: ['users.read', 'users.offboard'],
    admin: ['users.read', 'users.offboard', 'roles.manage'],
    elevated: ['users.read', 'users.offboard', 'users.delete'],
  },
});

export const authz = createAuthorization({
  permissions,
  store: createPostgresRoleAssignmentStore({ db: pool }),
  guards: [noSelfRoleChange(), keepLastHolder('admin'), timeBoxed('elevated', { maxMs: 4 * 3600_000 })],
});

// in a route — `scope` is anything that carries `permissions: authz.forSubject(principal.sub)`
const allowed = await requirePermission(scope, 'users.offboard');
if (!allowed.ok) return allowed;   // `forbidden` → 403, `unauthorized` → 401, through the error mapping
```

A key no role declares does not compile — at `requirePermission`, at the bound
gate's `can` / `require`, and at `withPermission`'s `permission`. A malformed
key in the map (`Users.Read`, `users..read`) throws when the map is defined.

**Assignments are rows** — `{ id, subject, role, grantedBy, grantedAt,
expiresAt, suspendedAt, suspendedBy, revokedAt, revokedBy }`, plus the scope
value — and stop working three ways: revoked (for good), suspended (until
resumed), expired. A time-boxed, elevated or break-glass role is an assignment
with an `expiresAt`; it is refused once that time passes, with no job running
and no row rewritten.

**Roles from the credential, beside the rows.** Where the identity provider
already says what someone is — a role claim, the membership role in the
organisation a token names — hand those names to the gate:
`authz.forSubject(sub, { scopeKey, roles: principal.roles })`, or
`withPermission({ …, roles: ({ principal }) => principal.roles })`. The subject
then holds the rows' roles and the credential's together; `access().credentialRoles`
says which came from where. A name the map does not declare grants nothing, the
credential's roles are never written as rows nor cached past the call, and
`grant` / `revoke` change only rows — the provider stays in charge of what it
issues, the app of what it grants on top.

**Resolution is per request**, through a cache bounded twice: by `cacheTtlMs`
(default 30 s, refused above 60 s) and by the earliest `expiresAt` among the
subject's active assignments. A revoke or a suspend made on another replica
stops working on the first request after the window; one made through the same
instance drops that subject's entry at once. `authz.forSubject(subject)`
resolves at most once, so a request sees one answer throughout.

**Changes run on your transaction** — `grant(tx, …)`, `revoke(tx, …)`,
`suspend(tx, …)`, `resume(tx, …)`, each returning a `Result` — so a role
change and its [audit](https://octabits.io/platform/audit.md) entry commit together. The store
takes a transaction-scoped advisory lock per scope value first, so the guards
read holders no concurrent change can move: the last two admins revoking each
other at the same moment leave one admin, not none (the integration suite runs
exactly that on a pool, and fails without the lock). Hand a change a pool
instead of a transaction and the lock ends with its statement.

| Key | Status | When |
|---|---|---|
| `unauthorized` | 401 | an anonymous caller at the gate |
| `forbidden` | 403 | the permission is not held; `noSelfRoleChange` |
| `invalid_role` | 400 | a role the map does not declare |
| `invalid_role_assignment` | 400 | an expiry in the past, an empty subject, a `scopeKey` on an unscoped store; `timeBoxed` |
| `role_assignment_not_found` | 404 | no such assignment in that scope |
| `already_granted` | 409 | the subject holds the role in force already |
| `already_revoked` | 409 | any change to a revoked assignment |
| `role_assignment_invalid_status` | 409 | a suspend of a suspended one, a resume of one that is not |
| `last_holder_conflict` | 409 | `keepLastHolder` |

**Guards are hooks you supply**: `(change, context) => Result`, run in order
before every change, the first refusal returned as it is. `change` names the
kind, the actor, the subject, the role and the scope;
`context.activeHolders(role)` reads under the lock. Three ship ready:
`noSelfRoleChange()`, `keepLastHolder(role)` and `timeBoxed(role, { maxMs })`.

**Scope.** Give the store a scope column (`scope: { column: 'org_id' }`) and an
assignment belongs to a scope value: a role held in one grants nothing in
another, and `NULL` is a value of its own. Without one, a `scopeKey` is refused.

### `requireFresh` — did they prove it recently?

`requirePermission` never answers "did the caller prove who they are a moment
ago". Some actions want exactly that — a passkey before a role change, a
password before an export, a second factor before a purge — and every host
used to write the rule by hand, once per dangerous route, or not at all.
`requireFresh(holder, { maxAgeSeconds, methods? })` is that rule as one gate:

```ts
const allowed = await requirePermission(ctx, 'roles.manage');
if (!allowed.ok) return allowed;
const fresh = requireFresh(ctx, { maxAgeSeconds: 300, methods: ['passkey', 'totp'] });
if (!fresh.ok) return fresh;              // 401 unauthorized, or 403 step_up_required
```

The proof — when the subject last authenticated, and how — is read off
whatever carries it: a `./sessions` session (`authMethod`, `authenticatedAt`,
refreshed by `reauthenticate` without a new session) on `ctx.session`; or a
verified token's `auth_time` and `amr` — OpenID Connect's spelling of the
same two facts — through `authProofFromClaims(payload)`, which
`./auth/better-auth`'s principal keeps as `proof` and a host's own claim
mapper keeps where it likes (`ctx.principal.proof`). There is no fallback to
`iat`: a refreshed token proves nothing about when the person was there, so a
token without `auth_time` is a missing proof.

Not every provider puts the proof in the **access token**. Zitadel writes
`auth_time` and `amr` into the ID token only; Entra ID adds them to an access
token when the API's registration asks for the optional claims. Where the
access token has none, the browser's ID token is the proof: send it on the
dangerous call (a header of your choosing), verify it with the same
`createJwtValidationService` — the issuer's keys, `audience` the SPA's client
id — check its subject is the access token's, and keep
`authProofFromClaims(idTokenPayload)`. After an OIDC step-up (`prompt=login`)
the provider hands the page a new ID token with the new `auth_time`.

The refusal is `step_up_required`, **403** by the key convention (not 401: a
client treats 401 as signed out, and this caller is not), carrying
`maxAgeSeconds`, the accepted `methods` (or `null`) and the `reason` —
`no_proof`, `stale` or `method` — the one error body with fields beyond `key`
and `message` (`SCHEMA_STEP_UP_ERROR`; `./client` hands them on as the error's
`maxAgeSeconds`, `methods` and `reason`). That is what a client needs to re-prompt:
`@octabits-io/session`'s `withStepUp` reads it, runs the provider's
re-authentication (a passkey ceremony on Better Auth, `prompt=login` on
OIDC) and retries the call exactly once. An anonymous caller is
`unauthorized`. `isStepUpRequired(error)` narrows the key.

`./authz/postgres` is the store over the structural executor (`pg`, PGlite,
postgres.js), `roleAssignmentDdl({ table?, scope? })` the table with its two
partial indexes, and `createPostgresAuthorization` both in one call.
`./authz/drizzle` is `roleAssignmentColumns` and the Drizzle store
(`createDrizzleRoleAssignmentStore`, `createDrizzleAuthorization`), whose
changes take the Drizzle transaction and the same lock:

```ts
import { roleAssignmentColumns, createDrizzleAuthorization } from '@octabits-io/server/authz/drizzle';

export const roleAssignment = pgTable('role_assignment', { ...roleAssignmentColumns, orgId: text('org_id') }, (t) => [
  index('role_assignment_subject_idx').on(t.orgId, t.subject).where(sql`revoked_at IS NULL`),
  index('role_assignment_role_idx').on(t.orgId, t.role).where(sql`revoked_at IS NULL`),
]);
const authz = createDrizzleAuthorization({ db, table: roleAssignment, scope: { column: 'orgId' }, permissions });
await db.transaction((tx) => authz.grant(tx, { subject, role: 'operator', grantedBy: staffId, scopeKey: orgId }));
```

One conformance suite runs the in-memory, the SQL and the Drizzle store on an
embedded PGlite; `src/authz/postgres.integration.test.ts` runs both SQL stores
on a pool against a real Postgres (Docker).

**On the bindings.** `./fetch`'s `withPermission` binds the gate to the caller
and refuses before the handler runs; it hands the handler `ctx.permissions`,
so `requirePermission(ctx, key)` checks a second key inside. `./node` serves the
same composed route. `./testing`'s `createTestAuthorization(permissions)` is the
gate over an in-memory store with a bearer validator to match, and
`testRequestAs(app, auth, { subject, roles }, method, path)` runs a request as a
subject holding exactly those roles:

```ts
export const POST = compose(
  withErrorMapping(),
  withBearerAuth({ authService }),
  withPermission({ authz, subject: ({ principal }) => principal.sub, permission: 'users.offboard' }),
)(async (request, ctx) => offboard(ctx.permissions.subject));

// in a test
const auth = createTestAuthorization(permissions);       // the app is built with auth.authz and auth.authService
const res = await testRequestAs(app, auth, { subject: 'u-1', roles: ['support'] }, 'POST', '/users/7/offboard');
expect(res.status).toBe(403);
```

## `./sessions` — the cookie that ends when you say so

`./auth` validates stateless bearer tokens, and a stateless token cannot be
listed, revoked, or ended when someone's access is removed. A session here is
an opaque cookie backed by a row, so it can:

```ts
import { createSessions, sessionCookie } from '@octabits-io/server/sessions';
import { createPostgresSessionStore } from '@octabits-io/server/sessions/postgres';

export const sessions = createSessions({
  store: createPostgresSessionStore({ db: pool }),
  app: 'admin',
  idleTimeoutMs: 15 * 60_000,          // default 30 minutes
  absoluteTimeoutMs: 8 * 3600_000,     // default 12 hours
});
export const cookie = sessionCookie({ name: '__Host-admin', sameSite: 'strict' });

// sign-in, once the identity provider has said who it is
const created = await sessions.create({ subject, ip, userAgent });
if (!created.ok) return created;
headers.append('set-cookie', cookie.serialize(created.value.token, created.value.session));

// every request
const session = await sessions.resolve(cookie.read(request));   // the session, or null

// "your sessions", "sign out", "sign out everywhere else"
await sessions.list({ subject });
await sessions.revoke(pool, { id, subject, by: subject, reason: 'signed out' });
await sessions.revokeAll(pool, { subject, app: 'admin', except: session.id, by: subject });

// offboarding: every app's sessions, with the role revocations and the audit entry in one commit
await withTransaction(pool, (tx) => sessions.revokeAll(tx, { subject, by: staffId, reason: 'offboarded' }));
```

**The token is never stored.** `create` returns 32 random bytes as base64url —
once — and the row keeps their SHA-256. A copy of the table opens nothing,
and a token of the wrong shape is refused before any read.

**Every request reads the row**, with no cache, so a revoked session — or every
session `revokeAll` ended — resolves to `null` on the next request, on every
replica. Both timeouts are checked there, with no job running: the absolute end
(`expiresAt`, fixed at creation; `create({ expiresInMs })` shortens it for one
session, an impersonation or a step-up) and the idle one, from `lastSeenAt`.
`lastSeenAt` is written at most once per `touchIntervalMs` (default 60 s), so a
busy session is not a write per request; the idle clock runs from the last
write, so the lag only ever ends a session early, never late.

**One table, several apps.** A manager is bound to one `app`: it creates that
app's sessions and resolves no other's, so an admin surface and a product app
on one database cannot open each other with a cookie. `list` and `revokeAll`
see every app unless told one — offboarding ends them all. `onBehalfOf` marks
a session someone opened as somebody else (the grant it was opened under).

**Ending a session runs on your transaction** (`revoke(tx, …)` returns a
`Result`; `revokeAll(tx, …)` the sessions it ended), so it commits with the
audit entry or the role revocation that caused it; hand it the pool to end one
outside a transaction. `purgeEnded({ before })` deletes the rows of every app
that ended before a cutoff — revoked, expired or idle — for a retention job.

The table is read before any scope is known — it is what says who the caller
is — so it sits outside a scope's row-level security, as the identity
provider's tables do.

| Key | Status | When |
|---|---|---|
| `unauthorized` | 401 | `withSession`: no cookie, or one that resolves to no open session |
| `forbidden` | 403 | `withSession`: a cookie-carrying write from another site |
| `invalid_session` | 400 | `create` with no subject or a lifetime above the absolute timeout; `revoke` with no `by` |
| `session_not_found` | 404 | no such session — or, given `subject`, not that subject's; `reauthenticate` on an ended one |
| `already_revoked` | 409 | a second revocation |

**The proof.** A session carries how its subject proved who they are
(`authMethod`, an open string the sign-in names: `passkey`, `password`,
`totp`, …) and when (`authenticatedAt`). Both are set at `create`
(`authMethod` from the input, `authenticatedAt` now) and refreshed by
`reauthenticate(id, { method }, { tx? })` on the same session — no new token,
no new cookie — when the person proves it again. `./authz`'s `requireFresh`
reads them (below). A table created before these columns existed takes them
with `sessionProofMigrationDdl()`, which backfills `authenticated_at` from
`created_at`.

**The cookie.** `sessionCookie()` is `HttpOnly`, host-only (it never writes a
`Domain`, so a sibling subdomain can neither read nor plant it), `Secure`, and
`SameSite=Lax` (or `'strict'`), with `Max-Age` up to the absolute end
(`persistent: false` for one that ends with the browser). Its default name,
`__Host-session`, makes the browser hold it to exactly that; it throws on a
name or path a browser would drop. Give each app on one host a name of its
own. `secure: false` is for plain-http development, under another name.

**On the bindings.** `./fetch`'s `withSession` resolves the cookie and hands
the handler `ctx.session`; put `withPermission` inside it to gate on the
session's subject, and `./node` serves the same route:

```ts
export const POST = compose(
  withErrorMapping(),
  withRateLimit({ max: 120, getDirectIp }),
  withSession({ sessions, cookie, failures: { max: 20, windowMs: 60_000 } }),
  withPermission({ authz, subject: ({ session }) => session.subject, permission: 'users.offboard' }),
)(async (request, { session, permissions }) => …);
```

On a write (any method but GET, HEAD and OPTIONS) that carries the cookie,
`withSession` refuses a browser's `Sec-Fetch-Site` of `cross-site` or
`same-site` — a sibling subdomain is another app — and, from a browser that
sends none, an `Origin` that is neither the request's own nor in
`trustedOrigins`, as `403 forbidden`. `csrf: false` leaves that to you.
`failures` counts presented tokens that did not resolve, per `ctx.clientIp` (or
`keyBy`), through any rate-limit store, and answers 429 past `max`; a valid
session still resolves.

`./sessions/postgres` is the store over the structural executor,
`sessionDdl({ table? })` the table (default `app_session`) with a unique index
on the token hash and one on a subject's open sessions, and
`createPostgresSessions` both in one call. `./sessions/drizzle` is
`sessionColumns` and the Drizzle store:

```ts
import { sessionColumns, createDrizzleSessions } from '@octabits-io/server/sessions/drizzle';

export const appSession = pgTable('app_session', { ...sessionColumns }, (t) => [
  uniqueIndex('app_session_token_hash_idx').on(t.tokenHash),
  index('app_session_subject_idx').on(t.subject, t.app).where(sql`revoked_at IS NULL`),
]);
const sessions = createDrizzleSessions({ db, table: appSession, app: 'admin' });
await db.transaction((tx) => sessions.revokeAll(tx, { subject, by: staffId, reason: 'offboarded' }));
```

One conformance suite runs the in-memory, the SQL and the Drizzle store on an
embedded PGlite.

## `./impersonation` — support acting as a customer

Support needs to see what a customer sees. Most products do it by minting the
customer's ordinary session: nothing marks it, it does not end early, it can
write, and every action reads as the customer's own — a security hole and,
under the GDPR, a transparency problem. Here support acts under a **grant**:

```ts
import { createImpersonation, requireWrite } from '@octabits-io/server/impersonation';
import { createPostgresImpersonationGrantStore } from '@octabits-io/server/impersonation/postgres';

export const impersonation = createImpersonation({
  store: createPostgresImpersonationGrantStore({ db: pool }),
  signing,                       // @octabits-io/signing's scoped service, for the handoff
  sessions,                      // ./sessions' manager of the app the customer uses
  audit: auditLog,               // @octabits-io/audit's log — start and end are entries
  maxDurationMs: 30 * 60_000,    // default 30 minutes
});

// support starts a look, with the reason the customer and an audit will ask for
const granted = await withTransaction(pool, (tx) =>
  impersonation.grant({ staff, target: customer, mode: 'read', reason: 'Ticket 4711: the invoice is missing', scopeKey }, { tx }));

// the same host: the session under the grant, ending no later than it
const opened = await impersonation.openSession(granted.value, { ip, userAgent });
headers.append('set-cookie', cookie.serialize(opened.value.token, opened.value.session));

// another host (the product app on the same database): a handoff, redeemed once there
const handoff = await impersonation.issueHandoff(granted.value.id);          // { token, expiresAt }
const grant = await impersonation.redeemHandoff(token);                       // the grant, exactly once
const opened = await impersonation.openSession(grant.value, { ip, userAgent });

// over — by support, by the customer, by an admin — on the transaction of the audit entry
await withTransaction(pool, (tx) => impersonation.end(tx, { id, by: staff }));
```

**A grant is a row**: who, as whom, `read` or `write`, a required `reason`,
when it ends. A grant to act as oneself is refused, a duration above
`maxDurationMs` is refused, and there is **no extension** — a longer look is a
new grant with a new reason, so every stretch of acting as someone is
explained. `end` is final and runs on your transaction, so it commits with the
audit entry that says why; it revokes the session the grant opened on the same
transaction (hand a SQL store the pool to end one outside a transaction).

**The session carries it.** `openSession` opens a `./sessions` session for the
target with `onBehalfOf` the grant's id and an absolute end no later than the
grant's. `markerFor(session)` reads the grant on every request: `null` for a
session of one's own, `{ grantId, staff, mode, expiresAt }` while the grant is
open, and `unauthorized` once it is ended or past its end — the session dies
with the grant, on every replica, with no job. `./fetch`'s `withImpersonation`
is that call as a wrapper inside `withSession`.

**Read-only by default.** `requireWrite(ctx)` refuses a write under a `read`
grant with `impersonation_read_only` (403, by the `*_read_only` convention);
`requirePermission(ctx, key, { write: true })` and `withPermission({ …, write:
true })` do the same at the gate, after the permission check. A host that
wants to block side effects beyond its own writes — a model call, an outbound
fetch, a mail — reads `ctx.impersonation` and refuses in the same way.

**Across hosts by a handoff.** Support's admin app and the customer's product
app are two hosts on one database, with two session managers. `issueHandoff`
mints `<grantId>.<nonce>.<expiresAt>.<signature>` over a purpose key
(`impersonation-handoff`) of a signing seam `@octabits-io/signing`'s scoped
service satisfies by shape (`hmac` / `verifyHmac`); the store keeps the nonce's
SHA-256, never the token. `redeemHandoff` verifies the signature before any
read, refuses a token past its TTL (`handoffTtlMs`, default 60 s, never beyond
the grant's end), and spends the nonce with one conditional update, so the
second redemption — and a replay from another replica — finds it spent. A
second `issueHandoff` replaces the first token. A deployment that opens the
session on the same host needs neither `signing` nor the handoff.

**Recorded.** Start, the session and end go through an `audit` seam by shape
(`record(tx, entry)` — `@octabits-io/audit`'s log fits as is), as
`impersonation.start` (kind `read` or `write` by mode), `impersonation.session`
and `impersonation.end`, each with the grant's reason, the target as
`{ kind: 'subject', id }`, the grant as `onBehalfOf` and the scope. A refusal
from the audit log refuses the grant, and with the row on your transaction
nothing is left behind. An optional `activity` seam (`emit(params, tx)` —
`@octabits-io/activity`'s publisher fits) tells the affected scope that support
looked: `impersonation.started` and `impersonation.ended`, with the grant id,
the mode and the end, and never a name.

**What the customer sees.** Transparency is the point: the customer's own
feed shows that support looked (the activity entry), the audit log holds who,
why and how long, and anything support did under a `write` grant carries the
grant as `onBehalfOf` on its entries. Recommended banner semantics for the
support member's browser while a grant is open: fixed at the top of every page,
not dismissible, naming the customer it acts as, the mode, and the time it
ends (`ctx.impersonation.expiresAt`), with one control — end it now. The
customer-facing side never shows the staff member's name; ids only, as
everywhere here.

| Key | Status | When |
|---|---|---|
| `invalid_impersonation` | 400 | no reason, staff is the target, duration above the limit, `end` with no `by`, a session the manager could not open |
| `invalid_handoff` | 400 | a token that is malformed, forged, expired, already redeemed or replaced |
| `unauthorized` | 401 | `markerFor` / `withImpersonation`: the grant the session was opened under is ended, past its end or gone |
| `impersonation_read_only` | 403 | a write under a `read` grant |
| `impersonation_not_found` | 404 | no such grant |
| `already_ended` | 409 | a second `end`; a handoff or a session on a grant that is not open |
| `impersonation_audit_failed` | 500 | the audit seam refused the entry |
| `impersonation_signing_failed` | 500 | the signing seam could not sign or verify |

`./impersonation/postgres` is the store over the structural executor,
`impersonationGrantDdl({ table? })` the table (default `impersonation_grant`)
with a partial index on a staff member's and on a target's open grants, and
`createPostgresImpersonation` both in one call. `./impersonation/drizzle` is
`impersonationGrantColumns` and the Drizzle store:

```ts
import { impersonationGrantColumns, createDrizzleImpersonation } from '@octabits-io/server/impersonation/drizzle';

export const impersonationGrant = pgTable('impersonation_grant', { ...impersonationGrantColumns }, (t) => [
  index('impersonation_grant_staff_idx').on(t.staff).where(sql`ended_at IS NULL`),
  index('impersonation_grant_target_idx').on(t.target).where(sql`ended_at IS NULL`),
]);
const impersonation = createDrizzleImpersonation({ db, table: impersonationGrant, sessions, audit });
await db.transaction((tx) => impersonation.end(tx, { id, by: staff }));
```

Keep the grants and the sessions on one ORM: `end` hands its transaction to
the sessions' `revoke`. One conformance suite runs the in-memory, the SQL and
the Drizzle store on an embedded PGlite. The grant table is read before any
scope is known, so, like the sessions, it sits outside a scope's row-level
security.

## `./outbound` — fetching a URL somebody else chose

An app that fetches an address a user typed — an ICS feed, a CalDAV server, a
webhook target, a file to import by link, an avatar URL — fetches whatever the
user points it at. Pointed at `http://169.254.169.254/` or `http://10.0.0.5/`,
the server requests its own network on their behalf (SSRF). A plain `fetch`
cannot be made safe by checking the URL first: the host name is resolved again
when the connection opens, and a name that answers with a public address for
the check and a private one for the connection (DNS rebinding) walks through.

`./outbound/node` is a `fetch` that checks the address where it cannot be
raced — in the connection's own DNS lookup, so the address checked is the
address dialled — and re-checks every redirect hop:

```ts
import { createOutboundFetch } from '@octabits-io/server/outbound/node'; // npm i undici

const development = process.env.NODE_ENV !== 'production';
export const outbound = createOutboundFetch({
  allowHttp: development,                                  // https only in production
  allowAddress: development ? () => true : undefined,      // default: isPublicAddress
  timeoutMs: 15_000, maxBytes: 10 * 1024 * 1024, maxRedirects: 5, // the defaults
  onResponse: bookResponse({ hosts, metering }),           // optional: every response, by its final URL — @octabits-io/metering books paid hosts here
});

// Errors as values:
const result = await outbound.request(feedUrl, { headers: { accept: 'text/calendar' } });
if (!result.ok) return result; // { key: 'outbound_address_refused', message } — never the URL
const text = await result.value.text(); // already read, within maxBytes

// Or fetch's own shape, for a library that takes one (rejects with OutboundFetchError):
const client = createSdkClient({ baseUrl: userSuppliedUrl, fetch: outbound.fetch });
```

- **Public addresses only**, by default (`isPublicAddress`): no loopback, RFC
  1918, carrier-grade NAT, link-local (cloud metadata), documentation,
  benchmarking, multicast or reserved IPv4; IPv6 only inside 2000::/3, so no
  `::1`, unique-local `fc00::/7` or link-local `fe80::/10`. An IPv6 address that
  embeds an IPv4 one — `::ffff:7f00:1`, NAT64 `64:ff9b::/96`, 6to4 `2002::/16`
  — is judged by the IPv4 inside. A host name is refused when *any* of its
  addresses is. `allowAddress` replaces the policy: `() => true` for a local
  server in development, or an allowlist for one internal host.
- **https only**, unless `allowHttp`; no credentials in the URL; an IP literal
  is checked before the request starts (a literal never reaches a lookup), in
  every spelling the URL parser normalises (`http://2130706433/`, `0x7f.1`).
- **Redirects** are followed by hand, each hop's URL and resolved address
  checked like the first; 303, and 301/302 after a POST, become a GET;
  `authorization`, `proxy-authorization` and `cookie` are dropped on a hop to
  another origin. `redirect: 'manual'` hands the 3xx back unread with its
  `location`, for a caller that follows redirects itself (tsdav's `.well-known`
  discovery) — its next request comes through the guard again.
  `redirect: 'error'` refuses one.
- **Bounded**: one deadline for the whole exchange, every hop and the body
  included (`timeoutMs`); the body is read up to `maxBytes` and refused past it,
  a larger `content-length` before reading. `overflow: 'truncate'` keeps the
  first `maxBytes` instead, for a reader of long documents that would rather
  have the beginning than nothing; such a response carries
  `x-outbound-truncated: 1` and `onResponse` reports `truncated: true`. A non-2xx status is a response, not
  an error: the status is the caller's to judge.
- **The errors name no address.** `invalid_outbound_url`,
  `outbound_address_refused`, `outbound_unreachable`, `outbound_timeout`,
  `outbound_too_large`, `outbound_too_many_redirects`,
  `outbound_redirect_refused`, `outbound_aborted` — fixed messages, because a
  feed link with a token in it is a secret. Keep the URL out of your logs too.

`./outbound` is the portable half — `isPublicAddress` and `checkOutboundUrl`
(a `Result<URL, OutboundError>`) on web-platform globals, for validating a URL
when it is saved. It does not make a request safe on its own: a host name
passes it, and only the connection's lookup can judge where that name goes.
`./outbound/node` imports `node:dns` and `undici` (an optional peer, `^7 || ^8`)
and sits outside the portability gate, as `./node` does.

**When not to use it**: for a URL your own configuration names (your relay,
your identity provider, your model endpoint) — those are trusted and may well be
internal. On a runtime without Node's sockets (a Worker, the edge) the guard
cannot be built; fetch a user's URL from a Node process (a job) instead. And it
is no egress firewall: a network policy that keeps the app's pods off the
metadata service and the internal ranges is still worth having.

### `./outbound/browser` — the page that only renders with JavaScript

Some pages are empty until their scripts have run. Rendering one needs a
browser, and a browser is a far larger outbound surface than one `fetch`: the
document, every script, image, font and XHR it issues, every redirect of each,
a WebSocket, a `preconnect` — and a service worker that outlives the page.
Launching Chromium against a user's URL with none of that guarded reopens
everything `./outbound/node` closes.

```ts
import { createOutboundBrowser } from '@octabits-io/server/outbound/browser'; // npm i playwright-core undici

export const renderer = createOutboundBrowser({
  executablePath: process.env.PW_CHROMIUM ?? '/usr/bin/chromium', // the host's binary, never downloaded
  timeoutMs: 20_000, maxTextBytes: 200_000, maxConcurrent: 2,       // the defaults
  textRoots: ['main'],                       // `text` from the first selector that matches, else the body
  settleMs: 5_000,                           // wait for the page's requests to settle, then read it anyway
  allowHttp: development, allowAddress: development ? () => true : undefined,
});

const result = await renderer.render(pageUrl);
if (!result.ok) return result; // the same OutboundError keys as the fetch — never the URL
const { url, html, text, title, blocked, refused, escaped } = result.value;
```

- **The browser never opens a socket of its own.** Every request the page
  makes is intercepted and answered by `./outbound/node`'s guarded fetch: the
  URL checked, every resolved address checked inside the connection's own
  lookup, the body bounded (`maxResponseBytes`). A refused request is aborted
  and counted in `refused`; a refused document refuses the render with the
  fetch's own error. A redirect of the document is followed by this module,
  one checked navigation per hop (`maxRedirects`, then
  `outbound_too_many_redirects`); a sub-resource's redirect is followed by the
  fetch, one checked hop at a time, and delivered under the URL that was asked
  for.
- **The browser's own network is dead by configuration.** Chromium is launched
  with a proxy that is a loopback socket of this process which answers every
  request and tunnel with a refusal, loopback included — so anything
  interception does not cover (a prefetch, a `preconnect`, a redirect the
  engine follows on its own, the browser's own background traffic) reaches
  nothing, and is counted in `escaped`. A WebSocket is routed and closed
  without a server; WebRTC is held to proxied routes, of which there are none.
  The test suite proves it by disabling interception and watching the request
  die at the sink.
- **Isolation by default.** One browser process per render, launched for the
  call and closed in `finally`; a fresh incognito context with service
  workers blocked, no storage, downloads refused, dialogs dismissed, `bypassCSP`
  off; `javaScriptEnabled` on — that is the point.
- **Bounds.** One deadline for the whole render — the wait for a slot, the
  launch, the navigation (`domcontentloaded`, then a bounded wait for the
  network to settle) and the extraction — then `outbound_timeout`. `text` (the
  inner text of `<main>`, else `<body>`) and `html` are cut at `maxTextBytes`
  and `maxHtmlBytes` of UTF-8. `image`, `media` and `font` requests are never
  issued (`blockResourceTypes`; counted in `blocked`). `maxConcurrent` renders
  at once, because each is a process.
- **Not a crawler.** One page per call; no clicks, no form fills, no sign-in,
  nothing kept between calls. Whether a site may be rendered this way is a
  question of that site's terms, and the host's to answer — the module makes
  the request safe for the host's network, not permitted by the site.
- **The binary is the host's.** `executablePath` names a Chromium on the
  machine — Debian's `chromium`, `google-chrome`, `$PW_CHROMIUM` — and the
  package downloads nothing. In a container that runs the process as root or
  without user namespaces Chromium's own sandbox cannot start; `--no-sandbox`
  through `args` is the operator's call, and a non-root user with the sandbox
  on is the better one. A render without a binary is `outbound_unreachable`;
  a host that treats the browser as a last resort after the plain fetch
  checks for the binary once and skips the step when there is none.

`./outbound/browser` imports `playwright-core` (an optional peer, `^1.50`),
`./outbound/node` (so `undici` too) and `node:http` for the sink, and sits
outside the portability gate with them.

## `./csp` — the policy for the pages

`buildSecurityHeaders` ships a Content Security Policy for a JSON API —
`default-src 'none'` — and that is the right one for `/api`. The pages of a
Nuxt, Next or Vite app need a different policy, and it is the one header that
decides what an injected tag can do: without it, one unescaped string in one
template is script execution in every visitor's session. Most apps either ship
no policy for their pages or ship one with `'unsafe-inline'` in `script-src`,
which is no policy. `./csp` builds the strict one, and is where the traps are
written down once instead of rediscovered per app.

```ts
import { buildCsp, createNonce } from '@octabits-io/server/csp'

// Per request, in the proxy or middleware that sees every page response:
const nonce = createNonce()
const csp = buildCsp({
  nonce,                                   // → script-src 'self' 'nonce-…' 'strict-dynamic'; style-src 'self' 'nonce-…'
  img: ['data:', 'blob:'],                 // every list is 'self' plus what you name
  connect: [identityProvider],             // the token endpoint the browser calls
  allowStyleAttributes: true,              // style="…" — see the traps
  unsafeEval: process.env.NODE_ENV === 'development',
})
response.headers.set('Content-Security-Policy', csp)
```

**Two modes.** A server-rendered app mints a **nonce** per response, puts it in
the header and on every `<script>` and `<style>` it writes; Next.js and Nuxt do
the latter themselves once the request carries the header. With a nonce the
policy adds `'strict-dynamic'`: a script the page trusted may load further
scripts, and host allow-lists are ignored by browsers that understand it, which
is what you want — the allow-list is the part that rots. A static SPA has no
request to mint a nonce on, so it names the **hashes** of its inline scripts:

```ts
import { readFileSync } from 'node:fs'
import { buildCsp, inlineScriptHashes } from '@octabits-io/server/csp'

const html = readFileSync('dist/index.html', 'utf8')
const csp = buildCsp({ scriptHashes: inlineScriptHashes(html), connect: [apiOrigin] })
// serve index.html with it; the hashes change with every build, so compute them from the build you serve
```

**The defaults** are the strict ones and every list is `'self'` plus what the
options add: `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'none'`,
`frame-src 'none'`, `form-action 'self'`; `media-src` and `worker-src` only
when named. `directives` takes anything else (`manifest-src`, …) and refuses a
directive the builder already owns. A value that is not a source expression
throws: a stray `;` would end the directive and start another, which is the
injection the header exists to prevent. `buildSecurityHeaders({ csp: false })`
leaves the policy out of the static headers for a host that sets it per
response — two `Content-Security-Policy` headers are both enforced, and the
intersection breaks the page.

**The traps**, each one a day somebody lost:

- **A nonce needs every page rendered per request.** A page prerendered at
  build time has no nonce in it, so its inline scripts are blocked and
  hydration fails silently. In Next.js that is the default 404 page and any
  page nothing made dynamic: `await connection()` or `export const dynamic =
  'force-dynamic'` on it, and check the build's route listing says ƒ for every page.
- **Style attributes.** `style-src` without `'unsafe-inline'` also blocks
  `style="…"` attributes in the markup. Positioning libraries (Radix, Reka,
  Floating UI) and `next/image` write them, so a server-rendered one is a
  violation — client-side writes through `element.style` are not. `allowStyleAttributes`
  emits `style-src-attr 'unsafe-inline'`, which allows exactly that: CSS in an
  attribute, nothing that runs. `<style>` elements still need the nonce.
- **Read the nonce once from the DOM.** A scroll-lock or a portal library
  injects a `<style>` and needs the nonce to do it. A server action can render
  with a fresh nonce while the browser still enforces the page's original one, so
  a nonce read from the latest render is wrong; read it once from an existing
  nonced element (the `get-nonce` pattern) and keep it.
- **Zod 4 probes `new Function`** in the browser, once, and the probe is an
  `eval` violation on every page that parses a schema — Zod catches it and
  works on, so nothing breaks and the report never stops. It arrives through
  any package with Zod in it (this platform's among them), not only your own
  imports. Set `z.config({ jitless: true })` before the app's code runs: in
  Next.js that is `instrumentation-client.ts`.
- **The framework's own fallback pages.** Next's built-in 404 page writes its
  styles into a `<style>` element with no nonce, so the first address that is
  not there is a violation; the default error page does the same. Ship your own
  `not-found.tsx` (and `global-error.tsx`) on the app's stylesheet.
- **`'unsafe-eval'` in development only.** Fast refresh evaluates code; `unsafeEval`
  takes the `NODE_ENV` check, never `true`. The dev server's websocket wants
  `connect: ['ws:', 'wss:']` under the same condition.
- **Report before you enforce**, on an app that already runs: the same value
  under `Content-Security-Policy-Report-Only` with `reportUri` blocks nothing
  and tells you what would have been.

**Prove it in a browser.** A blocked script is a page that half works, which a
test on its behaviour may not notice. `./csp/testing` listens for the browser's
`securitypolicyviolation` events and fails the test on the first one:

```ts
import { watchCspViolations } from '@octabits-io/server/csp/testing'

const csp = await watchCspViolations(page)   // before the first navigation
await page.goto('/')
// … the test's own steps
csp.assertNone()                              // or expect(csp.violations).toEqual([])
```

The page is structural — `exposeFunction` and `addInitScript`, the two
Playwright methods — so the subpath imports no Playwright and runs under any
runner with the same two; `cspViolationListenerScript` is the listener as a
script for one without. `ignore` leaves out a violation that is not the app's
and cannot be fixed in it — Next's development tools write their styles into
`<style>` elements with no nonce under `next dev`, on every page — and keeps it
in `ignored`; match it as narrowly as the report allows, and pass none in a
suite that builds the app. `apps/e2e` runs the approval demo's whole story
with it on, and the starter template under `next dev` with that one filter, so
each policy is a tested claim.

## Wire format

The one body every API on the platform sends when something goes wrong, on
Hono, on fetch and on Node, is published as JSON Schema (draft 2020-12):

| Document | What |
|---|---|
| `schema/error-response.schema.json` | `ErrorResponse` — `{ key, message }` for every keyed error (the status follows the key conventions), `fields` added on a validation failure; nothing else is ever serialised from an error |
| `schema/health.schema.json` | what `/health/live` and `/health/ready` answer |

Generated from `src/responses.ts`, checked in `pnpm lint`, in the package
(`@octabits-io/server/schema/error-response.schema.json`) and at its `$id`.
A client in another language can type the failure branch from the document.

## Stability

Pre-1.0: patches never break, minors say so in the changelog. The wire shapes
— the error bodies, the response schemas, the header names — are part of the
surface; the wrapper option names too.