auth
86 modules
auth/account_action_specs.ts
Account RPC action specs — declarative contract for self-service account operations. Import this module for the specs, Input/Output schemas, and the all_account_action_specs registry. Handlers live in auth/account_actions.ts so consumers doing typed-client codegen or surface reporting don't transitively drag in server-only query code.
auth/account_actions.ts
Account RPC action handlers — self-service operations for the authenticated account.
Seven
request_responseactions bound to handlers:- Session reads:
account_verify,account_session_list. - Session mutations:
account_session_revoke,account_session_revoke_all. - API token management:
account_token_create,account_token_list,account_token_revoke.
The action specs themselves live in auth/account_action_specs.ts. Every spec declares
auth: {account: 'required', actor: 'none'}so the dispatcher enforces account-grain auth before the handler runs. Revoke operations are account-scoped (via query_session_revoke_for_account / query_revoke_api_token_for_account) so passing another account's session or token id returnsrevoked: falserather than revealing whether the id exists.Every handler that ends a credential closes the connections it opened, after the handler's transaction commits (queue_connection_close on
deps.connection_closer): the two session revokes, the token revoke, andaccount_token_createfor the tokens its cap evicts.Counterpart to auth/account_routes.ts, which keeps the cookie-lifecycle flows (
login,logout,password,signup, bootstrap) on REST.- Session reads:
auth/account_queries.ts
Account and actor database queries.
Provides CRUD operations for the account and actor tables. For v1, every account has exactly one actor (1:1).
auth/account_route_schema.ts
Hono-free wire schemas + route shapes for the account REST routes.
Split from
account_routes.ts(whose handlers pullhono/cookieviasession_middleware) so cross-process test suites can build the account route shapes — and assert on thePOST /login/GET /api/account/statusresponse shapes — without dragging the in-process Hono session handler, and its optionalhonopeer, onto a backend-spawning consumer.account_routes.tsimports these back and attaches the live handlers; single source of truth for the wire shape.auth/account_routes.ts
Account route specs for cookie-based session management.
Returns
RouteSpec[]— caller applies them to Hono via apply_route_specs.Four REST flows remain here; each has a concrete reason to stay REST rather than moving to auth/account_actions.ts:
POST /login— issues a signedSet-Cookieand pre-handler rate-limits by IP + per-canonical-account before password hashing.POST /logout— clears the session cookie.POST /password— cookie clear + revoke-all cascade; rate-limit-shaped error envelope on 429.GET /verify— empty-body nginxauth_requestprobe. Programmatic callers should use theaccount_verifyRPC action for the typed payload.
Session listing/revocation and API token CRUD are on the RPC endpoint — see auth/account_actions.ts. Signup is in auth/signup_routes.ts. Defaults are closed/safe: accounts are created through bootstrap, admin action, or invite.
auth/account_schema.ts
Auth entity types and client-safe schemas.
Defines the runtime types for the fuz identity system: Account, Actor, RoleGrant, AuthSession, and ApiToken.
Identifier primitives (Username, UsernameProvided, Email) live in primitive_schemas.ts — they're general validator shapes that don't depend on the auth domain. The auth-shape request-contract primitive ActingActor lives in http/auth_shape.ts next to RouteAuth (the two pair:
auth.actor !== 'none'⟺ input declaresacting?: ActingActor).DDL lives in auth/auth_ddl.ts; role system in auth/role_schema.ts. See docs/identity.md for design rationale.
auth/actor_lookup_action_specs.ts
actor_lookupRPC spec — authenticated batched id → username/display_name resolver, keyed by actor id.Powers the labels arc for surfaces that stamp an actor id (bylines, owner columns, grantor labels, audit-log "by" cells). One round trip resolves an array of ids to display strings.
Auth + rate-limit posture
{account: 'required', actor: 'none'}+rate_limit: 'account'. Account-grain — only that the caller is signed in matters, not which actor is calling, so resolution skips the actor phase. The auth gate + per-account rate limit (default 1200/15min) + the per-call cap (ACTOR_LOOKUP_IDS_MAX) bound the batched username-enumeration surface that thecell_list↔actor_lookuppair would otherwise present.If a public-surface byline ever lands (e.g. an unauthenticated gallery), it should resolve via a separate public-safe mechanism (SSR-stamped labels or a per-cell embedded actor label), not by loosening this gate.
Wire shape — info-leak audit
Output:
{actors: [{id, username, display_name?}]}. Deliberately omitted:account_id— the actor↔account join is a control-plane detailemail, password/credential fields — never queriedcreated_at/updated_at— timing-oracle avoidance- role / role_grants / session state — separation of concern
display_nameis omitted (notnull) whenactor.nameis blank, so clients seeundefinedrather than a sentinel string. Unknown ids are silently absent from the response — by construction this is an existence-oracle (the caller can diff response ids against request ids), bounded by:- rate-limit (per-account, see above),
- ACTOR_LOOKUP_IDS_MAX cap per call,
- actor-uuid intractability (122-bit random),
- hard-deleted actors are indistinguishable from never-existed (no tombstone oracle — see auth/actor_lookup_queries.ts).
Response order is unspecified — callers index by
idwhen needed.auth/actor_lookup_actions.ts
actor_lookupRPC handler.Pure read — no audit, no side effects. Auth (
account: 'required') + rate-limit (account-grain) enforced at the spec layer; see auth/actor_lookup_action_specs.ts for the info-leak audit.display_nameis omitted (notnull) whenactor.nameis blank, matching the wire shapedisplay_name?so the typed client sees anundefinedrather than a sentinel string.auth/actor_lookup_queries.ts
Batched actor-by-id resolver.
Joins
actor⨝accountso callers see(username, display_name)for each actor row. The byline / owner-column / grantor surfaces stamp an actor id, so resolving "who is this actor?" lands the human label in one round trip.Accounts may host multiple actors (multi-actor shipped in v0.55.0). The inner join still resolves one row per actor —
actor.account_idisNOT NULLso every actor has exactly one account.Info-leak posture (see auth/actor_lookup_action_specs.ts §audit):
- Row shape omits
account_id— the join is control-plane, not wire-visible. - Hard-deleted actors (or account-cascade-orphaned rows) drop out silently — indistinguishable from never-existed (no tombstone oracle).
- No
created_at/updated_atprojected (timing-oracle avoidance). - Response order is unspecified —
WHERE id = ANY(...)returns index-scan order in practice but callers must not depend on it.
Caller is responsible for capping
ids.length— the SQL itself does not enforce a bound; the action-spec layer surfacesinvalid_paramsvia ACTOR_LOOKUP_IDS_MAX.- Row shape omits
auth/actor_search_action_specs.ts
actor_searchRPC spec — authenticated case-insensitive prefix search overactor.name, returning the same{id, username, display_name?}wire shape asactor_lookup.Powers person-target pickers — visiones'
CellGrantsEditor.svelteteacher-picks-student flow replaces the deferredactor_by_namearm ofcell_grant_createwith a debounced search against this method. Sibling toactor_lookup: that resolves a known batch of ids → labels; this resolves a partial name → candidate actors.Auth + rate-limit posture
{account: 'required', actor: 'none'}+rate_limit: 'account'. Same shape asactor_lookup: only that the caller is signed in matters, not which actor is calling. The auth gate, the per-account rate limit (default 1200/15min), and the ACTOR_SEARCH_LIMIT_MAX per-call cap bound the enumeration surface this method would otherwise present.The handler additionally requires the caller to be admin when
scope_idsis empty (the unbounded global-search arm). Non-admin callers must always pass at least one scope_id — the SQL filters actors to those holding a role_grant on one of the supplied scopes, so a non-admin caller is restricted to actors they share a scope with. The admin check is account-grain (any actor on the caller's account holds a globaladminrole_grant), matching theactor: 'none'posture.Caller-passes-scope_ids design
scope_idsis trusted as a filter, not as an authority claim — the SQL filters to actors with role_grants on those scopes regardless of whether the caller has authority over them. Consumers are responsible for pre-filteringscope_idsagainst their own authority before calling. Visiones passes the set of classrooms the teacher teaches, sourced client-side from the teacher's role_grant list; the teacher predicate stays in the visiones layer rather than baked into fuz_app.Crucially, this does not widen the scope-existence oracle: an attacker passing a random scope_id cannot learn "this scope has members matching X" because the join filters to actors holding a role_grant on the scope, and the SQL surfaces neither "did the scope exist" nor "did the scope have non-matching members" — only the matching subset is returned.
Wire shape — info-leak audit
Output
{actors: [{id, username, display_name?}]}is identical toactor_lookup's — see auth/actor_lookup_action_specs.ts for the full field-by-field audit. Same omissions (account_id, email, timestamps, role / role_grants / session state), samedisplay_nameomitted-not-null contract, same response-order-unspecified rule.Additional
actor_search-specific posture:- Prefix match (
LOWER(name) LIKE LOWER(query) || '%'), not full%query%. Full-LIKE would let a single call enumerate one alphabetical bucket spread across many starting letters, which defeats the per-call cap as an enumeration bound. - Hard-deleted actors silently drop (cascade through
actor.account_idFK) — no tombstone oracle, same posture asactor_lookup. - Empty result set on no-match — fail-soft like
cell_list. No "no actor matches" error message that would leak an existence boundary on the search-term axis.
Why not extend
actor_lookup?Splitting the methods keeps the wire contracts independent:
actor_lookup's input is{ids},actor_search's is{query}+ optional filters. Both surface the same ActorLookupEntryJson row shape (re-used here), so the labels arc on the consumer side stays uniform.- Prefix match (
auth/actor_search_actions.ts
actor_searchRPC handler.Pure read — no audit, no side effects. Auth (
account: 'required',actor: 'none') + rate-limit (account-grain) enforced at the spec layer; see auth/actor_search_action_specs.ts for the info-leak audit and threat model.The handler adds two checks the spec layer can't express:
- Admin gate on empty
scope_ids— unbounded global search is admin-only. Non-admin callers without ascope_idsfilter are rejected withinvalid_paramscarryingactor_search_scope_required. The admin check is account-grain (any active actor on the caller's account holds a globaladminrole_grant) since theactor: 'none'posture doesn't loadauth.role_grantsfor an in-memory check. - Limit clamp — input is bounded by ACTOR_SEARCH_LIMIT_MAX at the schema; the handler picks the default when omitted.
display_nameis omitted (notnull) whenactor.nameis blank, matching the wire shapeActorLookupEntryJson.display_name?— same convention as auth/actor_lookup_actions.ts.- Admin gate on empty
auth/actor_search_queries.ts
Prefix-based actor search.
Sibling to auth/actor_lookup_queries.ts — that resolves a batch of ids to labels; this resolves a partial name to candidate actors. Same row shape (ActorLookupRow) so the labels arc on the consumer side stays uniform.
Case-insensitive LIKE-prefix on
actor.namebacked by theidx_actor_name_lowerfunctional index. LIKE wildcards (%,_,\) in the query string are escaped at the JS layer so the prefix-only contract is enforceable — an unescaped%xyzwould widen the surface to full-LIKE and defeat the per-call cap as a binding bound.Auth filtering —
scope_idsWhen
scope_idsis non-empty, the result is filtered to actors holding an active role_grant on one of the supplied scopes. Active meansrevoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()). Stale (revoked / expired) role_grants do not confer membership for search-visibility purposes — otherwise a removed student would remain visible to teachers indefinitely.The
DISTINCTonactor.idcollapses the case where an actor holds multiple matching role_grants in the supplied scope set into one row.When
scope_idsis omitted (admin-only global path; the handler gates), no role_grant join — every actor with a matching prefix is returned.Info-leak posture (see auth/actor_search_action_specs.ts §audit)
- Row shape omits
account_id— the join is control-plane, not wire-visible. Identical to auth/actor_lookup_queries.ts. - Hard-deleted actors (cascade-orphaned via
actor.account_idFK) drop out silently. - No
created_at/updated_atprojected (timing-oracle avoidance). - Scope-membership uses ANY on the supplied
scope_idsarray but never surfaces "which scope matched" — the result row carries only the actor's wire shape. An attacker passing a random scope_id learns at most "this scope has at least one member matching X" if a match exists, indistinguishable from a no-match search; the caller-passes-scope_ids design (handler trusts the array as a filter, not as authority) means the attacker had to obtain the scope_id from somewhere else first.
Caller bounds
limit(the action-spec layer enforces ACTOR_SEARCH_LIMIT_MAX); SQL clamps to that cap on the call site before reaching this query.- Row shape omits
auth/admin_action_specs.ts
Admin RPC action specs — declarative contract for admin-only operations.
Import this module for the specs, Input/Output schemas, and the all_admin_action_specs registry. Handlers live in auth/admin_actions.ts.
Authorization is declared at the spec level (
auth: {role: ROLE_ADMIN}) so the RPC dispatcher enforces admin before the handler runs and the generated surface accurately reports the requirement.The registry always includes
app_settings_get/app_settings_update— the runtime factory only wires their handlers whenAdminActionOptions.app_settingsis provided; dispatch falls back tomethod_not_foundwhen absent.auth/admin_actions.ts
Admin RPC action handlers — admin-only operations exposed on the JSON-RPC surface.
Four action categories:
- Account management:
admin_account_list,admin_session_list,admin_session_revoke_all,admin_token_revoke_all. - Audit log reads:
audit_log_list,audit_log_role_grant_history. - Invite CRUD:
invite_create,invite_list,invite_delete. - App settings:
app_settings_get,app_settings_update. The update handler writes theapp_settingsrow in the database; signup reads theopen_signuptoggle fresh from that row on every request, so no in-memory state is shared between this surface and signup.
The action specs themselves live in auth/admin_action_specs.ts. Mutations emit matching audit events via
deps.audit.emit.Authorization is declared at the spec level (
auth: {role: 'admin'}) so the RPC dispatcher enforces it before the handler runs and the generated surface accurately reports the requirement.role_grant_revokein auth/role_grant_offer_actions.ts uses the same spec-level pattern even though its sibling methods are authenticated-but-not-admin — the dispatcher checks auth per-spec, so mixed-auth endpoints compose cleanly. Handler-level gates are reserved for input-dependent elevation (e.g.role_grant_offer_list/_historyelevate to admin only when the caller passes anaccount_idother than their own — an input-dependent check the spec can't express).- Account management:
auth/all_action_spec_registries.ts
Canonical list of every fuz_auth action-spec registry — for cross-cutting walkers and codegen only. Not a mounting surface; consumers continue to import individual
all_*_action_specsbundles (andcreate_*_actionsfactories) per registration site.The "one main bundle" alternative is an antipattern for mounting: create_standard_rpc_actions (admin + role_grant_offer + account) is the canonical surface, and opt-in registries (
self_service_role,actor_lookup) are deliberately opt-in because their eligibility (eligible_roles) or coverage (byline labels) is app-specific. Spreading everything into a single mount would silently widen the dispatch surface the moment a new opt-in landed — the exact failure mode this module is built to detect, not propagate. See./CLAUDE.md§RPC actions (auth/standard_rpc_actions.ts).Use cases for this registry:
- Cross-registry walker tests (input-invariants, auth-shape biconditional) — iterate the spec arrays once, fail when a new registry slips by without an entry here.
- Codegen that needs to see every fuz_auth surface at once (typed-client filters, attack-surface reports). For typed-client wiring of the standard surface, prefer all_standard_action_specs in auth/standard_action_specs.ts — it mirrors the create_standard_rpc_actions mount and stays narrower than this registry-of-registries (no opt-in bundles).
protocol_action_specs (heartbeat / cancel) is not included — those are transport-level wire-protocol concerns shipped by fuz_app and spread by every consumer at registration via protocol_actions from actions/protocol.ts. Walker tests that need protocol coverage spread protocol_action_specs separately.
auth/all_cell_actions.ts
auth/api_token_queries.ts
API token query functions for token CRUD and validation.
auth/api_token.ts
API token generation and hashing utilities.
Tokens use the format
secret_fuz_token_<base64url>and are stored as blake3 hashes. These are pure cryptographic operations with no framework dependency — the bearer auth middleware that validates tokens lives in auth/bearer_auth.ts.auth/app_settings_queries.ts
App settings database queries.
Single-row table queries for global app configuration.
auth/app_settings_schema.ts
App settings types and client-safe schemas.
Single-row table for global app configuration (e.g. open signup toggle).
auth/audit_emitter.ts
Bound audit-emit capability.
AuditEmitter closes over the pool-level Db, its registered listeners, and the optional AuditLogConfig. Built by the consumer's
audit_factorycallback on CreateAppBackendOptions — create_app_backend invokes the factory once with its constructed{db, log}and lands the result onAppDeps.audit. Consumers reach fordeps.audit.emit(ctx, input)and never see the pool — handlers cannot accidentally emit an audit event against the request's transactionaldb(which would be rolled back with the parent on a handler throw).These methods cover every fan-out shape the auth domain needs (
add_listenerandlistener_countmanage the listener chain):emit(ctx, input)— fire-and-forget pool write. Pushes the in-flight promise ontoctx.pending_effectsfor post-response flushing. Errors are logged, never thrown. Returnsvoidso callers don't pile upvoidkeywords or accidentallyawaitsomething whose handle is already inpending_effects.emit_role_grant_target(ctx, auth, input)— wrapper that lifts theactor_id/account_id/ipboilerplate every role-grant-shape audit site repeated. Delegates toemit.emit_pool(input)— awaitable pool write for code paths without a request context (ad-hoc maintenance scripts). Writes, then notifies.notify(event)— fan out an already-written audit row (e.g. rows returned by query_accept_offer that were inserted in-transaction by the query layer, or the offer-expiry rows auth/cleanup.ts writes in its sweep transaction). Runs every registered listener; per-listener throws are isolated.drain_inflight()— test-binary barrier: await every in-flightemitwrite, on an emitter built withtrack_inflight: true.
When listeners hear about a row
emitwrites the row at once, on the pool, whatever its outcome — the write is outside the request's transaction, so it survives a rollback. Listener fan-out is a separate step, and it depends on the outcome:- A failure row is announced as soon as it is written. It records an attempt, which happened whether or not the request's transaction commits.
- A success row is announced only after the request's transaction
commits, and never when the handler throws: the fan-out is queued on
ctx.post_commit_effectsand awaits the write. A success row reports a state change, and the listeners act on it — the revocation listeners close connections, the audit stream broadcasts it. Announced before the commit, a close could run while the revoked credential still reads as valid, and a rolled-back revocation would close connections anyway.
A success row emitted by a handler that later throws is still in the table — the write is outside the transaction — but is never announced.
The Rust spine reaches the same order by writing a success row inside the transaction and queueing its fan-out behind the commit.
Listeners are a documented registration seam — create_app_server registers additional listeners via
add_listenerafter the backend is built (the factory-managed audit-log SSE, per-endpoint WS auth guards, anyextra_audit_handlerson a WsEndpointSpec) before the first request runs, and removes each through the removeradd_listenerreturns when its assembly fails or the server closes. Consumers can also register listeners directly on the emitter they return fromaudit_factoryfor setups that don't pass through create_app_server.auth/audit_log_ddl.ts
Audit log DDL —
CREATE TABLE+ index statements for theaudit_logtable.Consumed by auth/migrations.ts. Separated from auth/audit_log_schema.ts so the schema module stays Zod-only (paired with auth/auth_ddl.ts and auth/role_grant_offer_ddl.ts).
Multi-actor invariants the envelope columns assume:
actor_id+account_id, when both populated, refer to the same account (derivable viaactor.account_id). Denormalized for indexed audit queries; do not let them disagree.target_actor_id+target_account_id, same rule when both populated.target_account_idis the SSE/WS socket-close key — sessions stay account-grain after multi-actor lands, so this column carries the routing identity even on actor-bound events.target_actor_idis populated iff the event subject is actor-bound (seeAuditLogEvent.target_actor_iddoc-comment for the rule).
No FK on the four identity columns. They are plain
UUID, notREFERENCES … ON DELETE SET NULL. An audit log is an append-only historical record, not a live relational entity;SET NULLerased the very attribution the log exists to preserve. With soft-delete as the default (account.deleted_at) the rows stay and the ids JOIN-resolve; on a hard purge the raw id survives instead of nulling, and the purge audit event snapshots the identity into metadata (delete = soft, purge = hard).auth/audit_log_queries.ts
Audit log database queries.
Records and retrieves auth mutation events for security monitoring. The canonical fire-and-forget entry point is
AppDeps.audit.emit(ctx, input)(see auth/audit_emitter.ts) — it closes over the pool so audit rows persist even when the request transaction rolls back. This module only exposes the in-transactionquery_*primitives and the drift counters; the bound emitter writes through query_audit_log against its captured pool.auth/audit_log_route_schema.ts
Hono-free wire schemas + route shape for the audit-log SSE stream.
Split from
audit_log_routes.ts(whose handler pullshono/streamingviarealtime/sse) so cross-process test suites can build the audit-stream route shape without dragging the in-process SSE handler, and its optionalhonopeer, onto a backend-spawning consumer.audit_log_routes.tsimports these back and attaches the live SSE handler; single source of truth for the wire shape.auth/audit_log_routes.ts
Audit log SSE stream route.
The two list-reads (
audit_log_list,audit_log_role_grant_history) moved to RPC in auth/admin_actions.ts, and the admin session listing moved toadmin_session_liston the same file. What remains here is the optionalGET /audit/streamSSE route — streams aren't an action-kind, so they stay on REST. The event payload broadcast on the stream surfaces via audit_log_event_specs (one EventSpec per audit event type) declared alongside the broadcaster in realtime/sse_auth_guard.ts.auth/audit_log_schema.ts
Audit log types and client-safe Zod schemas.
Records auth mutations (login, logout, grant, revoke, etc.) for security monitoring and operational visibility.
Table DDL and indexes live in auth/audit_log_ddl.ts.
auth/auth_ddl.ts
Identity-table DDL —
CREATE TABLE, index, and seed statements for the core auth tables (account, actor, role_grant, auth_session, api_token, bootstrap_lock, invite, app_settings).Consumed by auth/migrations.ts. Paired with auth/audit_log_ddl.ts (audit table) and auth/role_grant_offer_ddl.ts (offer table) — DDL lives in
*_ddl.ts, Zod schemas in*_schema.ts.These strings are the body of the frozen
full_auth_schemamigration — never edit them (see the auth/migrations.ts module doc for the silent-outage rationale). Schema changes ship as new appended migrations, so a column retired later still appears below (created by v0, dropped downstream in the chain).auth/auth_guard_resolver.ts
Auth guard resolver for the route spec system.
Maps the five-axis RouteAuth (
account/actor/roles/credential_types/required_scope) to two-phase middleware sets that apply_route_specs weaves into the per-route pipeline:pre_authorizationruns before the authorization phase, and holds every gate that reads only what the auth middleware already set: require_auth wheneverauth.account === 'required'orauth.actor === 'required'(per registry-time invariant 3,actor: 'required'today implies a credential — accountless actors are out of scope for v1),require_credential_types(types)wheneverauth.credential_types?.length, and the token-scope guard wheneverauth.required_scopenames a capability. All three fire before any body parsing, so a caller they refuse never sees route-shape information — and before the authorization phase, so a wrong channel costs no actor resolution.post_authorizationruns after the dispatcher's authorization phase has populated RequestContext, and holds the one gate that needs it:require_role(roles), wheneverauth.roles?.length, readsc.var.request_context.role_grants.
The resulting order — credential presence, then credential type, then scope, then role — is the spine's canonical coarse-to-fine ordering, the same one
check_action_auth_post_authorizationruns for actions. A route declaring both a credential gate and a surface gate therefore answers with the coarser fact ("this channel may never call me") rather than the finer one ("your token is scoped"), on both dispatch surfaces alike.Public routes (
auth.account === 'none' && auth.actor === 'none') yield empty guard arrays.'optional'axes contribute no pre-authorization 401; the authorization phase sets RequestContext to whatever the credential supports and the post-authorization gates decide whether the actor's role_grants / credential type match.auth/bearer_auth.ts
Bearer auth middleware for API token authentication.
Bearer tokens are rejected when
OriginorRefererheaders are present — browsers must use cookie auth. This reduces attack surface: a stolen token cannot be replayed from a browser context (the browser addsOriginautomatically). The discard is silent on the wire (anti-enumeration); inDEVonly, the middleware adds an `X-Fuz-Auth-Debug: bearer_discarded_browser_context` response header so tests/tooling can tell "token discarded for browser context" apart from "no credential supplied" without weakening production.Token generation and hashing utilities live in auth/api_token.ts.
auth/bootstrap_account.ts
Bootstrap flow for creating the first account.
Uses an atomic
bootstrap_locktable to prevent TOCTOU race conditions. Token verification and account creation happen in a single transaction.auth/bootstrap_route_schema.ts
Hono-free wire shape + schemas for
POST /bootstrap.Split from
bootstrap_routes.tsso the route's declared shape (method, path, auth, input/output/error schemas) is importable without the hono-coupled handler (which sets a session cookie and reads the client IP off the Hono context). create_bootstrap_route_specs spreads bootstrap_route_shape and attaches the live handler; the test surface builder (create_test_app_surface_spec) spreads it with a stub handler so attack-surface generation reads the real shape without pulling the in-process Hono app onto cross-process consumers. Single source of truth — the shape can't drift between the live route and the surface.auth/bootstrap_routes.ts
Bootstrap route spec for first-time account creation.
One-shot endpoint: exchanges a bootstrap token + credentials for an account with keeper privileges and a session cookie.
auth/cell_action_specs.ts
Cell RPC action specs — the declarative contract for the six generic cell verbs. App vocabulary (galleries, posts, events) lives in client-side helpers; the wire stays generic.
auth/cell_actions.ts
Generic cell RPC action handlers.
Six
request_responseactions bound to the specs in auth/cell_action_specs.ts:- Mutations:
cell_create,cell_update,cell_delete,cell_clone. - Reads:
cell_get,cell_list.
Authorization model:
cell_createis authenticated at the spec level. The handler stampscreated_byfromauth.actor.id.pathwrites are admin-only — non-admin callers supplyingpathget ERROR_CELL_PATH_ADMIN_ONLY.cell_getisoptionalauth at the spec level. Per-row authorization viacan_view_cell(auth, cell). Misses + unauthorized reads both 404, so private-cell existence doesn't leak through the wire. Bundled relations are filtered to viewable targets (strict target-visibility).cell_update/cell_deleteare authenticated at the spec level with per-row can_edit_cell enforcement.pathwrites on update are admin-only;visibilitywrites require the manage tier (can_manage_cell).cell_listisoptionalauth at the spec level. The SQL-side visibility predicate in query_cell_list admits null auth to public-only rows and authed callers to owned + public + grant-admitted rows; admin sees all. SQL-side because post-filtering in JS would silently truncate pages. The handler rejects thecreated_byfilter for null auth (account-id enumeration guard).
Mutations emit
cell_create/cell_update/cell_deleteaudit events viadeps.audit.emit(...). The AuditLogConfig threaded through the consumer'saudit_factory(see create_app_backend) must declare the cell event types (see auth/cell_audit_metadata.ts).App vocabulary (e.g., collection / entry kinds) lives in client-side helpers and per-app
validate_datadeps — this layer is generic-only by construction.- Mutations:
auth/cell_audit_action_specs.ts
cell_audit_listRPC — per-cell audit timeline.Returns audit-log rows whose metadata names this cell on any of the
(cell_id, source_id, parent_id, child_id, target_id, new_id)keys used by the cell-domain event types. The handler 404-masks for callers who are not in the cell's manage tier (can_manage_cell = admin / owner) — the timeline reveals who-touched-the-cell, so it is gated above can_view_cell.Read-only; no audit side effect. Returns the most-recent CELL_AUDIT_LIST_DEFAULT_LIMIT events; pagination is intentionally not on the wire yet — the only consumer renders a single page. Add
{before, limit}input +{next_before}output together when a paginating consumer surfaces.auth/cell_audit_actions.ts
cell_audit_listhandler — per-cell audit timeline read.Authz is manage-tier (can_manage_cell = admin / owner), NOT can_view_cell. The timeline surfaces the
actor_idof every account that touched the cell (including viacell_field/cell_item/ clone edges where the cell is the target / child / source); exposing that to mere viewers — or, for a public cell, to any authenticated caller — leaks who-touched-what. Gating to admin / owner mirrorscell_grant_list("the audit trail is the manager's to read"). Misses + unauthorized reads both 404 withcell_not_found— private-cell existence stays masked. No audit side effect (read-only).auth/cell_audit_events.ts
Canonical cell-layer audit event registry.
Maps every cell-domain
event_typea cell handler emits to its metadata schema. Consumers register the whole bundle in one shot viacreate_audit_log_config({extra_events: {...cell_audit_events, ...}})so the cell handlers'deps.audit.emit(...)calls validate against the extended registry. Spreading lets an app fold its own event types in alongside.Aggregator module by design — not a compat shim. The per-event metadata schemas live in their own files (auth/cell_audit_metadata.ts, auth/cell_grant_audit_metadata.ts, auth/cell_field_audit_metadata.ts, auth/cell_item_audit_metadata.ts); this module is the single registration surface that keeps the keys in lockstep with the handlers.
auth/cell_audit_metadata.ts
Audit-log metadata schemas for the cell layer's mutation events.
Apps register these via
extra_events:on create_audit_log_config alongside any app-defined event types.auth/cell_authorize.ts
Cell view + edit + manage authorization helpers.
Pure functions over
(auth, cell, grants). No DB I/O; the caller is responsible for loading the cell row and the cell's grant list out-of- band, then handing both to the predicate.Unauthenticated callers pass
nullforauth— public pages can resolvecell.visibility === 'public'cells without a session, and that branch is the only path that admits them. Callers may passnullforgrantsin that case (and any case where they know they don't need to consider grants);grant_admitsshort-circuits on null.Three tiers:
- can_view_cell — admin / public / owner / any
viewer+ grant. - can_edit_cell — admin / owner /
editorgrant. NULLcreated_byis admin-only (system-origin defense-in-depth). - can_manage_cell — admin / owner only. Not delegable, not a grant
level. Gates the manage tier:
visibilitywrites and all grant management (cell_grant_create/_list/_revoke).
Grant-admit branches: per-
actor_idgrants admit the matching actor;(role, scope_id?)grants admit any holder of an active role_grant matching those literals (scope_id IS NULLadmits any-scope role_grant). The grant'slevel(viewer/editor) gates which predicate it satisfies.The admin bypass on every tier is the global
admingrant (has_scoped_role(auth, ROLE_ADMIN, null)), never a scoped one.- can_view_cell — admin / public / owner / any
auth/cell_data_schema.ts
Generic cell-data base schema.
The wire-side shape every cell consumer can read without per-kind knowledge: a permissive bag with three universally-relevant typed fields. Per-kind schemas extend this and narrow
kindto a literal.Loose object: arbitrary additional fields pass through unvalidated, preserving the "unknown kinds ship without RPC churn" property. Per-kind shape enforcement is opt-in via the
validate_datadeps slot — see auth/cell_actions.ts.Discipline: a field joins CellData only when at least two consumers in different domains read it generically.
label(list/index rendering) andsummary(card subtitle + share-target description) meet this bar. Future candidates require evidence of generic usage — otherwise they stay per-kind.kindis not in here, and is rejected if present. Likevisibility, a cell'skindis a top-level column (cell.kindon CellJson / CellRow), not content metadata — it is the capability / identity axis a creation authorizer gates on, write-once at birth. A straykindkey insidedatais a fail-loud ERROR_CELL_KIND_IN_DATA at the create / update / clone-patch boundary (single source of truth). Access control (cell.visibility) is likewise a peer column, enforced by can_view_cell reading the column directly (no JSON dive).auth/cell_field_action_specs.ts
Cell-field RPC specs — declarative contract for the three named-relation verbs (
set/delete/list).(source_id, name) → target_idedges modeled after JSON object key/value pairs. One target per(source_id, name)pair: re-setting a name overwrites the prior target.cell_field_listis bidirectional: passsource_idfor forward fields,target_idfor reverse upfields (the latter has 2-layer authz — see handler).auth/cell_field_actions.ts
Cell-field RPC handlers.
Three
request_responseactions bound to the specs in auth/cell_field_action_specs.ts:cell_field_set— admin / owner / editor-grant onsourcemay set;targetmust be view-admitted (so a caller can't link to a cell they couldn't otherwise see). Idempotent UPSERT on(source_id, name).cell_field_delete— admin / owner / editor-grant onsource. Idempotent:deleted: falsewhen no row matched.cell_field_list— bidirectional. Forward (passsource_id) is gated oncan_view_cell(source)and filters targets to those the caller may view (strict target-visibility, batched). Reverse (passtarget_id) has 2-layer authz: gate oncan_view_cell(target)first, then filter rows bycan_view_cell(source).
IDOR-mask 404s on cell-miss / cell-unviewable, mirroring the existence- leak guards in auth/cell_actions.ts / auth/cell_grant_actions.ts.
Audit events
cell_field_set/cell_field_deletecarry IDs only — see auth/cell_field_audit_metadata.ts.auth/cell_field_audit_metadata.ts
Audit-log metadata schemas for
cell_field_set/cell_field_delete.IDs only — same discipline as the cell + cell_grant envelopes (audit logs store references, not denormalized strings). Apps register these via
extra_events:on create_audit_log_config.auth/cell_grant_action_specs.ts
Cell-grant ACL RPC specs — declarative contract for the three
cell_grant_*verbs (create,revoke,list).The grant primitive is a resource-side ACL: each row admits a principal (actor or role+scope) at a level (viewer / editor) on a single cell. Owner is implicit on
cell.created_byand never appears in the grant list.Principal is
{actor_id}or{role, scope_id?}— no name resolver on this verb. Callers pick an actor by id viaactor_search(debounced prefix search) and submit the resolved id directly.auth/cell_grant_actions.ts
Cell-grant ACL RPC handlers.
Three
request_responseactions bound to specs in auth/cell_grant_action_specs.ts:Grant management is manage-tier only (can_manage_cell = admin / owner). Editor-grant holders may edit a cell's content + relations but cannot manage its grants — delegating the share list would let an editor widen access or mint peer editors and escape the manager's authority.
cell_grant_create— admin / owner only. Validates role-shaped principals against the role schema; rejects owner-as-principal. Actor-shaped principals carry a pre-resolvedactor_id(callers pick viaactor_search). Idempotent — re-granting the same principal updateslevelvia UPSERT.cell_grant_revoke— admin / owner, plus self for actor-shaped grants ("leave shared cell"). Returnsstill_admittedcomputed by re-running can_view_cell against the remaining grants.cell_grant_list— admin / owner only. Viewers and editors alike get the IDOR-mask 404 (the share list is the manager's to curate).
All three 404 with
cell_not_foundon cell-miss / cell-unviewable, and withcell_grant_not_foundon grant-miss, mirroring the existence-leak guards in auth/cell_actions.ts.Audit events
cell_grant_create/cell_grant_revokecarry IDs only (no display-name snapshots); see auth/cell_grant_audit_metadata.ts.auth/cell_grant_audit_metadata.ts
Audit-log metadata schemas for the
cell_grantACL events.IDs only — no display-name snapshots. By convention audit logs store references, not denormalized strings; viewer tooling resolves
actor_id→actor.name,scope_id→ scope name, etc. at read time.Apps register these via
extra_events:on create_audit_log_config alongside the other cell metadata schemas.auth/cell_item_action_specs.ts
Cell-item RPC specs — declarative contract for the four ordered-child verbs (
insert/move/delete/list).(parent_id, position) → child_idrows.positionis opaque text (fractional-indexing key); the wire validates the alphabet (^[0-9A-Za-z]+$) and length, the lex-ordering invariant is the client's contract.auth/cell_item_actions.ts
Cell-item RPC handlers.
Four
request_responseactions bound to the specs in auth/cell_item_action_specs.ts:cell_item_insert— admin / owner / editor-grant onparentmay insert;childmust be view-admitted. Returnscell_item_position_takenon(parent_id, position)unique violation; client refreshes bracket and retries.cell_item_move— admin / owner / editor-grant onparent. Same collision-error shape as insert.cell_item_delete— admin / owner / editor-grant onparent. Idempotent:deleted: falsewhen no row matched.cell_item_list— bidirectional. Forward (passparent_id) is gated oncan_view_cell(parent)and filters children to those the caller may view (strict target-visibility, batched). Reverse (passchild_id) has 2-layer authz: gate oncan_view_cell(child), then filter rows bycan_view_cell(parent).
IDOR-mask 404s on cell-miss / cell-unviewable, mirroring the existence- leak guards in auth/cell_actions.ts.
Audit events
cell_item_insert/cell_item_move/cell_item_deletecarry IDs only — see auth/cell_item_audit_metadata.ts.auth/cell_item_audit_metadata.ts
Audit-log metadata schemas for
cell_item_insert/_move/_delete.IDs only (positions are opaque text, not user-derived; safe to log). Apps register these via
extra_events:on create_audit_log_config.auth/cell_relation_visibility.ts
Strict relation-read visibility filter.
Forward relation reads (the
cell_getbundle, forwardcell_field_list/cell_item_list, the deep-clone walk) must not surface edges whose target the caller cannot view — otherwise an editor of a public parent could enumerate private children by id, or a public cell could leak the existence of private linked cells. This helper bulk-loads the target cells + their grants and runs can_view_cell per target in memory, returning the set of viewable target ids. Batched (two queries for the whole id-set) to avoid the N+1 a naive per-row check would cause.auth/cleanup.ts
Periodic auth cleanup — sweeps expired sessions and role_grant offers.
run_auth_cleanup is one pass: it deletes expired sessions and closes the connections they opened (cleanup_expired_sessions), then audits newly expired offers (cleanup_expired_role_grant_offers), returning both counts. start_auth_cleanup schedules it — the one call a server makes, and what create_app_server's
auth_cleanupoption makes for it. The per-pass pieces stay exported for a consumer that wants its own cadence or error isolation per sweep. Twin of the Rust spine'sfuz_auth::auth_cleanup.Expired sessions: the sweep closes what it sweeps
A session past
expires_atfails every validity check, but the connections it opened do not read it again: a WebSocket re-authorizes the actor per message, never the session, and an SSE stream is authorized once at open. While the expired row lingers,account_session_revokeis the per-session handle on those connections — it closes for the row it deletes. The sweep deletes that row, so it takes over the close: every session it deletes has its connections closed throughAuthCleanupDeps.connection_closer, the same close a revocation makes (WS_CLOSE_SESSION_REVOKED on a WebSocket; an SSE stream ends). Otherwise a swept session's connections would be reachable only by an account-wide revocation.The sweep's close is the second on expiry, not the first: every connection already closes at its credential's deadline (auth/credential_deadline.ts) in the process holding it, whether or not a sweep runs. The sweep is for sessions only: an expired API token's row is not swept — its connections close at their deadline, and
account_token_revokestill finds the row to close for.The closes run after the delete's commit, which is the rule every revocation close keeps (see actions/connection_closer.ts). An expired session is refused with or without its row, so the order is not what protects a client rechecking on the close or a connection still opening; it keeps the sweep inside the one invariant admission leans on — a close that missed a registration belongs to a write the re-read can see — rather than on the re-read's clock. A commit whose outcome is unknown still closes: the sessions are expired either way.
That is why the delete runs in its own transaction rather than as one pool-level statement. The ids are in hand before the commit is sent, so a connection lost on the delete's response rolls the delete back for the next pass, and one lost on the commit's still closes — no failure leaves rows deleted with their ids unread. A commit that fails outright has closed too; the rows it left are deleted by the next pass, whose closes then find nothing.
One pass closes for every session it swept; a session with no live connection costs a lookup and closes nothing. The pass logs one count, not a line per session.
No audit row is written for a swept session: expiry is not an event anyone performed, and the session was already refused everywhere. So the audit listeners never see it, and the direct close is the only one.
One limit: the closes reach this process's transports, as every revocation close does — a second process on the same database keeps the connections it holds on a session this one swept.
Offer idempotency
An expired pending offer has no terminal state — expiry is computed from
expires_at, never written — so the offer sweep claims rows instead: it stampsrole_grant_offer.expire_audited_atin the same transaction as each row'srole_grant_offer_expireaudit row, so every expiry is audited exactly once — a second run finds nothing, and concurrent runs claim disjoint rows (row locks serialize them on the re-checked stamp). A failed audit insert rolls the stamps back with it and the next run retries. A re-offer clears the stamp, so the refreshed offer's own expiry is audited too.Scheduling
start_auth_cleanup runs the pass beside the server: a pass at once, then one per interval (DEFAULT_AUTH_CLEANUP_INTERVAL_MS unless the consumer has a reason to differ), until its
stopis called. A failed pass — the database out of reach — is logged and retried on the next interval.Start it once migrations have run (the startup pass reads the auth tables), with the app's bound AuditEmitter so the expiry audits reach its listeners and the closer its revocation handlers close through. AppDeps is all of that, so the standard assembly is one option:
const server = await create_app_server({backend, auth_cleanup: true, ...}); // on shutdown: stops the schedule, then closes the database await server.close();and a hand-assembled server calls it directly:
const auth_cleanup = start_auth_cleanup(backend.deps); // on shutdown, before the database closes await auth_cleanup.stop();The loop is start_periodic (periodic.ts), which keeps the Rust spine's schedule (
fuz_sys::periodic): passes never overlap, and a pass that overruns its interval delays the next one instead of bursting to catch up. It differs on shutdown. The Rust loop drops a pass in progress when its token is cancelled; a promise cannot be dropped, sostopends the schedule at once and its promise resolves when the pass in progress — if there is one — has finished. Awaiting it before closing the database lets that pass end on a live pool rather than fail on a closed one.auth/credential_deadline.ts
The credential deadline — the moment a resolved credential stops authenticating, which a long-lived connection opened on it must not outlive.
A WebSocket or an SSE stream is authorized once, at open, and closed on revocation by identity. Expiry has no revocation event: nothing is written when a session or token passes its
expires_at. So the auth middleware computes the deadline once (CREDENTIAL_DEADLINE_KEY, gathered ontoResolvedAuth.credential_deadline) and the transport closes the connection when it arrives (schedule_credential_deadline).The deadline is the earliest of what the credential carries:
- Session — the signed cookie's embedded expiry and the
auth_sessionrow'sexpires_at. - API token — the
api_tokenrow'sexpires_at; an eternal token (expires_at IS NULL) has none. - Daemon token — none: it has no row and no expiry, and a rotation closes no connection.
A row's expiry is read as remaining time measured on the database clock (EXPIRES_IN_MS_SQL,
expires_at - NOW()in the same statement that checksexpires_at > NOW()), then anchored to this process's monotonic clock (performance.now()) — so a skew between the database's clock and the server's moves no deadline. The cookie's expiry is a wall-clock second this server signed, read against the same wall clock the cookie check reads.The monotonic clock stops while the host is suspended, so a deadline on it alone would hold a connection past its credential by the time asleep. The wait (schedule_credential_deadline) therefore also carries the deadline on the wall clock (
Date.now()), which keeps running through a suspend, and fires at whichever is reached first: after a resume, within one timer step (CREDENTIAL_DEADLINE_TIMER_CAP_MS, an hour). The wall deadline is taken from the time left when the wait starts, so a skew between the database's clock and the server's still moves nothing. A wall clock stepped forward can fire it early; the client reconnects through the gates, the fail-closed side of the trade.The twin of the Rust spine's
fuz_auth::ResolvedAuth::credential_deadlineandfuz_realtime's deadline wait.- Session — the signed cookie's embedded expiry and the
auth/credential_type_schema.ts
Credential-type registry — how a request was authenticated.
Three builtins:
session(cookie-based),api_token(HTTP Bearer token),daemon_token(filesystem proof for the keeper account). Open-string registry on top so consumers can declare additional credential types (e.g.'sso_assertion','agent_token') without an upstream release.RoleSpec.required_credential_typesreferences entries from this registry; v1 keeps the field informative-only (consumed by auth/middleware.ts and the dispatcher). Mirrors the open-registry pattern used for RoleName, ScopeKindName, GrantPathName, and AuditEventTypeName.The Hono-side wire-validated CredentialType Zod enum (in hono_context.ts) is the closed-set narrow type middleware sets on the context; the constants below are the source of truth for those three string values. Future builtin credential types added here propagate to the wire enum by editing the import list.
auth/daemon_token_middleware.ts
Daemon token middleware — the credential consumer.
Validates a presented
X-Daemon-Tokenand resolves the keeper account. The producer half (rotation + file persistence) lives in testing/daemon_token_rotation.ts behind the dev-env guard: no production assembly mints daemon tokens — the credential's only remaining role is the cross-process test harness's keeper channel — mirroring the Rust spine, whose producer is confined tofuz_testing.Pure token primitives (schema, generation, validation) live in auth/daemon_token.ts. See docs/identity.md for design rationale.
auth/daemon_token.ts
Daemon token primitives — schema, generation, and validation.
Pure auth operations with no I/O or state management. The middleware, rotation, and persistence logic lives in auth/daemon_token_middleware.ts.
auth/deps.ts
Stateless capabilities bundle for fuz_app backends.
AppDeps is the central dependency injection type — injectable and swappable per environment (production vs test). Does not contain config (static values) or runtime state (mutable refs).
auth/grant_path_schema.ts
Grant-path registry — the surfaces through which a role can be granted to an actor.
Four builtins:
admin— granted by an admin viarole_grant_offer_create(subject to the consumer'sauthorizecallback) or admin-side direct grant.self_service— toggled by the holder themselves viaself_service_role_set(allowlisted byeligible_roles).system— granted by system code paths (signup, automation, etc.) that don't fit either of the above.- bootstrap — granted exactly once during the bootstrap flow
(
keeper,adminon a fresh install).
Open registry on top so consumers can declare additional paths (e.g.
'invite_only','sso_assertion') without an upstream release.RoleSpec.grant_pathsreferences entries from this registry; the default foradmin_actions.grantable_rolesisgrant_paths.includes('admin'), the default forself_service_role_actionseligibility isgrant_paths.includes('self_service'). Mirrors the open-registry pattern used for RoleName, ScopeKindName, CredentialTypeName, and AuditEventTypeName.auth/invite_queries.ts
Invite database queries.
CRUD operations for the invite table — creating invites, finding unclaimed matches, claiming, and cleanup.
auth/invite_schema.ts
Invite types and client-safe schemas.
Defines the runtime types for the invite system: invite creation, matching, and claiming.
auth/keyring.ts
Key ring for cookie signing.
Encapsulates secret keys and crypto operations. Keys are never exposed - only sign/verify operations are available. This prevents accidental logging or leakage of secrets.
@example
const keyring = create_keyring(process.env.SECRET_FUZ_COOKIE_KEYS); if (!keyring) throw new Error('No keys configured'); const signed = await keyring.sign('user:123:1700000000'); const result = await keyring.verify(signed); // result = { value: 'user:123:1700000000', key_index: 0 }auth/middleware.ts
Auth middleware stack factory.
Creates the standard middleware layers (origin, session, request_context, bearer_auth, optional daemon_token) from configuration.
auth/migrations.ts
Auth schema migrations.
Ordered list of
{name, up}migrations for the fuz identity system tables. Consumed by run_migrations with namespace'fuz_auth'.The released chain is frozen — every schema change ships as an appended migration. Once a consumer holds a long-lived production database, an already-bootstrapped DB has recorded the existing migrations as applied, so editing a released migration's body in place is a silent no-op there: the
CREATE TABLE IF NOT EXISTSdoesn't re-run, the runner sees nothing new, and the new column never lands — a silent, total auth outage. (Thedeleted_at/deleted_bysoft-delete columns were added to v0's base DDL this way; an older deployed DB never got them and every login broke.) So: never edit, rename, reorder, or re-purpose an entry in auth_migrations below. Add every additive change as a NEW appended entry using idempotentALTER TABLE ... ADD COLUMN IF NOT EXISTS— a fresh bootstrap and an old deployed DB then converge on the same shape:// v2: add display_name to account { name: 'account_display_name', up: async (db) => { await db.query('ALTER TABLE account ADD COLUMN IF NOT EXISTS display_name TEXT'); }, },The
/readyschema-drift probe (db/schema_ready.ts) is the runtime net: it fails the deploy loud when a live DB is missing a column the running code expects, rather than letting auth break silently. Discipline prevents the drift; the probe catches a lapse before cutover.Migrations are forward-only (no down). Use
IF NOT EXISTS/IF EXISTSfor DDL safety. Thenameappears in error messages on failure. Dev/test DBs (no long-lived data) may still drop + re-bootstrap freely on a break — the freeze is the contract for the deployed chain, not local iteration.Every entry is twinned on the Rust spine (
fuz_auth/src/migrations.rs), and the two names must match byte for byte: the_testing_migration_trackerparity gate compares the(namespace, name, sequence)rows both spines record, and a divergence there breaks the swap-freely invariant (a consumer pointing either implementation at one database). Append on both sides in the same change, so no entry below repeats the rule.auth/password_argon2.ts
Argon2id password hashing implementation.
Uses
@node-rs/argon2for native performance with OWASP-recommended parameters. Includes timing attack resistance via verify_dummy.Import argon2_password_deps for use as PasswordHashDeps in AppDeps.
auth/password.ts
Password hashing type definitions.
Defines the PasswordHashDeps injectable interface and PASSWORD_LENGTH_MIN. Concrete Argon2id implementation lives in auth/password_argon2.ts.
auth/request_context.ts
Request context middleware and role_grant checking helpers.
Two-phase identity resolution:
- Authentication (middleware) — create_request_context_middleware,
bearer_auth, anddaemon_token_middlewarevalidate the credential (session cookie, bearer token, daemon token) and setc.var.account_id+c.var.credential_typeon the Hono context. They do not resolve an acting actor or load role_grants; REQUEST_CONTEXT_KEY stays null at this stage, so account-grain identity is the only thing known. - Authorization (route-spec wrapper / RPC dispatcher) — after input
validation, the per-route layer inspects the route. If the input
schema declared
acting?: ActingActor(reference equality with the canonical ActingActor schema) or the auth requires role_grants (role/keeper), apply_authorization_phase resolves the actor againstc.var.account_idplus the validatedactingvalue via resolve_acting_actor, builds the{account, actor, role_grants}context via build_request_context, and sets it on REQUEST_CONTEXT_KEY before auth guards fire. Authenticated routes that don't need an actor still get an account-only context via build_account_context so handler signatures stay uniform.
Account-grain operations (logout, password_change, account_verify, etc.) declare neither
actingnor role_grant-requiring auth, so no actor is resolved and their handlers see a RequestContext withactor: null+ emptyrole_grants. They never triggeractor_required, which is what makes multi-actor logout work without first picking a persona.build_request_context loads
account → actor → role_grantsand verifies theactor.account_id === account.idbinding. refresh_role_grants reloads role_grants on an existing context.- Authentication (middleware) — create_request_context_middleware,
auth/resolved_auth.ts
The account-grain credential a request resolved to, and the re-check a long-lived connection runs on it once it is registered.
The auth middleware chain (daemon token → session cookie → bearer) resolves a credential and leaves it on the Hono context as separate keys. ResolvedAuth is that credential gathered into one value (get_resolved_auth), so the connections that outlive their request — a WebSocket, an SSE stream — can carry it past the handshake, re-read it with revalidate_resolved_auth, and close at its
credential_deadline.auth/role_grant_offer_action_specs.ts
Role grant offer RPC action specs — declarative contract for the consentful-role-grants surface (offer lifecycle + admin revoke + the immediate admin-only
role_grant_assign).Import this module for the specs, Input/Output schemas,
ERROR_ROLE_GRANT_OFFER_*reason constants, and the all_role_grant_offer_action_specs registry. Handlers live in auth/role_grant_offer_actions.ts.Authorization enforcement: offer-lifecycle specs declare account+actor required (no roles) and rely on
query_*IDOR guards or in-handler policy checks (e.g.role_grant_offer_list/_historyelevate to admin only when inspecting another account — an input-dependent check that can't be expressed at the spec level).role_grant_revokeandrole_grant_assignaddroles: ['admin']— the RPC dispatcher's per-spec post-authorization auth gate (check_action_auth_post_authorization) rejects non-admin callers before the handler runs even though the endpoint hosts non-admin methods alongside.auth/role_grant_offer_actions.ts
Role grant offer RPC action handlers — the consentful-role-grants action surface.
Eight actions: six offer-lifecycle methods (create / accept / decline / retract / list / history) plus
role_grant_revokeandrole_grant_assign(both admin-only). All mount on a consumer's JSON-RPC endpoint via create_rpc_endpoint. The action specs themselves live in auth/role_grant_offer_action_specs.ts. Mutations declareside_effects: trueso the RPC dispatcher wraps the handler in a DB transaction;role_grant_offer_listandrole_grant_offer_historydeclareside_effects: falseso they are addressable via GET.Authorization:
role_grant_offer_create— the role'sgrant_pathsmust include'admin', and by default the grantor must hold a globaladmin. Consumers needing a richer policy (e.g., "teacher may offer student in *their* classroom") pass anauthorizecallback that overrides the default — for app roles only: a builtin role (admin/keeper) is global-only (a scoped one is refused withrole_grant_builtin_scoped) and offered only by a global admin, both decided before the callback runs.role_grant_offer_accept/role_grant_offer_decline— keyed to the caller's account;query_*helpers enforce the IDOR guard. Accept also refuses a builtin-role offer that must not mint (keeper, scoped, or a grantor who no longer holds a global admin), auditing the refusal and leaving the offer pending.role_grant_offer_retract— keyed to the caller's actor.role_grant_offer_list/role_grant_offer_history— self by default;{account_id}is admin-only.role_grant_revoke— spec-levelauth: {role: 'admin'}; the RPC dispatcher rejects non-admin callers before the handler runs. The admin-grant-path gate prevents revoking keeper / daemon-scoped roles via this surface. Keys onactor_idto survive multi-actor accounts.role_grant_assign— spec-levelauth: {role: 'admin'}(a global grant, re-checked in the handler); the immediate consent-free conferral path. The admin-grant-path gate prevents assigning keeper / daemon-scoped roles via this surface, and a scopedadminis refused. No holder-propagation — only a global admin confers; holding a role grants no power to assign it.
Audit events are emitted in-transaction by the query layer (atomic with the role_grant write on accept/revoke) or by the handler via the bound
deps.audit.emit_role_grant_targethelper for single-event lifecycle transitions.audit.notify(SSE/WS broadcast) fires post-commit in both paths.WS notifications fan out post-commit via emit_after_commit when a
notification_senderis wired: offer lifecycle transitions notify the counterparty,role_grant_revokenotifies the revokee plus each superseded pending offer's grantor.auth/role_grant_offer_ddl.ts
Role grant offer DDL —
CREATE TABLE+ index statements, the index-side sentinel constants the queries / migrations interpolate, and the table's ROLE_GRANT_OFFER_COLUMNS projection const.Separated from auth/role_grant_offer_schema.ts so the schema module stays Zod-only (paired with auth/auth_ddl.ts and auth/audit_log_ddl.ts).
An offer is a pending grant awaiting recipient consent. Lifecycle states are mutually exclusive via a CHECK constraint (
role_grant_offer_single_terminal): at most one ofaccepted_at/declined_at/retracted_atmay be set. On accept, the offer'sresulting_role_grant_idlinks to the role_grant row produced by query_accept_offer.auth/role_grant_offer_notifications.ts
Role grant offer WebSocket notification specs, builders, and the narrow NotificationSender interface that decouples offer/revoke send sites from BackendWebsocketTransport.
Six RemoteNotificationActionSpecs cover the consentful-role-grants lifecycle events the server pushes to affected accounts:
role_grant_offer_received→ recipient's sockets when an offer is createdrole_grant_offer_retracted→ recipient's sockets when a grantor retractsrole_grant_offer_accepted→ grantor's sockets when the recipient acceptsrole_grant_offer_declined→ grantor's sockets when the recipient declinesrole_grant_offer_supersede→ grantor's sockets when a sibling accept, a revoke of the resulting role_grant, or destruction of the parent scope row obsoletes their pending offerrole_grant_revoke→ revokee's sockets when one of their active role_grants is revoked (companion to therole_grant_revokeaudit event)
Payloads are flat and normalized — RoleGrantOfferJson for the offer-lifecycle notifications (decline reason rides on
offer.decline_reason, not a sibling field), and{role_grant_id, role, scope_id, reason?}forrole_grant_revoke. The revokee/grantor/recipient account id travels via the send target (theNotificationSender.send_to_accountargument), not in the payload.The specs surface as EventSpecs via create_action_event_spec — callers append role_grant_offer_notification_specs to their
event_specson create_app_server so the surface reflects them and DEV-mode broadcast validation catches payload drift.auth/role_grant_offer_queries.ts
Role grant offer database queries.
Covers the offer side of the consentful-role-grants flow: create (with re-offer upsert), decline, retract, list, find-pending, sweep-expired, and the atomic query_accept_offer that bridges offer → role_grant.
IDOR guards are expressed in each helper's signature — decline/accept require the recipient's
to_account_id, retract requires the grantor'sfrom_actor_id.auth/role_grant_offer_schema.ts
Role grant offer types and client-safe schemas.
An offer is a pending grant awaiting recipient consent. Lifecycle states are mutually exclusive via a CHECK constraint (
role_grant_offer_single_terminal): at most one ofaccepted_at/declined_at/retracted_atmay be set. On accept, the offer'sresulting_role_grant_idlinks to the role_grant row produced by query_accept_offer.Table DDL and index-side sentinel constants live in auth/role_grant_offer_ddl.ts.
auth/role_grant_queries.ts
Role grant database queries.
Role grants are time-bounded, revocable grants of a role to an actor. The system is safe by default — no role_grant, no capability.
auth/role_schema.ts
Role system — builtin roles, role specs, and extensible role schema factory.
Defines the authorization policy vocabulary: which roles exist, their required credential types, the scope kinds each role applies to, and the grant paths through which each role can be granted. Each role gets a structured RoleSpec; the factory create_role_schema merges builtins with consumer-declared specs and validates every cross-axis field against the corresponding open registries (create_credential_type_schema, create_scope_kind_schema, create_grant_path_schema) at construction time so misconfigurations fire at server startup, not at first call.
RoleSpec carries the four cross-axis fields that the dispatcher branches on: credential type, scope kind, grant path, and the role-name itself. v1 keeps the cross-axis fields informative-only (registry-membership validation, no INSERT-time enforcement); v2 may add
(role, scope_kind)enforcement once the shape is clear from real consumer usage.auth/scope_kind_schema.ts
Scope-kind registry for role_grants and role_grant offers.
Role grants have a polymorphic
scope_idthat references whatever entity the consumer chooses (a classroom, a tenant, a workspace, etc.); thescope_kindcolumn tags each row with a machine-readable kind so admin UIs, codegen, and (in v2) registry-time(role, scope_kind)compatibility checks can read it without re-deriving fromscope_id.scope_kindis encoded as nullable paired with the existing nullablescope_id— both null for global, both non-null for scoped, mismatch rejected at the DB layer by therole_grant_scope_kind_paired/role_grant_offer_scope_kind_pairedCHECK constraints. There is no'global'magic-string value; the global case is unambiguously(scope_kind=NULL, scope_id=NULL).Open registry, no builtins. Consumers declare their kinds via
create_scope_kind_schema(consumer_kinds)and pass the result to create_role_schema soRoleSpec.applicable_scope_kindscan be validated at construction time. Mirrors the open-string registry pattern used for RoleName, AuditEventTypeName, and CredentialType.The literal
'GLOBAL'(uppercase) appears as an index expression inside the partial unique indexes onrole_grantandrole_grant_offer(COALESCE(scope_kind, 'GLOBAL')) — never as a column value, never as a registry entry. The uppercase form is structurally distinct from any consumer-declared kind (which match the lowercase ScopeKindName regex), so it cannot collide.auth/self_service_role_action_specs.ts
Unified self-service role toggle action spec — schemas, error reasons, and the codegen-ready registry.
Client-safe: no query-layer or audit-write imports. Handler factory lives in auth/self_service_role_actions.ts.
auth/self_service_role_actions.ts
Unified self-service role toggle RPC action.
One static
request_responseaction —self_service_role_set— that takes{role, enabled}and toggles a global role_grant on the caller for an allowlisted role. Idempotent in both directions: re-enabling an already-held role returnschanged: false; disabling a role the caller doesn't hold returnschanged: false.Eligibility is derived by default from
RoleSpec.grant_paths— every role whosegrant_pathsincludes'self_service'(GRANT_PATH_SELF_SERVICE) is eligible. The factory accepts an optionaleligible_rolesoverride (validated against the suppliedroles.role_specsat factory time so typos surface at startup instead of at first call) for deployments that want to lock the surface down further than the role spec declares. Roles outside the eligible set are rejected withforbidden+ reasonrole_not_self_service_eligible.Audit metadata carries
self_service: trueso admin reviewers can distinguish self-toggled role_grants from admin grants/offers. Therole_grant_create/role_grant_revokemetadata schemas declareself_service: z.boolean().optional()explicitly, so the field is part of the documented schema surface and is round-trip-validated by query_audit_log.Static method name —
rolelives in the input, not the method name — so the spec is codegen-compatible (satisfies RequestResponseActionSpec) and the surface stays constant as consumers add eligible roles. Mirrors the existingrole_grant_offer_create({role})precedent rather than generating per-role methods.Specs and schemas live in auth/self_service_role_action_specs.ts so client-side codegen can import the surface without dragging in the query layer.
auth/session_cookie.ts
Generic session management for cookie-based auth.
Parameterized on identity type via
SessionOptions<TIdentity>. Handles signing, expiration, and key rotation. Apps provide encode/decode for their specific identity format.Cookie value format:
${encode(identity)}:${expires_at}(signed with HMAC-SHA256).auth/session_middleware.ts
Hono session boundary — cookie I/O, request-time middleware, and the session-creation helper shared by login / signup / bootstrap.
auth/session_queries.ts
Auth session database queries.
Server-side sessions keyed by blake3 hash of the session token. The cookie contains the raw token; the database stores only the hash.
auth/signup_route_schema.ts
Hono-free wire schemas + route shape for
POST /signup.Split from
signup_routes.ts(whose handler pullshono/cookieviasession_middlewareto set the new session cookie) so cross-process test suites can build the signup route shape — and assert on the response shape — without dragging the in-process Hono session handler, and its optionalhonopeer, onto a backend-spawning consumer.signup_routes.tsimports these back and attaches the live handler; single source of truth for the wire shape.auth/signup_routes.ts
Signup route spec for account creation.
Public endpoint that creates an account. When
open_signupis disabled (default), a matching unclaimed invite is required. When enabled, anyone can sign up without an invite. Follows the auth/bootstrap_routes.ts pattern.auth/standard_action_specs.ts
Aggregate spec list mirroring create_standard_rpc_actions on the backend.
create_standard_rpc_actions (in auth/standard_rpc_actions.ts) bundles three action registries into one mounted RPC surface: admin + role_grant_offer + account. Frontends mounting that surface need the matching spec list to feed create_rpc_client so the typed Proxy knows about every standard method.
Without this aggregate, every consumer spreads three (or four with self-service roles)
all_*_action_specsimports at the typed-client site, the codegen-sources table, and any other registry construction — a triplicate that drifts silently on either side.Self-service role specs are not included — they're opt-in (require
eligible_rolesconfiguration) and not bundled into create_standard_rpc_actions. Consumers that mount them spread all_self_service_role_action_specs separately.auth/standard_rpc_actions.ts
Combined admin + role-grant-offer + account RPC actions for fuz_app consumers.
The canonical "standard" RPC surface: every stock fuz_app RPC action a typical web consumer wants on one endpoint. Consumers that want a narrower surface drop down to the per-domain factories directly (create_admin_actions / create_role_grant_offer_actions / create_account_actions).
Option routing: shared
rolesflows to both admin and role-grant-offer;default_ttl_msandauthorizego to role-grant-offer only;max_tokensgoes to account only;notification_senderreaches role-grant-offer transparently (admin + account ignore it).deps.connection_closeris read by admin + account, whose revocation handlers close live connections through it.Paired with create_admin_rpc_adapters on the UI side.
auth/token_lifetime.ts
API token lifetime — the temporal axis of a minted credential.
lifetimeis required onaccount_token_create, mirroringscope: there is deliberately no default. A caller who wants a never-expiring token spells{kind: 'eternal'}, and the request records that they did — an omitted lifetime is a validation error upstream, not an eternal token.expires_at IS NULLtherefore means "deliberately eternal", never "minted before the policy existed".Lifetime is deliberately not part of the scope document — TokenScope is strict on both spines and sits on the hot resolve path. The expiry lives in the indexed
api_token.expires_atcolumn where the SQL validation gate already enforces it (expires_at IS NULL OR expires_at > NOW()).A framework-level ceiling (
max_token_ttl_days) is deferred untilfuzfhas an expiry story (an expiry-aware read + a 401 hint); seedocs/security.md§API tokens.auth/token_scope.ts
TokenScope — the per-credential authority narrowing stored on
api_token.scope.Twin of the Rust spine's
fuz_auth::token_scope. The stored document is the wire contract; both spines read the sameapi_token.scopeJSONB, so thekindstrings, the field names, and the version constant must match exactly.The whole vocabulary
Two variants, and three rules:
fullmust be spelled at mint.account_token_createtakes a requiredscope, the column isNOT NULL, and session / daemon-token credentials resolve tofullby construction. There is no absent state — which is the whole point. The 2026-02scopecolumn storedfull/read/writeand nothing enforced them; the reason that was worse than nothing is that the absent/unenforced case was indistinguishable from a decision.methodsdenies every method it does not list. An empty list is legal and renders the token inert for RPC — unlike an emptycredential_typesallowlist, which is rejected because it renders an *action* unreachable.- A narrowed token is RPC-only.
methodsdenies every non-RPC spine surface outright — the db-admin browser, the bare-hash fact read, the audit SSE stream, and the WS upgrade. See token_scope_admits_non_rpc.