auth
82 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.- Session reads:
auth/account_queries.ts
Account and actor database queries.
Provides CRUD operations for the account and actor tables. For v1, every account has exactly one actor (1:1).
auth/account_route_schema.ts
Hono-free wire schemas + route shapes for the account REST routes.
Split from
account_routes.ts(whose handlers pullhono/cookieviasession_middleware) so cross-process test suites can build the account route shapes — and assert on thePOST /login/GET /api/account/statusresponse shapes — without dragging the in-process Hono session handler, and its optionalhonopeer, onto a backend-spawning consumer.account_routes.tsimports these back and attaches the live handlers; single source of truth for the wire shape.auth/account_routes.ts
Account route specs for cookie-based session management.
Returns
RouteSpec[]— caller applies them to Hono via apply_route_specs.Four REST flows remain here; each has a concrete reason to stay REST rather than moving to auth/account_actions.ts:
POST /login— issues a signedSet-Cookieand pre-handler rate-limits by IP + per-canonical-account before password hashing.POST /logout— clears the session cookie.POST /password— cookie clear + revoke-all cascade; rate-limit-shaped error envelope on 429.GET /verify— empty-body nginxauth_requestprobe. Programmatic callers should use theaccount_verifyRPC action for the typed payload.
Session listing/revocation and API token CRUD are on the RPC endpoint — see auth/account_actions.ts. Signup is in auth/signup_routes.ts. Defaults are closed/safe: accounts are created through bootstrap, admin action, or invite.
auth/account_schema.ts
Auth entity types and client-safe schemas.
Defines the runtime types for the fuz identity system: Account, Actor, RoleGrant, AuthSession, and ApiToken.
Identifier primitives (Username, UsernameProvided, Email) live in primitive_schemas.ts — they're general validator shapes that don't depend on the auth domain. The auth-shape request-contract primitive ActingActor lives in http/auth_shape.ts next to RouteAuth (the two pair:
auth.actor !== 'none'⟺ input declaresacting?: ActingActor).DDL lives in auth/auth_ddl.ts; role system in auth/role_schema.ts. See docs/identity.md for design rationale.
auth/actor_lookup_action_specs.ts
actor_lookupRPC spec — authenticated batched id → username/display_name resolver, keyed by actor id.Powers the labels arc for surfaces that stamp an actor id (bylines, owner columns, grantor labels, audit-log "by" cells). One round trip resolves an array of ids to display strings.
Auth + rate-limit posture
{account: 'required', actor: 'none'}+rate_limit: 'account'. Account-grain — only that the caller is signed in matters, not which actor is calling, so resolution skips the actor phase. The auth gate + per-account rate limit (default 1200/15min) + the {@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 detailemail, password/credential fields — never queriedcreated_at/updated_at— timing-oracle avoidance- role / role_grants / session state — separation of concern
display_nameis omitted (notnull) whenactor.nameis blank, so clients seeundefinedrather than a sentinel string. Unknown ids are silently absent from the response — by construction this is an existence-oracle (the caller can diff response ids against request ids), bounded by:- rate-limit (per-account, see above),
- {@link ACTOR_LOOKUP_IDS_MAX} cap per call,
- actor-uuid intractability (122-bit random),
- hard-deleted actors are indistinguishable from never-existed (no tombstone oracle — see auth/actor_lookup_queries.ts).
Response order is unspecified — callers index by
idwhen needed.auth/actor_lookup_actions.ts
actor_lookupRPC handler.Pure read — no audit, no side effects. Auth (
account: 'required') + rate-limit (account-grain) enforced at the spec layer; see auth/actor_lookup_action_specs.ts for the info-leak audit.display_nameis omitted (notnull) whenactor.nameis blank, matching the wire shapedisplay_name?so the typed client sees anundefinedrather than a sentinel string.auth/actor_lookup_queries.ts
Batched actor-by-id resolver.
Joins
actor⨝accountso callers see(username, display_name)for each actor row. The byline / owner-column / grantor surfaces stamp an actor id, so resolving "who is this actor?" lands the human label in one round trip.Accounts may host multiple actors (multi-actor shipped in v0.55.0). The inner join still resolves one row per actor —
actor.account_idisNOT NULLso every actor has exactly one account.Info-leak posture (see auth/actor_lookup_action_specs.ts §audit):
- Row shape omits
account_id— the join is control-plane, not wire-visible. - Hard-deleted actors (or account-cascade-orphaned rows) drop out silently — indistinguishable from never-existed (no tombstone oracle).
- No
created_at/updated_atprojected (timing-oracle avoidance). - Response order is unspecified —
WHERE id = ANY(...)returns index-scan order in practice but callers must not depend on it.
Caller is responsible for capping
ids.length— the SQL itself does not enforce a bound; the action-spec layer surfacesinvalid_paramsvia ACTOR_LOOKUP_IDS_MAX.- Row shape omits
auth/actor_search_action_specs.ts
actor_searchRPC spec — authenticated case-insensitive prefix search overactor.name, returning the same{id, username, display_name?}wire shape asactor_lookup.Powers person-target pickers — visiones'
CellGrantsEditor.svelteteacher-picks-student flow replaces the deferredactor_by_namearm ofcell_grant_createwith a debounced search against this method. Sibling toactor_lookup: that resolves a known batch of ids → labels; this resolves a partial name → candidate actors.Auth + rate-limit posture
{account: 'required', actor: 'none'}+rate_limit: 'account'. Same shape asactor_lookup: only that the caller is signed in matters, not which actor is calling. The auth gate, the per-account rate limit (default 1200/15min), and the ACTOR_SEARCH_LIMIT_MAX per-call cap bound the enumeration surface this method would otherwise present.The handler additionally requires the caller to be admin when
scope_idsis empty (the unbounded global-search arm). Non-admin callers must always pass at least one scope_id — the SQL filters actors to those holding a role_grant on one of the supplied scopes, so a non-admin caller is restricted to actors they share a scope with. The admin check is account-grain (any actor on the caller's account holds a globaladminrole_grant), matching theactor: 'none'posture.Caller-passes-scope_ids design
scope_idsis trusted as a filter, not as an authority claim — the SQL filters to actors with role_grants on those scopes regardless of whether the caller has authority over them. Consumers are responsible for pre-filteringscope_idsagainst their own authority before calling. Visiones passes the set of classrooms the teacher teaches, sourced client-side from the teacher's role_grant list; the teacher predicate stays in the visiones layer rather than baked into fuz_app.Crucially, this does not widen the scope-existence oracle: an attacker passing a random scope_id cannot learn "this scope has members matching X" because the join filters to actors holding a role_grant on the scope, and the SQL surfaces neither "did the scope exist" nor "did the scope have non-matching members" — only the matching subset is returned.
Wire shape — info-leak audit
Output
{actors: [{id, username, display_name?}]}is identical toactor_lookup's — see auth/actor_lookup_action_specs.ts for the full field-by-field audit. Same omissions (account_id, email, timestamps, role / role_grants / session state), samedisplay_nameomitted-not-null contract, same response-order-unspecified rule.Additional
actor_search-specific posture:- Prefix match (
LOWER(name) LIKE LOWER(query) || '%'), not full%query%. Full-LIKE would let a single call enumerate one alphabetical bucket spread across many starting letters, which defeats the per-call cap as an enumeration bound. - Hard-deleted actors silently drop (cascade through
actor.account_idFK) — no tombstone oracle, same posture asactor_lookup. - Empty result set on no-match — fail-soft like
cell_list. No "no actor matches" error message that would leak an existence boundary on the search-term axis.
Why not extend
actor_lookup?Splitting the methods keeps the wire contracts independent:
actor_lookup's input is{ids},actor_search's is{query}+ optional filters. Both surface the same ActorLookupEntryJson row shape (re-used here), so the labels arc on the consumer side stays uniform.- Prefix match (
auth/actor_search_actions.ts
actor_searchRPC handler.Pure read — no audit, no side effects. Auth (
account: 'required',actor: 'none') + rate-limit (account-grain) enforced at the spec layer; see auth/actor_search_action_specs.ts for the info-leak audit and threat model.The handler adds two checks the spec layer can't express:
- Admin gate on empty
scope_ids— unbounded global search is admin-only. Non-admin callers without ascope_idsfilter are rejected withinvalid_paramscarryingactor_search_scope_required. The admin check is account-grain (any actor on the caller's account holds a globaladminrole_grant) since theactor: 'none'posture doesn't loadauth.role_grantsfor an in-memory check. - Limit clamp — input is bounded by ACTOR_SEARCH_LIMIT_MAX at the schema; the handler picks the default when omitted.
display_nameis omitted (notnull) whenactor.nameis blank, matching the wire shapeActorLookupEntryJson.display_name?— same convention as auth/actor_lookup_actions.ts.- Admin gate on empty
auth/actor_search_queries.ts
Prefix-based actor search.
Sibling to auth/actor_lookup_queries.ts — that resolves a batch of ids to labels; this resolves a partial name to candidate actors. Same row shape (ActorLookupRow) so the labels arc on the consumer side stays uniform.
Case-insensitive LIKE-prefix on
actor.namebacked by theidx_actor_name_lowerfunctional index. LIKE wildcards (%,_,\) in the query string are escaped at the JS layer so the prefix-only contract is enforceable — an unescaped%xyzwould widen the surface to full-LIKE and defeat the per-call cap as a binding bound.Auth filtering —
scope_idsWhen
scope_idsis non-empty, the result is filtered to actors holding an active role_grant on one of the supplied scopes. Active meansrevoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()). Stale (revoked / expired) role_grants do not confer membership for search-visibility purposes — otherwise a removed student would remain visible to teachers indefinitely.The
DISTINCTonactor.idcollapses the case where an actor holds multiple matching role_grants in the supplied scope set into one row.When
scope_idsis omitted (admin-only global path; the handler gates), no role_grant join — every actor with a matching prefix is returned.Info-leak posture (see auth/actor_search_action_specs.ts §audit)
- Row shape omits
account_id— the join is control-plane, not wire-visible. Identical to auth/actor_lookup_queries.ts. - Hard-deleted actors (cascade-orphaned via
actor.account_idFK) drop out silently. - No
created_at/updated_atprojected (timing-oracle avoidance). - Scope-membership uses ANY on the supplied
scope_idsarray but never surfaces "which scope matched" — the result row carries only the actor's wire shape. An attacker passing a random scope_id learns at most "this scope has at least one member matching X" if a match exists, indistinguishable from a no-match search; the caller-passes-scope_ids design (handler trusts the array as a filter, not as authority) means the attacker had to obtain the scope_id from somewhere else first.
Caller bounds
limit(the action-spec layer enforces ACTOR_SEARCH_LIMIT_MAX); SQL clamps to that cap on the call site before reaching this query.- Row shape omits
auth/admin_action_specs.ts
Admin RPC action specs — declarative contract for admin-only operations.
Import this module for the specs, Input/Output schemas, and the all_admin_action_specs registry. Handlers live in auth/admin_actions.ts.
Authorization is declared at the spec level (
auth: {role: ROLE_ADMIN}) so the RPC dispatcher enforces admin before the handler runs and the generated surface accurately reports the requirement.The registry always includes
app_settings_get/app_settings_update— the runtime factory only wires their handlers whenAdminActionOptions.app_settingsis provided; dispatch falls back tomethod_not_foundwhen absent.auth/admin_actions.ts
Admin RPC action handlers — admin-only operations exposed on the JSON-RPC surface.
Four action categories:
- Account management:
admin_account_list,admin_session_list,admin_session_revoke_all,admin_token_revoke_all. - Audit log reads:
audit_log_list,audit_log_role_grant_history. - Invite CRUD:
invite_create,invite_list,invite_delete. - App settings:
app_settings_get,app_settings_update. The update handler writes theapp_settingsrow in the database; signup reads theopen_signuptoggle fresh from that row on every request, so no in-memory state is shared between this surface and signup.
The action specs themselves live in auth/admin_action_specs.ts. Mutations emit matching audit events via
deps.audit.emit.Authorization is declared at the spec level (
auth: {role: 'admin'}) so the RPC dispatcher enforces it before the handler runs and the generated surface accurately reports the requirement.role_grant_revokein auth/role_grant_offer_actions.ts uses the same spec-level pattern even though its sibling methods are authenticated-but-not-admin — the dispatcher checks auth per-spec, so mixed-auth endpoints compose cleanly. Handler-level gates are reserved for input-dependent elevation (e.g.role_grant_offer_list/_historyelevate to admin only when the caller passes anaccount_idother than their own — an input-dependent check the spec can't express).- Account management:
auth/all_action_spec_registries.ts
Canonical list of every fuz_auth action-spec registry — for cross-cutting walkers and codegen only. Not a mounting surface; consumers continue to import individual
all_*_action_specsbundles (andcreate_*_actionsfactories) per registration site.The "one main bundle" alternative is an antipattern for mounting: create_standard_rpc_actions (admin + role_grant_offer + account) is the canonical surface, and opt-in registries (
self_service_role,actor_lookup) are deliberately opt-in because their eligibility (eligible_roles) or coverage (byline labels) is app-specific. Spreading everything into a single mount would silently widen the dispatch surface the moment a new opt-in landed — the exact failure mode this module is built to detect, not propagate. See./CLAUDE.md§RPC actions (auth/standard_rpc_actions.ts).Use cases for this registry:
- Cross-registry walker tests (input-invariants, auth-shape biconditional) — iterate the spec arrays once, fail when a new registry slips by without an entry here.
- Codegen that needs to see every fuz_auth surface at once (typed-client filters, attack-surface reports). For typed-client wiring of the standard surface, prefer all_standard_action_specs in auth/standard_action_specs.ts — it mirrors the create_standard_rpc_actions mount and stays narrower than this registry-of-registries (no opt-in bundles).
protocol_action_specs (heartbeat / cancel) is not included — those are transport-level wire-protocol concerns shipped by fuz_app and spread by every consumer at registration via protocol_actions from actions/protocol.ts. Walker tests that need protocol coverage spread protocol_action_specs separately.
auth/all_cell_actions.ts
auth/api_token_queries.ts
API token query functions for token CRUD and validation.
auth/api_token.ts
API token generation and hashing utilities.
Tokens use the format
secret_fuz_token_<base64url>and are stored as blake3 hashes. These are pure cryptographic operations with no framework dependency — the bearer auth middleware that validates tokens lives in auth/bearer_auth.ts.auth/app_settings_queries.ts
App settings database queries.
Single-row table queries for global app configuration.
auth/app_settings_schema.ts
App settings types and client-safe schemas.
Single-row table for global app configuration (e.g. open signup toggle).
auth/audit_emitter.ts
Bound audit-emit capability.
AuditEmitter closes over the pool-level Db, its registered listeners, and the optional AuditLogConfig. Built by the consumer's
audit_factorycallback on CreateAppBackendOptions — create_app_backend invokes the factory once with its constructed{db, log}and lands the result onAppDeps.audit. Consumers reach fordeps.audit.emit(ctx, input)and never see the pool — handlers cannot accidentally emit an audit event against the request's transactionaldb(which would be rolled back with the parent on a handler throw).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 registered listener; per-listener throws are isolated.
Listeners are a documented registration seam — create_app_server registers additional listeners via
add_listenerafter the backend is built (the factory-managed audit-log SSE, per-endpoint WS auth guards and logout closers, anyextra_audit_handlerson a WsEndpointSpec) before the first request runs. Consumers can also register listeners directly on the emitter they return fromaudit_factoryfor setups that don't pass through create_app_server.auth/audit_log_ddl.ts
Audit log DDL —
CREATE TABLE+ index statements for theaudit_logtable.Consumed by auth/migrations.ts. Separated from auth/audit_log_schema.ts so the schema module stays Zod-only (paired with auth/auth_ddl.ts and auth/role_grant_offer_ddl.ts).
Multi-actor invariants the envelope columns assume:
actor_id+account_id, when both populated, refer to the same account (derivable viaactor.account_id). Denormalized for indexed audit queries; do not let them disagree.target_actor_id+target_account_id, same rule when both populated.target_account_idis the SSE/WS socket-close key — sessions stay account-grain after multi-actor lands, so this column carries the routing identity even on actor-bound events.target_actor_idis populated iff the event subject is actor-bound (seeAuditLogEvent.target_actor_iddoc-comment for the rule).
No FK on the four identity columns. They are plain
UUID, notREFERENCES … ON DELETE SET NULL. An audit log is an append-only historical record, not a live relational entity;SET NULLerased the very attribution the log exists to preserve. With soft-delete as the default (account.deleted_at) the rows stay and the ids JOIN-resolve; on a hard purge the raw id survives instead of nulling, and the purge audit event snapshots the identity into metadata (delete = soft, purge = hard).auth/audit_log_queries.ts
Audit log database queries.
Records and retrieves auth mutation events for security monitoring. The canonical fire-and-forget entry point is
AppDeps.audit.emit(ctx, input)(see auth/audit_emitter.ts) — it closes over the pool so audit rows persist even when the request transaction rolls back. This module only exposes the in-transactionquery_*primitives and the drift counters; the bound emitter writes through query_audit_log against its captured pool.auth/audit_log_route_schema.ts
Hono-free wire schemas + route shape for the audit-log SSE stream.
Split from
audit_log_routes.ts(whose handler pullshono/streamingviarealtime/sse) so cross-process test suites can build the audit-stream route shape without dragging the in-process SSE handler, and its optionalhonopeer, onto a backend-spawning consumer.audit_log_routes.tsimports these back and attaches the live SSE handler; single source of truth for the wire shape.auth/audit_log_routes.ts
Audit log SSE stream route.
The two list-reads (
audit_log_list,audit_log_role_grant_history) moved to RPC in auth/admin_actions.ts, and the admin session listing moved toadmin_session_liston the same file. What remains here is the optionalGET /audit/streamSSE route — streams aren't an action-kind, so they stay on REST. The event payload broadcast on the stream surfaces via audit_log_event_specs (one EventSpec per audit event type) declared alongside the broadcaster in realtime/sse_auth_guard.ts.auth/audit_log_schema.ts
Audit log types and client-safe Zod schemas.
Records auth mutations (login, logout, grant, revoke, etc.) for security monitoring and operational visibility.
Table DDL and indexes live in auth/audit_log_ddl.ts.
auth/auth_ddl.ts
Identity-table DDL —
CREATE TABLE, index, and seed statements for the core auth tables (account, actor, role_grant, auth_session, api_token, bootstrap_lock, invite, app_settings).Consumed by auth/migrations.ts. Paired with auth/audit_log_ddl.ts (audit table) and auth/role_grant_offer_ddl.ts (offer table) — DDL lives in
*_ddl.ts, Zod schemas in*_schema.ts.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). The discard is silent on the wire (anti-enumeration); inDEVonly, the middleware adds an `X-Fuz-Auth-Debug: bearer_discarded_browser_context` response header so tests/tooling can tell "token discarded for browser context" apart from "no credential supplied" without weakening production.Token generation and hashing utilities live in auth/api_token.ts.
auth/bootstrap_account.ts
Bootstrap flow for creating the first account.
Uses an atomic
bootstrap_locktable to prevent TOCTOU race conditions. Token verification and account creation happen in a single transaction.auth/bootstrap_route_schema.ts
Hono-free wire shape + schemas for
POST /bootstrap.Split from
bootstrap_routes.tsso the route's declared shape (method, path, auth, input/output/error schemas) is importable without the hono-coupled handler (which sets a session cookie and reads the client IP off the Hono context). create_bootstrap_route_specs spreads bootstrap_route_shape and attaches the live handler; the test surface builder (create_test_app_surface_spec) spreads it with a stub handler so attack-surface generation reads the real shape without pulling the in-process Hono app onto cross-process consumers. Single source of truth — the shape can't drift between the live route and the surface.auth/bootstrap_routes.ts
Bootstrap route spec for first-time account creation.
One-shot endpoint: exchanges a bootstrap token + credentials for an account with keeper privileges and a session cookie.
auth/cell_action_specs.ts
Cell RPC action specs — the declarative contract for the six generic cell verbs. App vocabulary (galleries, posts, events) lives in client-side helpers; the wire stays generic.
auth/cell_actions.ts
Generic cell RPC action handlers.
Six
request_responseactions bound to the specs in auth/cell_action_specs.ts:- Mutations:
cell_create,cell_update,cell_delete,cell_clone. - Reads:
cell_get,cell_list.
Authorization model:
cell_createis authenticated at the spec level. The handler stampscreated_byfromauth.actor.id.pathwrites are admin-only — non-admin callers supplyingpathget ERROR_CELL_PATH_ADMIN_ONLY.cell_getisoptionalauth at the spec level. Per-row authorization viacan_view_cell(auth, cell). Misses + unauthorized reads both 404, so private-cell existence doesn't leak through the wire. Bundled relations are filtered to viewable targets (strict target-visibility).cell_update/cell_deleteare authenticated at the spec level with per-row can_edit_cell enforcement.pathwrites on update are admin-only;visibilitywrites require the manage tier (can_manage_cell).cell_listisoptionalauth at the spec level. The SQL-side visibility predicate in query_cell_list admits null auth to public-only rows and authed callers to owned + public + grant-admitted rows; admin sees all. SQL-side because post-filtering in JS would silently truncate pages. The handler rejects thecreated_byfilter for null auth (account-id enumeration guard).
Mutations emit
cell_create/cell_update/cell_deleteaudit events viadeps.audit.emit(...). The AuditLogConfig threaded through the consumer'saudit_factory(see create_app_backend) must declare the cell event types (see auth/cell_audit_metadata.ts).App vocabulary (e.g., collection / entry kinds) lives in client-side helpers and per-app
validate_datadeps — this layer is generic-only by construction.- Mutations:
auth/cell_audit_action_specs.ts
cell_audit_listRPC — per-cell audit timeline.Returns audit-log rows whose metadata names this cell on any of the
(cell_id, source_id, parent_id, child_id, target_id, new_id)keys used by the cell-domain event types. The handler 404-masks for callers who are not in the cell's manage tier (can_manage_cell = admin / owner) — the timeline reveals who-touched-the-cell, so it is gated above can_view_cell.Read-only; no audit side effect. Returns the most-recent CELL_AUDIT_LIST_DEFAULT_LIMIT events; pagination is intentionally not on the wire yet — the only consumer renders a single page. Add
{before, limit}input +{next_before}output together when a paginating consumer surfaces.auth/cell_audit_actions.ts
cell_audit_listhandler — per-cell audit timeline read.Authz is manage-tier (can_manage_cell = admin / owner), NOT can_view_cell. The timeline surfaces the
actor_idof every account that touched the cell (including viacell_field/cell_item/ clone edges where the cell is the target / child / source); exposing that to mere viewers — or, for a public cell, to any authenticated caller — leaks who-touched-what. Gating to admin / owner mirrorscell_grant_list("the audit trail is the manager's to read"). Misses + unauthorized reads both 404 withcell_not_found— private-cell existence stays masked. No audit side effect (read-only).auth/cell_audit_events.ts
Canonical cell-layer audit event registry.
Maps every cell-domain
event_typea cell handler emits to its metadata schema. Consumers register the whole bundle in one shot viacreate_audit_log_config({extra_events: {...cell_audit_events, ...}})so the cell handlers'deps.audit.emit(...)calls validate against the extended registry. Spreading lets an app fold its own event types in alongside.Aggregator module by design — not a compat shim. The per-event metadata schemas live in their own files (auth/cell_audit_metadata.ts, auth/cell_grant_audit_metadata.ts, auth/cell_field_audit_metadata.ts, auth/cell_item_audit_metadata.ts); this module is the single registration surface that keeps the keys in lockstep with the handlers.
auth/cell_audit_metadata.ts
Audit-log metadata schemas for the cell layer's mutation events.
Apps register these via
extra_events:on create_audit_log_config alongside any app-defined event types.auth/cell_authorize.ts
Cell view + edit + manage authorization helpers.
Pure functions over
(auth, cell, grants). No DB I/O; the caller is responsible for loading the cell row and the cell's grant list out-of- band, then handing both to the predicate.Unauthenticated callers pass
nullforauth— public pages can resolvecell.visibility === 'public'cells without a session, and that branch is the only path that admits them. Callers may passnullforgrantsin that case (and any case where they know they don't need to consider grants);grant_admitsshort-circuits on null.Three tiers:
- can_view_cell — admin / public / owner / any
viewer+ grant. - can_edit_cell — admin / owner /
editorgrant. NULLcreated_byis admin-only (system-origin defense-in-depth). - can_manage_cell — admin / owner only. Not delegable, not a grant
level. Gates the manage tier:
visibilitywrites and all grant management (cell_grant_create/_list/_revoke).
Grant-admit branches: per-
actor_idgrants admit the matching actor;(role, scope_id?)grants admit any holder of an active role_grant matching those literals (scope_id IS NULLadmits any-scope role_grant). The grant'slevel(viewer/editor) gates which predicate it satisfies.- can_view_cell — admin / public / owner / any
auth/cell_data_schema.ts
Generic cell-data base schema.
The wire-side shape every cell consumer can read without per-kind knowledge: a permissive bag with three universally-relevant typed fields. Per-kind schemas extend this and narrow
kindto a literal.Loose object: arbitrary additional fields pass through unvalidated, preserving the "unknown kinds ship without RPC churn" property. Per-kind shape enforcement is opt-in via the
validate_datadeps slot — see auth/cell_actions.ts.Discipline: a field joins CellData only when at least two consumers in different domains read it generically.
label(list/index rendering) andsummary(card subtitle + share-target description) meet this bar. Future candidates require evidence of generic usage — otherwise they stay per-kind.kindis not in here, and is rejected if present. Likevisibility, a cell'skindis a top-level column (cell.kindon CellJson / CellRow), not content metadata — it is the capability / identity axis a creation authorizer gates on, write-once at birth. A straykindkey insidedatais a fail-loud ERROR_CELL_KIND_IN_DATA at the create / update / clone-patch boundary (single source of truth). Access control (cell.visibility) is likewise a peer column, enforced by can_view_cell reading the column directly (no JSON dive).auth/cell_field_action_specs.ts
Cell-field RPC specs — declarative contract for the three named-relation verbs (
set/delete/list).(source_id, name) → target_idedges modeled after JSON object key/value pairs. One target per(source_id, name)pair: re-setting a name overwrites the prior target.cell_field_listis bidirectional: passsource_idfor forward fields,target_idfor reverse upfields (the latter has 2-layer authz — see handler).auth/cell_field_actions.ts
Cell-field RPC handlers.
Three
request_responseactions bound to the specs in auth/cell_field_action_specs.ts:cell_field_set— admin / owner / editor-grant onsourcemay set;targetmust be view-admitted (so a caller can't link to a cell they couldn't otherwise see). Idempotent UPSERT on(source_id, name).cell_field_delete— admin / owner / editor-grant onsource. Idempotent:deleted: falsewhen no row matched.cell_field_list— bidirectional. Forward (passsource_id) is gated oncan_view_cell(source)and filters targets to those the caller may view (strict target-visibility, batched). Reverse (passtarget_id) has 2-layer authz: gate oncan_view_cell(target)first, then filter rows bycan_view_cell(source).
IDOR-mask 404s on cell-miss / cell-unviewable, mirroring the existence- leak guards in auth/cell_actions.ts / auth/cell_grant_actions.ts.
Audit events
cell_field_set/cell_field_deletecarry IDs only — see auth/cell_field_audit_metadata.ts.auth/cell_field_audit_metadata.ts
Audit-log metadata schemas for
cell_field_set/cell_field_delete.IDs only — same discipline as the cell + cell_grant envelopes (audit logs store references, not denormalized strings). Apps register these via
extra_events:on create_audit_log_config.auth/cell_grant_action_specs.ts
Cell-grant ACL RPC specs — declarative contract for the three
cell_grant_*verbs (create,revoke,list).The grant primitive is a resource-side ACL: each row admits a principal (actor or role+scope) at a level (viewer / editor) on a single cell. Owner is implicit on
cell.created_byand never appears in the grant list.Principal is
{actor_id}or{role, scope_id?}— no name resolver on this verb. Callers pick an actor by id viaactor_search(debounced prefix search) and submit the resolved id directly.auth/cell_grant_actions.ts
Cell-grant ACL RPC handlers.
Three
request_responseactions bound to specs in auth/cell_grant_action_specs.ts:Grant management is manage-tier only (can_manage_cell = admin / owner). Editor-grant holders may edit a cell's content + relations but cannot manage its grants — delegating the share list would let an editor widen access or mint peer editors and escape the manager's authority.
cell_grant_create— admin / owner only. Validates role-shaped principals against the role schema; rejects owner-as-principal. Actor-shaped principals carry a pre-resolvedactor_id(callers pick viaactor_search). Idempotent — re-granting the same principal updateslevelvia UPSERT.cell_grant_revoke— admin / owner, plus self for actor-shaped grants ("leave shared cell"). Returnsstill_admittedcomputed by re-running can_view_cell against the remaining grants.cell_grant_list— admin / owner only. Viewers and editors alike get the IDOR-mask 404 (the share list is the manager's to curate).
All three 404 with
cell_not_foundon cell-miss / cell-unviewable, and withcell_grant_not_foundon grant-miss, mirroring the existence-leak guards in auth/cell_actions.ts.Audit events
cell_grant_create/cell_grant_revokecarry IDs only (no display-name snapshots); see auth/cell_grant_audit_metadata.ts.auth/cell_grant_audit_metadata.ts
Audit-log metadata schemas for the
cell_grantACL events.IDs only — no display-name snapshots. By convention audit logs store references, not denormalized strings; viewer tooling resolves
actor_id→actor.name,scope_id→ scope name, etc. at read time.Apps register these via
extra_events:on create_audit_log_config alongside the other cell metadata schemas.auth/cell_item_action_specs.ts
Cell-item RPC specs — declarative contract for the four ordered-child verbs (
insert/move/delete/list).(parent_id, position) → child_idrows.positionis opaque text (fractional-indexing key); the wire validates the alphabet (^[0-9A-Za-z]+$) and length, the lex-ordering invariant is the client's contract.auth/cell_item_actions.ts
Cell-item RPC handlers.
Four
request_responseactions bound to the specs in auth/cell_item_action_specs.ts:cell_item_insert— admin / owner / editor-grant onparentmay insert;childmust be view-admitted. Returnscell_item_position_takenon(parent_id, position)unique violation; client refreshes bracket and retries.cell_item_move— admin / owner / editor-grant onparent. Same collision-error shape as insert.cell_item_delete— admin / owner / editor-grant onparent. Idempotent:deleted: falsewhen no row matched.cell_item_list— bidirectional. Forward (passparent_id) is gated oncan_view_cell(parent)and filters children to those the caller may view (strict target-visibility, batched). Reverse (passchild_id) has 2-layer authz: gate oncan_view_cell(child), then filter rows bycan_view_cell(parent).
IDOR-mask 404s on cell-miss / cell-unviewable, mirroring the existence- leak guards in auth/cell_actions.ts.
Audit events
cell_item_insert/cell_item_move/cell_item_deletecarry IDs only — see auth/cell_item_audit_metadata.ts.auth/cell_item_audit_metadata.ts
Audit-log metadata schemas for
cell_item_insert/_move/_delete.IDs only (positions are opaque text, not user-derived; safe to log). Apps register these via
extra_events:on create_audit_log_config.auth/cell_relation_visibility.ts
Strict relation-read visibility filter.
Forward relation reads (the
cell_getbundle, forwardcell_field_list/cell_item_list, the deep-clone walk) must not surface edges whose target the caller cannot view — otherwise an editor of a public parent could enumerate private children by id, or a public cell could leak the existence of private linked cells. This helper bulk-loads the target cells + their grants and runs can_view_cell per target in memory, returning the set of viewable target ids. Batched (two queries for the whole id-set) to avoid the N+1 a naive per-row check would cause.auth/cleanup.ts
Periodic auth cleanup — sweeps expired sessions and role_grant offers.
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'.The released chain is frozen — every schema change ships as an appended migration. Once a consumer holds a long-lived production database, an already-bootstrapped DB has recorded the existing migrations as applied, so editing a released migration's body in place is a silent no-op there: the
CREATE TABLE IF NOT EXISTSdoesn't re-run, the runner sees nothing new, and the new column never lands — a silent, total auth outage. (Thedeleted_at/deleted_bysoft-delete columns were added to v0's base DDL this way; an older deployed DB never got them and every login broke.) So: never edit, rename, reorder, or re-purpose an entry in auth_migrations below. Add every additive change as a NEW appended entry using idempotentALTER TABLE ... ADD COLUMN IF NOT EXISTS— a fresh bootstrap and an old deployed DB then converge on the same shape:// v2: add display_name to account { name: 'account_display_name', up: async (db) => { await db.query('ALTER TABLE account ADD COLUMN IF NOT EXISTS display_name TEXT'); }, },The
/readyschema-drift probe (db/schema_ready.ts) is the runtime net: it fails the deploy loud when a live DB is missing a column the running code expects, rather than letting auth break silently. Discipline prevents the drift; the probe catches a lapse before cutover.Migrations are forward-only (no down). Use
IF NOT EXISTS/IF EXISTSfor DDL safety. Thenameappears in error messages on failure. Dev/test DBs (no long-lived data) may still drop + re-bootstrap freely on a break — the freeze is the contract for the deployed chain, not local iteration.auth/password_argon2.ts
Argon2id password hashing implementation.
Uses
@node-rs/argon2for native performance with OWASP-recommended parameters. Includes timing attack resistance via verify_dummy.Import argon2_password_deps for use as PasswordHashDeps in AppDeps.
auth/password.ts
Password hashing type definitions.
Defines the PasswordHashDeps injectable interface and PASSWORD_LENGTH_MIN. Concrete Argon2id implementation lives in auth/password_argon2.ts.
auth/request_context.ts
Request context middleware and role_grant checking helpers.
Two-phase identity resolution:
- Authentication (middleware) — create_request_context_middleware,
bearer_auth, anddaemon_token_middlewarevalidate the credential (session cookie, bearer token, daemon token) and setc.var.account_id+c.var.credential_typeon the Hono context. They do not resolve an acting actor or load role_grants; REQUEST_CONTEXT_KEY stays null at this stage, so account-grain identity is the only thing known. - Authorization (route-spec wrapper / RPC dispatcher) — after input
validation, the per-route layer inspects the route. If the input
schema declared
acting?: ActingActor(reference equality with the canonical ActingActor schema) or the auth requires role_grants (role/keeper), apply_authorization_phase resolves the actor againstc.var.account_idplus the validatedactingvalue via resolve_acting_actor, builds the{account, actor, role_grants}context via build_request_context, and sets it on REQUEST_CONTEXT_KEY before auth guards fire. Authenticated routes that don't need an actor still get an account-only context via build_account_context so handler signatures stay uniform.
Account-grain operations (logout, password_change, account_verify, etc.) declare neither
actingnor role_grant-requiring auth, so no actor is resolved and their handlers see a RequestContext withactor: null+ emptyrole_grants. They never triggeractor_required, which is what makes multi-actor logout work without first picking a persona.build_request_context loads
account → actor → role_grantsand verifies theactor.account_id === account.idbinding. refresh_role_grants reloads role_grants on an existing context.- Authentication (middleware) — create_request_context_middleware,
auth/role_grant_offer_action_specs.ts
Role grant offer RPC action specs — declarative contract for the consentful-role-grants surface (offer lifecycle + admin revoke + the immediate admin-only
role_grant_assign).Import this module for the specs, Input/Output schemas,
ERROR_ROLE_GRANT_OFFER_*reason constants, and the all_role_grant_offer_action_specs registry. Handlers live in auth/role_grant_offer_actions.ts.Authorization enforcement: offer-lifecycle specs declare account+actor required (no roles) and rely on
query_*IDOR guards or in-handler policy checks (e.g.role_grant_offer_list/_historyelevate to admin only when inspecting another account — an input-dependent check that can't be expressed at the spec level).role_grant_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.
Eight actions: six offer-lifecycle methods (create / accept / decline / retract / list / history) plus
role_grant_revokeandrole_grant_assign(both admin-only). All mount on a consumer's JSON-RPC endpoint via create_rpc_endpoint. The action specs themselves live in auth/role_grant_offer_action_specs.ts. Mutations declareside_effects: trueso the RPC dispatcher wraps the handler in a DB transaction;role_grant_offer_listandrole_grant_offer_historydeclareside_effects: falseso they are addressable via GET.Authorization:
role_grant_offer_create— the 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.role_grant_assign— spec-levelauth: {role: 'admin'}; the immediate consent-free conferral path. The admin-grant-path gate prevents assigning keeper / daemon-scoped roles via this surface. No holder-propagation — only admins confer (see participation-gates.md Decision 7).
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 createdrole_grant_offer_retracted→ recipient's sockets when a grantor retractsrole_grant_offer_accepted→ grantor's sockets when the recipient acceptsrole_grant_offer_declined→ grantor's sockets when the recipient declinesrole_grant_offer_supersede→ grantor's sockets when a sibling accept, a revoke of the resulting role_grant, or destruction of the parent scope row obsoletes their pending offerrole_grant_revoke→ revokee's sockets when one of their active role_grants is revoked (companion to therole_grant_revokeaudit event)
Payloads are flat and normalized — RoleGrantOfferJson for the offer-lifecycle notifications (decline reason rides on
offer.decline_reason, not a sibling field), and{role_grant_id, role, scope_id, reason?}forrole_grant_revoke. The revokee/grantor/recipient account id travels via the send target (theNotificationSender.send_to_accountargument), not in the payload.The specs surface as EventSpecs via create_action_event_spec — callers append role_grant_offer_notification_specs to their
event_specson create_app_server so the surface reflects them and DEV-mode broadcast validation catches payload drift.auth/role_grant_offer_queries.ts
Role grant offer database queries.
Covers the offer side of the consentful-role-grants flow: create (with re-offer upsert), decline, retract, list, find-pending, sweep-expired, and the atomic query_accept_offer that bridges offer → role_grant.
IDOR guards are expressed in each helper's signature — decline/accept require the recipient's
to_account_id, retract requires the grantor'sfrom_actor_id.auth/role_grant_offer_schema.ts
Role grant offer types and client-safe schemas.
An offer is a pending grant awaiting recipient consent. Lifecycle states are mutually exclusive via a CHECK constraint (
role_grant_offer_single_terminal): at most one ofaccepted_at/declined_at/retracted_atmay be set. On accept, the offer'sresulting_role_grant_idlinks to the role_grant row produced by query_accept_offer.Table DDL and index-side sentinel constants live in auth/role_grant_offer_ddl.ts.
auth/role_grant_queries.ts
Role grant database queries.
Role grants are time-bounded, revocable grants of a role to an actor. The system is safe by default — no role_grant, no capability.
auth/role_schema.ts
Role system — builtin roles, role specs, and extensible role schema factory.
Defines the authorization policy vocabulary: which roles exist, their required credential types, the scope kinds each role applies to, and the grant paths through which each role can be granted. Each role gets a structured RoleSpec; the factory create_role_schema merges builtins with consumer-declared specs and validates every cross-axis field against the corresponding open registries (create_credential_type_schema, create_scope_kind_schema, create_grant_path_schema) at construction time so misconfigurations fire at server startup, not at first call.
RoleSpec carries the four cross-axis fields that the dispatcher branches on: credential type, scope kind, grant path, and the role-name itself. v1 keeps the cross-axis fields informative-only (registry-membership validation, no INSERT-time enforcement); v2 may add
(role, scope_kind)enforcement once the shape is clear from real consumer usage.auth/scope_kind_schema.ts
Scope-kind registry for role_grants and role_grant offers.
Role grants have a polymorphic
scope_idthat references whatever entity the consumer chooses (a classroom, a tenant, a workspace, etc.); thescope_kindcolumn tags each row with a machine-readable kind so admin UIs, codegen, and (in v2) registry-time(role, scope_kind)compatibility checks can read it without re-deriving fromscope_id.scope_kindis encoded as nullable paired with the existing nullablescope_id— both null for global, both non-null for scoped, mismatch rejected at the DB layer by therole_grant_scope_kind_paired/role_grant_offer_scope_kind_pairedCHECK constraints. There is no'global'magic-string value; the global case is unambiguously(scope_kind=NULL, scope_id=NULL).Open registry, no builtins. Consumers declare their kinds via
create_scope_kind_schema(consumer_kinds)and pass the result to create_role_schema soRoleSpec.applicable_scope_kindscan be validated at construction time. Mirrors the open-string registry pattern used for RoleName, AuditEventTypeName, and CredentialType.The literal
'GLOBAL'(uppercase) appears as an index expression inside the partial unique indexes onrole_grantandrole_grant_offer(COALESCE(scope_kind, 'GLOBAL')) — never as a column value, never as a registry entry. The uppercase form is structurally distinct from any consumer-declared kind (which match the lowercase ScopeKindName regex), so it cannot collide.auth/self_service_role_action_specs.ts
Unified self-service role toggle action spec — schemas, error reasons, and the codegen-ready registry.
Client-safe: no query-layer or audit-write imports. Handler factory lives in auth/self_service_role_actions.ts.
auth/self_service_role_actions.ts
Unified self-service role toggle RPC action.
One static
request_responseaction —self_service_role_set— that takes{role, enabled}and toggles a global role_grant on the caller for an allowlisted role. Idempotent in both directions: re-enabling an already-held role returnschanged: false; disabling a role the caller doesn't hold returnschanged: false.Eligibility is derived by default from
RoleSpec.grant_paths— every role whosegrant_pathsincludes'self_service'(GRANT_PATH_SELF_SERVICE) is eligible. The factory accepts an optionaleligible_rolesoverride (validated against the suppliedroles.role_specsat factory time so typos surface at startup instead of at first call) for deployments that want to lock the surface down further than the role spec declares. Roles outside the eligible set are rejected withforbidden+ reasonrole_not_self_service_eligible.Audit metadata carries
self_service: trueso admin reviewers can distinguish self-toggled role_grants from admin grants/offers. Therole_grant_create/role_grant_revokemetadata schemas declareself_service: z.boolean().optional()explicitly, so the field is part of the documented schema surface and is round-trip-validated by query_audit_log.Static method name —
rolelives in the input, not the method name — so the spec is codegen-compatible (satisfies RequestResponseActionSpec) and the surface stays constant as consumers add eligible roles. Mirrors the existingrole_grant_offer_create({role})precedent rather than generating per-role methods.Specs and schemas live in auth/self_service_role_action_specs.ts so client-side codegen can import the surface without dragging in the query layer.
auth/session_cookie.ts
Generic session management for cookie-based auth.
Parameterized on identity type via
SessionOptions<TIdentity>. Handles signing, expiration, and key rotation. Apps provide encode/decode for their specific identity format.Cookie value format:
${encode(identity)}:${expires_at}(signed with HMAC-SHA256).auth/session_middleware.ts
Hono session boundary — cookie I/O, request-time middleware, and the session-creation helper shared by login / signup / bootstrap.
auth/session_queries.ts
Auth session database queries.
Server-side sessions keyed by blake3 hash of the session token. The cookie contains the raw token; the database stores only the hash.
auth/signup_route_schema.ts
Hono-free wire schemas + route shape for
POST /signup.Split from
signup_routes.ts(whose handler pullshono/cookieviasession_middlewareto set the new session cookie) so cross-process test suites can build the signup route shape — and assert on the response shape — without dragging the in-process Hono session handler, and its optionalhonopeer, onto a backend-spawning consumer.signup_routes.tsimports these back and attaches the live handler; single source of truth for the wire shape.auth/signup_routes.ts
Signup route spec for account creation.
Public endpoint that creates an account. When
open_signupis disabled (default), a matching unclaimed invite is required. When enabled, anyone can sign up without an invite. Follows the auth/bootstrap_routes.ts pattern.auth/standard_action_specs.ts
Aggregate spec list mirroring create_standard_rpc_actions on the backend.
create_standard_rpc_actions (in auth/standard_rpc_actions.ts) bundles three action registries into one mounted RPC surface: admin + role_grant_offer + account. Frontends mounting that surface need the matching spec list to feed create_rpc_client so the typed Proxy knows about every standard method.
Without this aggregate, every consumer spreads three (or four with self-service roles)
all_*_action_specsimports at the typed-client site, the codegen-sources table, and any other registry construction — a triplicate that drifts silently on either side.Self-service role specs are not included — they're opt-in (require
eligible_rolesconfiguration) and not bundled into create_standard_rpc_actions. Consumers that mount them spread all_self_service_role_action_specs separately.auth/standard_rpc_actions.ts
Combined admin + role-grant-offer + account RPC actions for fuz_app consumers.
The canonical "standard" RPC surface: every stock fuz_app RPC action a typical web consumer wants on one endpoint. Consumers that want a narrower surface drop down to the per-domain factories directly (create_admin_actions / create_role_grant_offer_actions / create_account_actions).
Option routing: shared
rolesflows to both admin and role-grant-offer;default_ttl_msandauthorizego to role-grant-offer only;max_tokensgoes to account only; 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.