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

npm i @octabits-io/server zod
// 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 Results, 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.
  • 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.
  • 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

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:

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:

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.

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 operationIds; 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 Results, 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.

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

// 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 Requests — 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.

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:

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:

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.

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.

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

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.

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

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):

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

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.

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.

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

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:

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:

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);

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

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:

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:

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:

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:

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:

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.

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.

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:

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:

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.

From @octabits-io/server@0.23.0 in the platform repository.