@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.
composewraps one handler; the host’s framework (Next, SvelteKit, TanStack Start, Hono) matches the route and hands the request over../fetch/octaflowdispatches six workflow routes because it has to, and says so. - Fastify. It does not speak
RequestandResponse, and itsreq/resare not Node’s either. The cores on the root are usable from it — they take plain values — but no binding ships. Express, Connect, NestJS andhttp.createServerare served: that adapter is./node. - The browser side of sign-in.
./authsays 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(./oidcor./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}, kindserver, parented on the incomingtraceparent) and the two semantic-convention metrics,http.server.request.durationandhttp.server.active_requests. The span rides on the request (spanOf) and onctx.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, soroutenames the pattern per handler; without it the span is the bare method. Outermost, ahead ofwithRequestScope, 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’sparamspromise is awaited), handed to the handler asctx.scope, disposed{ commit: true }when the handler returns (a returned keyed error is still a response) and{ commit: false }when it throws orguardrejects. Exactly one dispose per request. The scope is anything withdispose({ commit }).withBearerAuth({ authService, authorize?, statusOverrides?, onUnauthorized? })— the validated principal lands onctx.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, onctx.session; no open session is 401 (ornullwithrequired: 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 })— insidewithSession: the grant a session was opened under, onctx.impersonation(nullfor a session of one’s own); 401 once the grant is ended or past its end.withPermission’swrite: true, orrequireWrite(ctx)in the handler, refuses a write under a read-only grant as403 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 withRetry-After. The IP is resolved by a rightmost-untrusted walk ofX-Forwarded-ForovertrustedProxies; withoutgetDirectIpor a trusted proxy every request keys to one bucket, and the wrapper warns once. The counters live instore— 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 thrownApiError, a thrownResponse, a database connection error (503), anything else (500, message redacted in production, logged); a returnederr(error)is mapped through the key conventions —*_not_found→ 404,already_*→ 409,validation_*→ 400,forbidden→ 403 — plus your overrides.withSecurityHeaders(options?)—buildSecurityHeaderson every response, error bodies included.withJsonBody(schema)— the zod-parsed body onctx.body, or the standard{ key: 'validation_error', message, fields }400.createHealthHandler({ checkReady, logger? })— the health trio on one catch-all:…/readyrunscheckReady(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, andassertNotInProduction(name, value?), which fails startup when a dev-only escape hatch is set in production. - Errors —
ApiErrorand its subclasses,resolveErrorResponse, theErrorStatusOverridesmap, the api-error response types (ValidationApiError,NotFoundApiError, …) that every@octabits-ioserver emits and every client helper understands. These are wire vocabulary and change when the API surface does; onlyOctErroris 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/loggersatisfies it as-is; so does a five-line adapter over pino orconsole. Every wrapper that takes one takes it as an option and falls back toconsole.TracerandMeter— the subset of the OpenTelemetry API a server touches:startSpan/withSpanandcounter/upDownCounter/histogram.@octabits-io/telemetry’s implementations satisfy them; a host on the real SDK adapts in a dozen lines.noopTraceris 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;toRequestreadsreq.originalUrlwhen it is there, so a handler that dispatches on the pathname sees what the client asked for. Barehttphas nooriginalUrlandreq.urlis already full. - A body parser spends the stream. When
req.bodyis 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 carriesduplex: 'half', which Node’sRequestrequires. - Streaming and client disconnect.
sendResponseflushes the headers before piping, or a server-sent-events body that holds its first chunk back leaves the client waiting on headers; multipleSet-Cookies go throughgetSetCookie(), neverHeaders.forEach, which joins them.resclosing 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:
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.resolveScope({ scopeKey, url, request })— auth and scope, inline, before the SDK sees the request; an early{ response }(ajsonRpcError, say) is returned verbatim.- a fresh
McpServer, withregisterTools(server, getScope)binding the tools to this request’s scope — no module-level server, no context smuggling. - one JSON response per request, so it is fully materialised before the
handler resolves and disposing the scope in a
finallycannot 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 thejwtplugin’s keys with the endpoint as their audience —jwksUriisbetterAuthJwksUri(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 asaudience— and its scopes inscp;issuerUrlandjwksUriareentraIssuer(directoryId)andentraJwksUri(directoryId)from./auth/entra. Zitadel signs JWT access tokens when the client’s token type is set to JWT; itsaudcarries 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);
./sessions — the cookie that ends when you say so
./auth validates stateless bearer tokens, and a stateless token cannot be
listed, revoked, or ended when someone’s access is removed. A session here is
an opaque cookie backed by a row, so it can:
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-localfc00::/7or link-localfe80::/10. An IPv6 address that embeds an IPv4 one —::ffff:7f00:1, NAT6464:ff9b::/96, 6to42002::/16— is judged by the IPv4 inside. A host name is refused when any of its addresses is.allowAddressreplaces the policy:() => truefor 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-authorizationandcookieare dropped on a hop to another origin.redirect: 'manual'hands the 3xx back unread with itslocation, for a caller that follows redirects itself (tsdav’s.well-knowndiscovery) — 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 tomaxBytesand refused past it, a largercontent-lengthbefore reading.overflow: 'truncate'keeps the firstmaxBytesinstead, for a reader of long documents that would rather have the beginning than nothing; such a response carriesx-outbound-truncated: 1andonResponsereportstruncated: 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 inrefused; 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, thenoutbound_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 inescaped. 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,bypassCSPoff;javaScriptEnabledon — 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 — thenoutbound_timeout.text(the inner text of<main>, else<body>) andhtmlare cut atmaxTextBytesandmaxHtmlBytesof UTF-8.image,mediaandfontrequests are never issued (blockResourceTypes; counted inblocked).maxConcurrentrenders 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.
executablePathnames a Chromium on the machine — Debian’schromium,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-sandboxthroughargsis the operator’s call, and a non-root user with the sandbox on is the better one. A render without a binary isoutbound_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()orexport const dynamic = 'force-dynamic'on it, and check the build’s route listing says ƒ for every page. - Style attributes.
style-srcwithout'unsafe-inline'also blocksstyle="…"attributes in the markup. Positioning libraries (Radix, Reka, Floating UI) andnext/imagewrite them, so a server-rendered one is a violation — client-side writes throughelement.styleare not.allowStyleAttributesemitsstyle-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 (theget-noncepattern) and keep it. - Zod 4 probes
new Functionin the browser, once, and the probe is anevalviolation 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. Setz.config({ jitless: true })before the app’s code runs: in Next.js that isinstrumentation-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 ownnot-found.tsx(andglobal-error.tsx) on the app’s stylesheet. 'unsafe-eval'in development only. Fast refresh evaluates code;unsafeEvaltakes theNODE_ENVcheck, nevertrue. The dev server’s websocket wantsconnect: ['ws:', 'wss:']under the same condition.- Report before you enforce, on an app that already runs: the same value
under
Content-Security-Policy-Report-OnlywithreportUriblocks 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.