http
19 modules
http/auth_shape.ts
Canonical auth shape for action specs and route specs.
Replaces the discriminated
'public' | 'authenticated' | 'keeper' | {role}literal that conflated authentication, account resolution, actor resolution, and authorization into a single value. The flat record names each axis the dispatcher actually walks:account— does the dispatcher require / load / skip the account?actor— does the dispatcher require / load / skip the acting actor?roles— disjunction of permitted roles (any-of); absent = no role check.credential_types— restricts the credential channel (e.g. daemon_token); absent = any authenticated credential.required_scope— the capability an api token's scope must admit to reach this route (rpc:<method>orsurface:<name>); route specs only, absent = no scope gate.
The same shape governs both
ActionSpec.auth(in actions/action_spec.ts) andRouteSpec.auth(in http/route_spec.ts). The canonical schema lives here inhttp/because that preserves the existingactions → httpdependency direction (and http/error_schemas.ts / http/surface.ts consume the type).Registry-time invariants 1, 3, and 4 live on the schema's
.superRefineso any spec that fails them throws at the Zod parse boundary. Invariant 2 (the `actor !== 'none' ⟺ input or query declares acting?: ActingActor` biconditional) needs introspection of the spec's input/query schemas, so it is enforced at registration time inside the dispatcher loops (apply_route_specs, create_rpc_endpoint, register_action_ws) via the assert_route_auth_acting_biconditional helper exported below.http/body_limit.ts
Request body size limit — rejects an oversized body with
413, and aContent-Lengthit cannot read with400, before auth and validation.Wraps Hono's
bodyLimitwith a header-first check:- A
Content-Lengththat is not a plain run of ASCII digits (abc,-1,+5,1e9,0x1F4, empty, or a combined5, 500) is refused with400{error: 'invalid_content_length'}. A cap cannot be applied to a length that does not parse, andbodyLimitpasses such a request on uncounted. The runtimes' HTTP parsers answer their own bodiless400to most of these before the application sees them, so this branch is defense in depth where they do and the refusal where they do not: Node's refuses them all; Deno and Bun hand over a repeated equal header as one combined value (50, 50) and refuse the rest (observed on Node 24.14.1, Deno 2.8.3, Bun 1.4.3). The Rust spine's parser (hyper) refuses a malformed value and differing repeats, and accepts an equal repeat as the one value. - A request whose
Content-Lengthexceeds the cap is refused with413{error: 'payload_too_large'}, whatever its method. A digit run of any length compares as a number, so one too long to represent exactly is over the cap, never unreadable. Over a socket that branch is reached on Node and Deno, whose parsers hand over any length below 2^64 and refuse a longer one with their own400.Bun.servehands none over: with its default options it answers a length past 128 MiB with its own413, and one of 2^63 - 1 or more with its own400(observed on Node 24.14.1, Deno 2.8.3, Bun 1.4.3). - Both refusals answer on the header alone — without touching
c.req.raw.body— and carryConnection: close. - A request with a well-formed
Content-LengthbesideTransfer-Encoding, or with noContent-Length, falls through tobodyLimit, which measures the bytes that arrive: withTransfer-Encodingthe declared length does not frame the body.bodyLimitreads the body into memory, up to the cap, before the next middleware runs, so that413— sent once more than the cap has been read, with noConnection: close— also comes ahead of the origin check, auth and the handler. The Rust spine counts such a body as its handler reads it instead, so there a refusal made on the headers answers first; which status an over-cap body with no declared length gets when another refusal also applies is implementation-defined across the two (docs/security.md§"Body Size Limiting"). The Node, Deno and Bun parsers refuse a request carrying both headers with their own400; hyper accepts one, drops itsContent-Length, frames the body as chunked, and closes the connection after the response. - A well-formed
Content-Lengthwithin the cap is trusted to frame the body, as the runtime's HTTP parser enforces.
What the connection does after the header
413is the runtime's (docs/security.md§"Body Size Limiting"):- Node (
@hono/node-server) closes at once, the body unread. TheConnection: closeheader is what does it: without one the adapter holds the connection for a drain window and then closes or, the body untouched and arriving within the window, reads it off and serves the next request. - Deno closes once the whole declared body has arrived and holds the connection open while a client withholds it; a request pipelined behind the body is not served. Neither the header nor what the handler does with the body changes that (observed on Deno 2.8.3).
- Bun closes at once, the body unread, honouring the header. Bun 1.4.3
is the tested floor: an older Bun kept the connection after the
413and served the next request (correctly framed), so run Bun at the floor or later.
- A
http/client_ip.ts
Read the resolved client IP off the Hono context.
Split from
proxy.ts(which pullshono/utils/ipaddrfor trusted-proxy CIDR matching) so dispatch + route modules that only need to *read* the IP stay free of that value import — and the cross-process test surface that imports them stays free of the optionalhonopeer. The IP itself is set on the context by the trusted-proxy middleware inproxy.ts.http/common_routes.ts
Common route spec factories for fuz_app consumers.
Generic HTTP route factories with no auth-domain dependencies. Auth-aware route factories (account status) live in auth/account_routes.ts.
http/db_routes.ts
API route specs for database administration.
Allowlist-gated PostgreSQL table browser using
information_schema— consumers declarebrowsable_tablesand the credential floor (NON_BROWSABLE_TABLES) is always subtracted. Provides: list tables, view columns/rows (paginated, bytea values placeholdered), delete rows by PK (audited asdb_admin_row_delete), health check.http/error_schemas.ts
Standard error response schemas and error code constants for fuz_app routes.
Defines
ERROR_*constants (single source of truth for machine-parseable error codes), Zod schemas for error response shapes, a type for error schema maps, and derive_error_schemas to auto-populate middleware-produced errors from a route's auth requirement and input schema.Used in
RouteSpec.errorsandMiddlewareSpec.errorsfor surface introspection and DEV-mode validation.http/ip_canonical.ts
IP address canonicalization — collapse equivalent string forms into a single key per RFC 5952 (IPv6) plus the dotted form for IPv4-mapped IPv6 addresses.
Why this exists. Without canonicalization, the four representations
::1,::01,::0001, and0:0:0:0:0:0:0:1are the same IPv6 address but produce four distinct strings — so an attacker rotating equivalent forms behind a trusted-passthrough proxy could defeat per-IP rate limiting (each form gets a fresh bucket) and polluteaudit_log.ipforensics. The collision can extend to IPv4-mapped IPv6 forms (::ffff:127.0.0.1vs0:0:0:0:0:ffff:7f00:1vs the bare127.0.0.1) — three keys for one address.Canonicalization runs through canonicalize_ip which:
- Lowercases and char-set filters (IP_LITERAL_CHARS) — non-IP
strings (
'unknown','attacker:controlled','::1\n') pass through unchanged so downstream strict validators can still reject them. - Parses via Hono's
convertIPv*ToBinaryfamily. - Re-emits the canonical RFC 5952 string (lowercase hex, longest-zero-run compressed, IPv4-mapped emitted in the dotted form mandated by RFC 5952 §5).
- Strips the
::ffff:prefix from dotted IPv4-mapped forms so the bucket collapses to plain IPv4 — the strip moves AFTER canonicalization because the dotted form is the only form the strip can recognize symmetrically.
Mirrors
zzz_server::proxy::normalize_ip(landed 2026-05-16) which uses the same parse-then-canonicalize-then-strip ordering for the same rate-limit-key-poisoning surface.- Lowercases and char-set filters (IP_LITERAL_CHARS) — non-IP
strings (
http/jsonrpc_errors.ts
JSON-RPC error infrastructure for fuz_app routes.
Provides error types, named constructors, and HTTP status mapping for the throw/catch error pattern used by apply_route_specs. Core error codes (5 standard + 10 general application). Domain-specific codes stay in consumers — add by casting
as JsonrpcErrorCode.JsonrpcErrorCode and JsonrpcErrorObject types are Zod-inferred from http/jsonrpc.ts — this module re-uses those as the single source of truth.
Complementary to http/error_schemas.ts: that module is declarative (Zod schemas for surface introspection), this one is runtime (throw + catch + map).
http/jsonrpc_helpers.ts
JSON-RPC message builders, type guards, and converters.
Used by the SAES runtime (ActionEvent, ActionDispatcher, transports) and the RPC endpoint dispatcher. Complements http/jsonrpc.ts (schemas) and http/jsonrpc_errors.ts (error infrastructure).
http/jsonrpc.ts
JSON-RPC 2.0 envelope schemas for RPC dispatch and SAES transport.
Params are object-only following MCP (no positional arrays); per-action spec.input narrows further. Result is the full JSON-RPC valid value space per spec §5 ("determined by the method invoked"): object, array, string, number, boolean, or null — the per-action spec.output is the actual contract. The envelope-level loose-object-only constraint inherited from MCP would reject any action with
output: z.null()/z.string()/ etc. on the wire, so we relax it here while keeping MCP-superset shape on params + _meta.MCP
_metatypes (JsonrpcMcpMeta, JsonrpcRequestParamsMeta) are exported as building blocks for per-action schemas but are NOT validated at the envelope level —_metacontent validation belongs in per-action schemas where it produces the correct error code (invalid_params, notinvalid_request).@source https://github.com/modelcontextprotocol/typescript-sdk @see https://www.jsonrpc.org/specification
http/middleware_spec.ts
Middleware spec type — named middleware layer definition.
Separated from http/route_spec.ts so middleware modules can import this type without creating an upward dependency on routes.
http/origin.ts
Request source verification middleware for API protection.
Verifies requests carry an
Originthe server allows, or none. CSRF protection isSameSite: stricton session cookies (see auth/session_middleware.ts) plus the CORS simple-request refusal (http/simple_request.ts) —SameSitealone admits same-site pages, which include every other port on the same host. This module provides origin allowlisting for locally-running services — preventing untrusted websites from making requests as the user browses the web — and is the only gate on the WebSocket upgrade, which is never preflighted.The allowlist grammar is shared with the Rust spine entry for entry: parse_allowed_origins accepts and refuses the same entries, naming each refusal with the same AllowedOriginsErrorKind string, and an accepted list matches the same origins. The shared conformance table (testing/cross_backend/origin_conformance.json) pins both.
http/pending_effects.ts
Two-queue side-effect machinery for request handlers.
Handlers register fire-and-forget work in one of two queues, distinguished by their timing contract:
pending_effects: Array<Promise<void>>— eager. Producers push pool writes that are already in flight (audit emits, session-touch UPDATE, api-token usage tracking). The pool write is rollback-resilient by virtue of running outside the request transaction; pushing the in-flight handle lets test mode (await_pending_effects: true) await it.post_commit_effects: Array<() => void | Promise<void>>— deferred. Producers go throughemit_after_commit(ctx, fn); the flush middleware is the only site that ever invokes the thunk, and it does so after the request handler (and its wrappingdb.transaction) returns. Used for WS sends and any work that must observe a committed transaction.
Discard on rollback. A
post_commit_effectsthunk is discarded if the handler's transaction rolls back (a thrown handler) — it must not announce state that never committed. Both dispatch sites enforce this by wrapping their handler in dispatch_with_post_commit_rollback (see its TSDoc below for the full two-sided contract and the Rust-twin parity note). The eagerpending_effectsqueue is the deliberate opposite: its pool writes run outside the transaction and intentionally survive rollback (attempt audits).The split exists because the two shapes encode different contracts: eager pushers are saying "wait for this work that's already started"; thunk pushers are saying "run this after the handler returns." Burying both behind one
Array<PendingEffect>madec.var.pending_effects.push(x)ambiguous at the call site. With separate queues, the field name is the contract.Both RouteContext (HTTP routes) and ActionContext (RPC + WS actions) carry both queues by convention, so this module stays in
http/(every transport depends on it).http/proxy.ts
Trusted proxy configuration and middleware.
Resolves the client IP from
X-Forwarded-Foronly when the TCP connection originates from a configured trusted proxy. Without this middleware, get_client_ip returns'unknown'.http/route_spec.ts
Introspectable route spec system for Hono apps.
Routes are defined as data (method, path, auth, input/output schemas, handler), then applied to Hono. The attack surface is generated from the specs — always accurate, always complete.
Input/output schemas align with SAES ActionSpec conventions:
input: Zod schema for the request body (z.null()for no body)output: Zod schema for the success response bodyz.strictObject()for inputs (reject unknown keys)
http/schema_helpers.ts
Shared pure helpers for schema introspection and middleware matching.
Used by both http/route_spec.ts (input validation) and http/surface.ts (attack surface generation). Extracted to avoid circular dependencies between routes and middleware.
http/simple_request.ts
Refusing CORS simple requests — the cross-origin POSTs a browser sends without asking first.
A cross-origin
fetchor formPOSTskips the CORS preflight when itsContent-Typeis absent or one of the three CORS-safelisted types (text/plain,application/x-www-form-urlencoded,multipart/form-data). The browser sends such a request as-is, with whatever cookies apply — only the response is withheld from the page. Any other content type makes the browser send anOPTIONSpreflight first, and since fuz_app answers no preflight with CORS headers, the real request is never sent.The session cookie's
SameSite=Strictdoesn't close this on its own: same-site ignores the port, so a page onhttp://localhost:8000is same-site with a server onhttp://localhost:4460and its requests carry the cookie. The origin allowlist (http/origin.ts) refuses such a page unless a wildcard (http://localhost:*) admits it; this gate refuses it whatever the allowlist says.So every
POSTroute carries a content-type check, run first by apply_route_specs (is_post_content_type_refused, 415unsupported_content_type): a JSON route takes onlyapplication/json, bodyless or not, and araw_bodyroute takes any type that isn't safelisted. OnlyPOSTneeds it:PUT,PATCH, andDELETEare never simple methods, so the browser always preflights them.The check rests on the server answering no preflight. A consumer that adds CORS middleware granting cross-origin requests reopens what this closes.
Twin of the Rust spine's
fuz_http::simple_request: the same predicates, is_json_content_type checked in each spine JSON handler and is_cors_simple_content_type by thesimple_request_layerconsumers mount over routes with other bodies.http/surface_query.ts
Pure query functions over AppSurface data.
Usable in tests, the adversarial auth runner, and future surface explorer UI. Replaces duplicated inline
.filter()patterns.Categorical filters (filter_authenticated_routes, filter_role_routes, filter_keeper_routes) group the new flat-record RouteAuth shape into the legacy categorical buckets (
'authenticated','role','keeper') for adversarial test runners and the surface explorer. The buckets are derived views over the authority axes (account,actor,roles,credential_types) — see http/auth_shape.ts for the canonical shape.http/surface.ts
App surface generation — JSON-serializable attack surface from route and middleware specs.
Pure schema helpers (is_null_schema, schema_to_surface, middleware_applies, middleware_spec_applies, merge_error_schemas) live in http/schema_helpers.ts.