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_response actions 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 returns revoked: false rather 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, and account_token_create for the tokens its cap evicts.

    Counterpart to auth/account_routes.ts, which keeps the cookie-lifecycle flows (login, logout, password, signup, bootstrap) on REST.

  • 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 pull hono/cookie via session_middleware) so cross-process test suites can build the account route shapes — and assert on the POST /login / GET /api/account/status response shapes — without dragging the in-process Hono session handler, and its optional hono peer, onto a backend-spawning consumer. account_routes.ts imports 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 signed Set-Cookie and 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 nginx auth_request probe. Programmatic callers should use the account_verify RPC 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 declares acting?: 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_lookup RPC 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 the cell_list ↔ actor_lookup pair 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 detail
    • email, password/credential fields — never queried
    • created_at / updated_at — timing-oracle avoidance
    • role / role_grants / session state — separation of concern

    display_name is omitted (not null) when actor.name is blank, so clients see undefined rather 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:

    1. rate-limit (per-account, see above),
    2. ACTOR_LOOKUP_IDS_MAX cap per call,
    3. actor-uuid intractability (122-bit random),
    4. hard-deleted actors are indistinguishable from never-existed (no tombstone oracle — see auth/actor_lookup_queries.ts).

    Response order is unspecified — callers index by id when needed.

  • auth/actor_lookup_actions.ts

    actor_lookup RPC 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_name is omitted (not null) when actor.name is blank, matching the wire shape display_name? so the typed client sees an undefined rather than a sentinel string.

  • auth/actor_lookup_queries.ts

    Batched actor-by-id resolver.

    Joins actor ⨝ account so 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_id is NOT NULL so 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_at projected (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 surfaces invalid_params via ACTOR_LOOKUP_IDS_MAX.

  • auth/actor_search_action_specs.ts

    actor_search RPC spec — authenticated case-insensitive prefix search over actor.name, returning the same {id, username, display_name?} wire shape as actor_lookup.

    Powers person-target pickers — visiones' CellGrantsEditor.svelte teacher-picks-student flow replaces the deferred actor_by_name arm of cell_grant_create with a debounced search against this method. Sibling to actor_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 as actor_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_ids is 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 global admin role_grant), matching the actor: 'none' posture.

    Caller-passes-scope_ids design

    scope_ids is 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-filtering scope_ids against 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 to actor_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), same display_name omitted-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_id FK) — no tombstone oracle, same posture as actor_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.

  • auth/actor_search_actions.ts

    actor_search RPC 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 a scope_ids filter are rejected with invalid_params carrying actor_search_scope_required. The admin check is account-grain (any active actor on the caller's account holds a global admin role_grant) since the actor: 'none' posture doesn't load auth.role_grants for 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_name is omitted (not null) when actor.name is blank, matching the wire shape ActorLookupEntryJson.display_name? — same convention as auth/actor_lookup_actions.ts.

  • 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.name backed by the idx_actor_name_lower functional index. LIKE wildcards (%, _, \) in the query string are escaped at the JS layer so the prefix-only contract is enforceable — an unescaped %xyz would widen the surface to full-LIKE and defeat the per-call cap as a binding bound.

    Auth filtering — scope_ids

    When scope_ids is non-empty, the result is filtered to actors holding an active role_grant on one of the supplied scopes. Active means revoked_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 DISTINCT on actor.id collapses the case where an actor holds multiple matching role_grants in the supplied scope set into one row.

    When scope_ids is 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_id FK) drop out silently.
    • No created_at / updated_at projected (timing-oracle avoidance).
    • Scope-membership uses ANY on the supplied scope_ids array 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.

  • 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 when AdminActionOptions.app_settings is provided; dispatch falls back to method_not_found when 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 the app_settings row in the database; signup reads the open_signup toggle 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_revoke in 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/_history elevate to admin only when the caller passes an account_id other than their own — an input-dependent check the spec can't express).

  • 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_specs bundles (and create_*_actions factories) 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_factory callback on CreateAppBackendOptions — create_app_backend invokes the factory once with its constructed {db, log} and lands the result on AppDeps.audit. Consumers reach for deps.audit.emit(ctx, input) and never see the pool — handlers cannot accidentally emit an audit event against the request's transactional db (which would be rolled back with the parent on a handler throw).

    These methods cover every fan-out shape the auth domain needs (add_listener and listener_count manage the listener chain):

    • emit(ctx, input) — fire-and-forget pool write. Pushes the in-flight promise onto ctx.pending_effects for post-response flushing. Errors are logged, never thrown. Returns void so callers don't pile up void keywords or accidentally await something whose handle is already in pending_effects.
    • emit_role_grant_target(ctx, auth, input) — wrapper that lifts the actor_id / account_id / ip boilerplate every role-grant-shape audit site repeated. Delegates to emit.
    • 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-flight emit write, on an emitter built with track_inflight: true.

    When listeners hear about a row

    emit writes 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_effects and 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_listener after the backend is built (the factory-managed audit-log SSE, per-endpoint WS auth guards, any extra_audit_handlers on a WsEndpointSpec) before the first request runs, and removes each through the remover add_listener returns when its assembly fails or the server closes. Consumers can also register listeners directly on the emitter they return from audit_factory for setups that don't pass through create_app_server.

  • auth/audit_log_ddl.ts

    Audit log DDL — CREATE TABLE + index statements for the audit_log table.

    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 via actor.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_id is 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_id is populated iff the event subject is actor-bound (see AuditLogEvent.target_actor_id doc-comment for the rule).

    No FK on the four identity columns. They are plain UUID, not REFERENCES … ON DELETE SET NULL. An audit log is an append-only historical record, not a live relational entity; SET NULL erased 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-transaction query_* 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 pulls hono/streaming via realtime/sse) so cross-process test suites can build the audit-stream route shape without dragging the in-process SSE handler, and its optional hono peer, onto a backend-spawning consumer. audit_log_routes.ts imports 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 to admin_session_list on the same file. What remains here is the optional GET /audit/stream SSE 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_schema migration — 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_authorization runs before the authorization phase, and holds every gate that reads only what the auth middleware already set: require_auth whenever auth.account === 'required' or auth.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) whenever auth.credential_types?.length, and the token-scope guard whenever auth.required_scope names 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_authorization runs after the dispatcher's authorization phase has populated RequestContext, and holds the one gate that needs it: require_role(roles), whenever auth.roles?.length, reads c.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_authorization runs 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 Origin or Referer headers are present — browsers must use cookie auth. This reduces attack surface: a stolen token cannot be replayed from a browser context (the browser adds Origin automatically). The discard is silent on the wire (anti-enumeration); in DEV only, 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_lock table 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.ts so 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_response actions 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_create is authenticated at the spec level. The handler stamps created_by from auth.actor.id. path writes are admin-only — non-admin callers supplying path get ERROR_CELL_PATH_ADMIN_ONLY.
    • cell_get is optional auth at the spec level. Per-row authorization via can_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_delete are authenticated at the spec level with per-row can_edit_cell enforcement. path writes on update are admin-only; visibility writes require the manage tier (can_manage_cell).
    • cell_list is optional auth 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 the created_by filter for null auth (account-id enumeration guard).

    Mutations emit cell_create / cell_update / cell_delete audit events via deps.audit.emit(...). The AuditLogConfig threaded through the consumer's audit_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_data deps — this layer is generic-only by construction.

  • auth/cell_audit_action_specs.ts

    cell_audit_list RPC — 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_list handler — per-cell audit timeline read.

    Authz is manage-tier (can_manage_cell = admin / owner), NOT can_view_cell. The timeline surfaces the actor_id of every account that touched the cell (including via cell_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 mirrors cell_grant_list ("the audit trail is the manager's to read"). Misses + unauthorized reads both 404 with cell_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_type a cell handler emits to its metadata schema. Consumers register the whole bundle in one shot via create_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 null for auth — public pages can resolve cell.visibility === 'public' cells without a session, and that branch is the only path that admits them. Callers may pass null for grants in that case (and any case where they know they don't need to consider grants); grant_admits short-circuits on null.

    Three tiers:

    • can_view_cell — admin / public / owner / any viewer+ grant.
    • can_edit_cell — admin / owner / editor grant. NULL created_by is admin-only (system-origin defense-in-depth).
    • can_manage_cell — admin / owner only. Not delegable, not a grant level. Gates the manage tier: visibility writes and all grant management (cell_grant_create / _list / _revoke).

    Grant-admit branches: per-actor_id grants admit the matching actor; (role, scope_id?) grants admit any holder of an active role_grant matching those literals (scope_id IS NULL admits any-scope role_grant). The grant's level (viewer / editor) gates which predicate it satisfies.

    The admin bypass on every tier is the global admin grant (has_scoped_role(auth, ROLE_ADMIN, null)), never a scoped one.

  • 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 kind to 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_data deps 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) and summary (card subtitle + share-target description) meet this bar. Future candidates require evidence of generic usage — otherwise they stay per-kind.

    kind is not in here, and is rejected if present. Like visibility, a cell's kind is a top-level column (cell.kind on CellJson / CellRow), not content metadata — it is the capability / identity axis a creation authorizer gates on, write-once at birth. A stray kind key inside data is 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_id edges modeled after JSON object key/value pairs. One target per (source_id, name) pair: re-setting a name overwrites the prior target. cell_field_list is bidirectional: pass source_id for forward fields, target_id for reverse upfields (the latter has 2-layer authz — see handler).

  • auth/cell_field_actions.ts

    Cell-field RPC handlers.

    Three request_response actions bound to the specs in auth/cell_field_action_specs.ts:

    • cell_field_set — admin / owner / editor-grant on source may set; target must 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 on source. Idempotent: deleted: false when no row matched.
    • cell_field_list — bidirectional. Forward (pass source_id) is gated on can_view_cell(source) and filters targets to those the caller may view (strict target-visibility, batched). Reverse (pass target_id) has 2-layer authz: gate on can_view_cell(target) first, then filter rows by can_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_delete carry 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_by and 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 via actor_search (debounced prefix search) and submit the resolved id directly.

  • auth/cell_grant_actions.ts

    Cell-grant ACL RPC handlers.

    Three request_response actions 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-resolved actor_id (callers pick via actor_search). Idempotent — re-granting the same principal updates level via UPSERT.
    • cell_grant_revoke — admin / owner, plus self for actor-shaped grants ("leave shared cell"). Returns still_admitted computed 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_found on cell-miss / cell-unviewable, and with cell_grant_not_found on grant-miss, mirroring the existence-leak guards in auth/cell_actions.ts.

    Audit events cell_grant_create / cell_grant_revoke carry 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_grant ACL 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_id rows. position is 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_response actions bound to the specs in auth/cell_item_action_specs.ts:

    • cell_item_insert — admin / owner / editor-grant on parent may insert; child must be view-admitted. Returns cell_item_position_taken on (parent_id, position) unique violation; client refreshes bracket and retries.
    • cell_item_move — admin / owner / editor-grant on parent. Same collision-error shape as insert.
    • cell_item_delete — admin / owner / editor-grant on parent. Idempotent: deleted: false when no row matched.
    • cell_item_list — bidirectional. Forward (pass parent_id) is gated on can_view_cell(parent) and filters children to those the caller may view (strict target-visibility, batched). Reverse (pass child_id) has 2-layer authz: gate on can_view_cell(child), then filter rows by can_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_delete carry 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_get bundle, forward cell_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_cleanup option 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's fuz_auth::auth_cleanup.

    Expired sessions: the sweep closes what it sweeps

    A session past expires_at fails 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_revoke is 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 through AuthCleanupDeps.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_revoke still 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 stamps role_grant_offer.expire_audited_at in the same transaction as each row's role_grant_offer_expire audit 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 stop is 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, so stop ends 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 onto ResolvedAuth.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_session row's expires_at.
    • API token — the api_token row's expires_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 checks expires_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_deadline and fuz_realtime's deadline wait.

  • 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_types references 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-Token and 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 to fuz_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 via role_grant_offer_create (subject to the consumer's authorize callback) or admin-side direct grant.
    • self_service — toggled by the holder themselves via self_service_role_set (allowlisted by eligible_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, admin on 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_paths references entries from this registry; the default for admin_actions.grantable_roles is grant_paths.includes('admin'), the default for self_service_role_actions eligibility is grant_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 EXISTS doesn't re-run, the runner sees nothing new, and the new column never lands — a silent, total auth outage. (The deleted_at / deleted_by soft-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 idempotent ALTER 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 /ready schema-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 EXISTS for DDL safety. The name appears 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_tracker parity 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/argon2 for 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:

    1. Authentication (middleware) — create_request_context_middleware, bearer_auth, and daemon_token_middleware validate the credential (session cookie, bearer token, daemon token) and set c.var.account_id + c.var.credential_type on 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.
    2. 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 against c.var.account_id plus the validated acting value 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 acting nor role_grant-requiring auth, so no actor is resolved and their handlers see a RequestContext with actor: null + empty role_grants. They never trigger actor_required, which is what makes multi-actor logout work without first picking a persona.

    build_request_context loads account → actor → role_grants and verifies the actor.account_id === account.id binding. refresh_role_grants reloads role_grants on an existing context.

  • 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/_history elevate to admin only when inspecting another account — an input-dependent check that can't be expressed at the spec level). role_grant_revoke and role_grant_assign add roles: ['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_revoke and role_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 declare side_effects: true so the RPC dispatcher wraps the handler in a DB transaction; role_grant_offer_list and role_grant_offer_history declare side_effects: false so they are addressable via GET.

    Authorization:

    • role_grant_offer_create — the role's grant_paths must include 'admin', and by default the grantor must hold a global admin. Consumers needing a richer policy (e.g., "teacher may offer student in *their* classroom") pass an authorize callback that overrides the default — for app roles only: a builtin role (admin / keeper) is global-only (a scoped one is refused with role_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-level auth: {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 on actor_id to survive multi-actor accounts.
    • role_grant_assign — spec-level auth: {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 scoped admin is 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_target helper 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_sender is wired: offer lifecycle transitions notify the counterparty, role_grant_revoke notifies 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 of accepted_at / declined_at / retracted_at may be set. On accept, the offer's resulting_role_grant_id links 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 created
    • role_grant_offer_retracted → recipient's sockets when a grantor retracts
    • role_grant_offer_accepted → grantor's sockets when the recipient accepts
    • role_grant_offer_declined → grantor's sockets when the recipient declines
    • role_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 offer
    • role_grant_revoke → revokee's sockets when one of their active role_grants is revoked (companion to the role_grant_revoke audit 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?} for role_grant_revoke. The revokee/grantor/recipient account id travels via the send target (the NotificationSender.send_to_account argument), not in the payload.

    The specs surface as EventSpecs via create_action_event_spec — callers append role_grant_offer_notification_specs to their event_specs on 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's from_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 of accepted_at / declined_at / retracted_at may be set. On accept, the offer's resulting_role_grant_id links 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_id that references whatever entity the consumer chooses (a classroom, a tenant, a workspace, etc.); the scope_kind column 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 from scope_id.

    scope_kind is encoded as nullable paired with the existing nullable scope_id — both null for global, both non-null for scoped, mismatch rejected at the DB layer by the role_grant_scope_kind_paired / role_grant_offer_scope_kind_paired CHECK 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 so RoleSpec.applicable_scope_kinds can 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 on role_grant and role_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_response action — 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 returns changed: false; disabling a role the caller doesn't hold returns changed: false.

    Eligibility is derived by default from RoleSpec.grant_paths — every role whose grant_paths includes 'self_service' (GRANT_PATH_SELF_SERVICE) is eligible. The factory accepts an optional eligible_roles override (validated against the supplied roles.role_specs at 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 with forbidden + reason role_not_self_service_eligible.

    Audit metadata carries self_service: true so admin reviewers can distinguish self-toggled role_grants from admin grants/offers. The role_grant_create / role_grant_revoke metadata schemas declare self_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 — role lives 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 existing role_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 pulls hono/cookie via session_middleware to 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 optional hono peer, onto a backend-spawning consumer. signup_routes.ts imports 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_signup is 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_specs imports 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_roles configuration) 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 roles flows to both admin and role-grant-offer; default_ttl_ms and authorize go to role-grant-offer only; max_tokens goes to account only; notification_sender reaches role-grant-offer transparently (admin + account ignore it). deps.connection_closer is 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.

    lifetime is required on account_token_create, mirroring scope: 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 NULL therefore 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_at column 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 until fuzf has an expiry story (an expiry-aware read + a 401 hint); see docs/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 same api_token.scope JSONB, so the kind strings, the field names, and the version constant must match exactly.

    The whole vocabulary

    Two variants, and three rules:

    1. full must be spelled at mint. account_token_create takes a required scope, the column is NOT NULL, and session / daemon-token credentials resolve to full by construction. There is no absent state — which is the whole point. The 2026-02 scope column stored full / read / write and nothing enforced them; the reason that was worse than nothing is that the absent/unenforced case was indistinguishable from a decision.
    2. methods denies every method it does not list. An empty list is legal and renders the token inert for RPC — unlike an empty credential_types allowlist, which is rejected because it renders an *action* unreachable.
    3. A narrowed token is RPC-only. methods denies 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.