auth
59 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.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_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 {@link ACTOR_LOOKUP_IDS_MAX | per-call cap} 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 detail -email, password/credential fields β never queried -created_at/updated_atβ timing-oracle avoidance - role / role_grants / session state β separation of concerndisplay_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:1. rate-limit (per-account, see above), 2. {@link 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
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
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). - Nocreated_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.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 throughactor.account_idFK) β no tombstone oracle, same posture asactor_lookup. - Empty result set on no-match β fail-soft likecell_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_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 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 asactor_lookup_actions.ts.auth/actor_search_queries.ts
Prefix-based actor search.
Sibling to
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
actor_search_action_specs.tsΒ§audit)- Row shape omits
account_idβ the join is control-plane, not wire-visible. Identical toactor_lookup_queries.ts. - Hard-deleted actors (cascade-orphaned viaactor.account_idFK) drop out silently. - Nocreated_at/updated_atprojected (timing-oracle avoidance). - Scope-membership uses ANY on the suppliedscope_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.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(registered only whenAdminActionOptions.app_settingsis provided β the mutable ref is owned by the server context and shared with signup middleware).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).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 (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/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, the
on_audit_eventsubscriber chain, and the optional AuditLogConfig. Built by the consumer'saudit_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).Four methods cover every fan-out shape the auth domain needs:
-
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 apending_effectsqueue (cleanup sweeps, ad-hoc maintenance scripts). Same write-then-notify semantics asemit, just synchronous-with-await. -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). Runs every listener on the chain; per-listener throws are isolated.The chain is a documented mutable seam β create_app_server appends additional listeners after the backend is built (the factory-managed audit-log SSE, per-endpoint WS auth guards and logout closers, any
extra_audit_handlerson a WsEndpointSpec) before the first request runs. Consumers can also append 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).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_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.auth/auth_guard_resolver.ts
Auth guard resolver for the route spec system.
Maps the four-axis RouteAuth (
account/actor/roles/credential_types) to two-phase middleware sets that apply_route_specs weaves into the per-route pipeline:-
pre_validationruns before input validation. require_auth lands here wheneverauth.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). Pre-validation 401 fires before any body parsing so unauthenticated callers never see route-shape information from parse failures. -post_authorizationruns after the dispatcher's authorization phase has populated RequestContext.require_role(roles)fires wheneverauth.roles?.length.require_credential_types(types)fires wheneverauth.credential_types?.length.Public routes (
auth.account === 'none' && auth.actor === 'none') yield empty guard arrays.'optional'axes contribute no pre-validation 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).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_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/cleanup.ts
Periodic auth cleanup β sweeps expired sessions and role_grant offers.
Single entry point for consumers scheduling auth maintenance. Internally runs every known sweep and emits the corresponding audit events so consumer code only manages cadence, not per-task wiring.
The per-task primitives remain exported from their home modules (query_session_cleanup_expired, query_role_grant_offer_sweep_expired); cleanup_expired_role_grant_offers here wraps the latter with the required
role_grant_offer_expireaudit emission and is the piece most likely to be reused in a consumer's bespoke scheduler.Idempotency: the audit log has no tombstone on
role_grant_offer_expire, so concurrent sweep runs double-audit. The expected deployment pattern is a single scheduled invocation per instance β matching query_session_cleanup_expired.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 rotation, persistence, and middleware.
Manages the lifecycle of filesystem-resident daemon tokens: writing to disk, rotation on an interval, and HTTP middleware for authentication.
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'.Schema is not stabilized yet β append-only is NOT the rule. While fuz_app is pre-stable, migration bodies, names, and positions can change freely between versions; consumers upgrading across a schema change are expected to drop and re-bootstrap their dev/test databases (production deployments are not yet a supported use case). Once the schema is declared stable a hard append-only-after-publish rule will apply and the cliff will be called out in the release notes for that version. Until then: edit, rename, reorder, or replace migrations as needed; bias toward collapsing work into the existing v0/v1 entries rather than appending v2 patch migrations.
To add a migration in the pre-stable phase, prefer extending an existing entry's body (consumers will re-bootstrap on upgrade). If you do append a new entry to auth_migrations, the runner will apply it on existing tracker rows β the same shape that will become mandatory once the schema stabilizes:
// v2: add display_name to account { name: 'account_display_name', up: async (db) => { await db.query('ALTER TABLE account ADD COLUMN display_name TEXT'); }, },Migrations are forward-only (no down). Use
IF NOT EXISTS/IF EXISTSfor DDL safety. Thenameappears in error messages on failure.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:
1. 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. 2. Authorization (route-spec wrapper / RPC dispatcher) β after input validation, the per-route layer inspects the route. If the input schema declaredacting?: 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.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).
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_revokeaddsroles: ['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.
Seven actions: six offer-lifecycle methods (create / accept / decline / retract / list / history) plus
role_grant_revoke(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 grantor must hold an active role_grant for the role being offered, and that role'sgrant_pathsmust include'admin'. Consumers needing a richer policy (e.g., "teacher may offer student in *their* classroom") pass anauthorizecallback that overrides the default. -role_grant_offer_accept/role_grant_offer_declineβ keyed to the caller's account;query_*helpers enforce the IDOR guard. -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.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 and the index-side sentinel constants the queries / migrations interpolate.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 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 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_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;app_settingsgoes to admin only;default_ttl_msandauthorizego to role-grant-offer only;max_tokensgoes to account only; sharedconnection_closerflows to admin + account (role-grant-offer ignores);notification_senderreaches role-grant-offer transparently (admin + account ignore it).Paired with create_admin_rpc_adapters on the UI side.