@fuzdev/fuz_app 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/dist/actions/CLAUDE.md +525 -827
  2. package/dist/actions/broadcast_api.d.ts +1 -1
  3. package/dist/actions/broadcast_api.js +1 -1
  4. package/dist/actions/cancel.d.ts +2 -2
  5. package/dist/actions/cancel.js +3 -3
  6. package/dist/actions/connection_closer.d.ts +65 -0
  7. package/dist/actions/connection_closer.d.ts.map +1 -0
  8. package/dist/actions/connection_closer.js +38 -0
  9. package/dist/actions/register_action_ws.d.ts +2 -2
  10. package/dist/actions/register_action_ws.d.ts.map +1 -1
  11. package/dist/actions/register_action_ws.js +23 -2
  12. package/dist/actions/register_ws_endpoint.d.ts +12 -10
  13. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  14. package/dist/actions/register_ws_endpoint.js +5 -5
  15. package/dist/actions/transports_ws_auth_guard.d.ts +25 -10
  16. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  17. package/dist/actions/transports_ws_auth_guard.js +24 -9
  18. package/dist/actions/ws_endpoint_spec.d.ts +119 -0
  19. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -0
  20. package/dist/actions/ws_endpoint_spec.js +13 -0
  21. package/dist/auth/CLAUDE.md +592 -1808
  22. package/dist/auth/account_action_specs.d.ts +1 -1
  23. package/dist/auth/account_actions.d.ts +13 -0
  24. package/dist/auth/account_actions.d.ts.map +1 -1
  25. package/dist/auth/account_actions.js +31 -1
  26. package/dist/auth/account_routes.d.ts +12 -2
  27. package/dist/auth/account_routes.d.ts.map +1 -1
  28. package/dist/auth/account_routes.js +55 -8
  29. package/dist/auth/account_schema.d.ts +4 -4
  30. package/dist/auth/account_schema.d.ts.map +1 -1
  31. package/dist/auth/admin_action_specs.d.ts +8 -8
  32. package/dist/auth/admin_actions.d.ts +11 -0
  33. package/dist/auth/admin_actions.d.ts.map +1 -1
  34. package/dist/auth/admin_actions.js +25 -0
  35. package/dist/auth/api_token_queries.js +1 -1
  36. package/dist/auth/audit_emitter.d.ts +56 -12
  37. package/dist/auth/audit_emitter.d.ts.map +1 -1
  38. package/dist/auth/audit_emitter.js +38 -12
  39. package/dist/auth/audit_log_ddl.d.ts +1 -1
  40. package/dist/auth/audit_log_ddl.d.ts.map +1 -1
  41. package/dist/auth/audit_log_ddl.js +1 -1
  42. package/dist/auth/audit_log_schema.d.ts +5 -3
  43. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  44. package/dist/auth/audit_log_schema.js +5 -3
  45. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  46. package/dist/auth/bootstrap_account.js +1 -5
  47. package/dist/auth/bootstrap_routes.d.ts +8 -2
  48. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  49. package/dist/auth/bootstrap_routes.js +15 -11
  50. package/dist/auth/invite_schema.d.ts +2 -2
  51. package/dist/auth/keyring.d.ts +6 -6
  52. package/dist/auth/keyring.js +8 -8
  53. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -1
  54. package/dist/auth/role_grant_offer_actions.js +4 -2
  55. package/dist/auth/signup_routes.d.ts +1 -1
  56. package/dist/auth/standard_rpc_actions.d.ts +1 -0
  57. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  58. package/dist/auth/standard_rpc_actions.js +1 -0
  59. package/dist/db/create_db.d.ts.map +1 -1
  60. package/dist/db/create_db.js +13 -0
  61. package/dist/dev/setup.d.ts +2 -2
  62. package/dist/dev/setup.js +3 -3
  63. package/dist/http/CLAUDE.md +225 -483
  64. package/dist/http/error_schemas.d.ts +0 -4
  65. package/dist/http/error_schemas.d.ts.map +1 -1
  66. package/dist/http/error_schemas.js +0 -4
  67. package/dist/http/ip_canonical.d.ts +100 -0
  68. package/dist/http/ip_canonical.d.ts.map +1 -0
  69. package/dist/http/ip_canonical.js +195 -0
  70. package/dist/http/origin.d.ts +14 -6
  71. package/dist/http/origin.d.ts.map +1 -1
  72. package/dist/http/origin.js +14 -32
  73. package/dist/http/pending_effects.d.ts +1 -1
  74. package/dist/http/pending_effects.js +1 -1
  75. package/dist/http/proxy.d.ts +13 -5
  76. package/dist/http/proxy.d.ts.map +1 -1
  77. package/dist/http/proxy.js +15 -23
  78. package/dist/http/surface.d.ts +50 -0
  79. package/dist/http/surface.d.ts.map +1 -1
  80. package/dist/http/surface.js +27 -1
  81. package/dist/primitive_schemas.d.ts +20 -4
  82. package/dist/primitive_schemas.d.ts.map +1 -1
  83. package/dist/primitive_schemas.js +25 -4
  84. package/dist/realtime/sse_auth_guard.d.ts +16 -4
  85. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  86. package/dist/realtime/sse_auth_guard.js +15 -3
  87. package/dist/runtime/mock.js +1 -1
  88. package/dist/server/app_backend.d.ts +66 -19
  89. package/dist/server/app_backend.d.ts.map +1 -1
  90. package/dist/server/app_backend.js +57 -34
  91. package/dist/server/app_server.d.ts +101 -10
  92. package/dist/server/app_server.d.ts.map +1 -1
  93. package/dist/server/app_server.js +105 -6
  94. package/dist/server/env.d.ts +7 -7
  95. package/dist/server/env.d.ts.map +1 -1
  96. package/dist/server/env.js +14 -14
  97. package/dist/server/startup.d.ts.map +1 -1
  98. package/dist/server/startup.js +12 -0
  99. package/dist/server/static.d.ts +4 -4
  100. package/dist/server/static.js +7 -7
  101. package/dist/testing/CLAUDE.md +269 -59
  102. package/dist/testing/admin_integration.d.ts +18 -23
  103. package/dist/testing/admin_integration.d.ts.map +1 -1
  104. package/dist/testing/admin_integration.js +159 -202
  105. package/dist/testing/adversarial_headers.d.ts +6 -0
  106. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  107. package/dist/testing/adversarial_headers.js +13 -5
  108. package/dist/testing/app_server.d.ts +148 -60
  109. package/dist/testing/app_server.d.ts.map +1 -1
  110. package/dist/testing/app_server.js +143 -54
  111. package/dist/testing/attack_surface.d.ts +8 -7
  112. package/dist/testing/attack_surface.d.ts.map +1 -1
  113. package/dist/testing/attack_surface.js +12 -8
  114. package/dist/testing/audit_completeness.d.ts +23 -22
  115. package/dist/testing/audit_completeness.d.ts.map +1 -1
  116. package/dist/testing/audit_completeness.js +199 -158
  117. package/dist/testing/audit_drift_guard.d.ts +116 -0
  118. package/dist/testing/audit_drift_guard.d.ts.map +1 -0
  119. package/dist/testing/audit_drift_guard.js +134 -0
  120. package/dist/testing/bootstrap_success.d.ts +28 -0
  121. package/dist/testing/bootstrap_success.d.ts.map +1 -0
  122. package/dist/testing/bootstrap_success.js +144 -0
  123. package/dist/testing/connection_closer_helpers.d.ts +44 -0
  124. package/dist/testing/connection_closer_helpers.d.ts.map +1 -0
  125. package/dist/testing/connection_closer_helpers.js +48 -0
  126. package/dist/testing/cross_backend/capabilities.d.ts +64 -0
  127. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -0
  128. package/dist/testing/cross_backend/capabilities.js +47 -0
  129. package/dist/testing/cross_backend/setup.d.ts +215 -0
  130. package/dist/testing/cross_backend/setup.d.ts.map +1 -0
  131. package/dist/testing/cross_backend/setup.js +101 -0
  132. package/dist/testing/data_exposure.d.ts +14 -15
  133. package/dist/testing/data_exposure.d.ts.map +1 -1
  134. package/dist/testing/data_exposure.js +127 -146
  135. package/dist/testing/db_entities.d.ts +11 -1
  136. package/dist/testing/db_entities.d.ts.map +1 -1
  137. package/dist/testing/db_entities.js +13 -1
  138. package/dist/testing/integration.d.ts +35 -21
  139. package/dist/testing/integration.d.ts.map +1 -1
  140. package/dist/testing/integration.js +231 -293
  141. package/dist/testing/integration_helpers.d.ts +16 -6
  142. package/dist/testing/integration_helpers.d.ts.map +1 -1
  143. package/dist/testing/integration_helpers.js +7 -7
  144. package/dist/testing/mock_fs.d.ts.map +1 -1
  145. package/dist/testing/mock_fs.js +0 -2
  146. package/dist/testing/rate_limiting.d.ts.map +1 -1
  147. package/dist/testing/rate_limiting.js +13 -4
  148. package/dist/testing/role_grant_helpers.d.ts +31 -0
  149. package/dist/testing/role_grant_helpers.d.ts.map +1 -0
  150. package/dist/testing/role_grant_helpers.js +46 -0
  151. package/dist/testing/round_trip.d.ts +21 -16
  152. package/dist/testing/round_trip.d.ts.map +1 -1
  153. package/dist/testing/round_trip.js +65 -86
  154. package/dist/testing/rpc_helpers.d.ts +2 -1
  155. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  156. package/dist/testing/rpc_round_trip.d.ts +24 -21
  157. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  158. package/dist/testing/rpc_round_trip.js +91 -106
  159. package/dist/testing/schema_introspect.d.ts +106 -0
  160. package/dist/testing/schema_introspect.d.ts.map +1 -0
  161. package/dist/testing/schema_introspect.js +123 -0
  162. package/dist/testing/schema_parity.d.ts +144 -0
  163. package/dist/testing/schema_parity.d.ts.map +1 -0
  164. package/dist/testing/schema_parity.js +233 -0
  165. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  166. package/dist/testing/sse_round_trip.js +12 -6
  167. package/dist/testing/standard.d.ts +57 -25
  168. package/dist/testing/standard.d.ts.map +1 -1
  169. package/dist/testing/standard.js +62 -5
  170. package/dist/testing/stubs.d.ts +22 -3
  171. package/dist/testing/stubs.d.ts.map +1 -1
  172. package/dist/testing/stubs.js +28 -21
  173. package/dist/testing/surface_invariants.d.ts +66 -1
  174. package/dist/testing/surface_invariants.d.ts.map +1 -1
  175. package/dist/testing/surface_invariants.js +103 -1
  176. package/dist/testing/transports/surface_source.d.ts +51 -0
  177. package/dist/testing/transports/surface_source.d.ts.map +1 -0
  178. package/dist/testing/transports/surface_source.js +19 -0
  179. package/dist/ui/SurfaceExplorer.svelte +161 -2
  180. package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
  181. package/package.json +4 -4
@@ -1,1830 +1,614 @@
1
1
  # auth/
2
2
 
3
- > Auth domain: identity, crypto primitives, schema + DDL, queries, middleware, routes, RPC actions, cleanup.
4
-
5
- Grouped below by theme. For design rationale and threat model, see
6
- ../../../docs/identity.md and ../../../docs/security.md. For the
7
- subsystem's place in server assembly and middleware ordering, see
8
- ../../../docs/architecture.md and the root ../../../CLAUDE.md. For
9
- the workspace-wide DI vocabulary (capabilities / options / runtime
10
- state), see Skill(fuz-stack) dependency-injection.
11
-
12
- Auth-specific instances: stateless capabilities in `AppDeps` /
13
- `RouteFactoryDeps`; static config in `*Options`; runtime state
14
- (`DaemonTokenState`, mutable `AppSettings` ref, `BootstrapStatus`) is
15
- inline, never in `deps`. All `query_*` functions take
16
- `deps: QueryDeps = {db}` as their first arg.
17
-
18
- ## Crypto primitives
19
-
20
- Pure, I/O-free operations. Framework-dependent middleware lives in later
21
- sections.
22
-
23
- | Module | Exports |
24
- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult` |
26
- | `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `session_cookie_options`, `SESSION_AGE_MAX`, `SESSION_REFRESH_THRESHOLD_S`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
27
- | `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
28
- | `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
29
- | `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
30
- | `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
31
- | `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult` |
32
-
33
- Design notes:
34
-
35
- - **Keyring** encapsulates secrets — only `sign` / `verify` are exposed, keys
36
- never leave the closure. `__` separator splits multiple rotation keys;
37
- first key signs, all keys verify. Old keys remain valid for verification
38
- indefinitely — rotating `SECRET_COOKIE_KEYS` is a security-critical deploy.
39
- Minimum key length is 32 chars.
40
- - **Session cookie** encodes `${identity}:${expires_at}` and HMAC-SHA256
41
- signs the concatenation. Expiration is embedded in the signed value (not
42
- only in the cookie `Max-Age`) for defense-in-depth. `TIdentity` is generic:
43
- `string` for session-id references (server-side sessions, per-session
44
- revocation), `number` for direct account-id references (no server state).
45
- The canonical fuz pattern is `SessionOptions<string>` via
46
- `create_session_config(name)`. `SessionOptions.max_age` is the single
47
- source of truth for cookie lifetime — drives both the signed `expires_at`
48
- and the HTTP `Max-Age` attribute. `process_session_cookie` re-signs on
49
- key rotation **or** when within `refresh_threshold_seconds` (default
50
- `SESSION_REFRESH_THRESHOLD_S` = 1 day) of expiry, mirroring the DB-side
51
- `AUTH_SESSION_EXTEND_THRESHOLD_MS` so a continuously-active user's
52
- cookie tracks their server session.
53
- - **Password** has two schemas deliberately. `Password` enforces the current
54
- length policy (used at account creation and password change);
55
- `PasswordProvided` is minimal (`min(1)`) for login / verification so a
56
- tightened policy does not lock out existing accounts. Both carry
57
- `sensitivity: 'secret'` meta.
58
- - **Argon2id** uses OWASP parameters (`memoryCost: 19456`, `timeCost: 2`,
59
- `parallelism: 1`) via `@node-rs/argon2`. `verify_dummy` returns `false` but
60
- takes the same time as a real verification — call on account-lookup miss
61
- to equalize timing. The dummy hash is memoized.
62
- - **API token** format is `secret_fuz_token_<base64url>`. Prefix enables
63
- secret scanning (GitHub, TruffleHog, etc.); public `id` is `tok_<12 chars>`;
64
- storage key is the blake3 hash. Raw token is returned exactly once.
65
- - **Daemon token** is a 43-char base64url (256 bits). Validation is
66
- timing-safe and accepts both `current_token` and `previous_token` during
67
- the rotation race window. Pure primitives only rotation lifecycle lives
68
- in `daemon_token_middleware.ts`.
69
- - **Bootstrap account** is one-shot; protected by the `bootstrap_lock` table
70
- via atomic `UPDATE ... WHERE id = 1 AND bootstrapped = false RETURNING id`.
71
- Token read + password hash happen outside the transaction (CPU + I/O);
72
- lock acquisition + account + actor + two role_grants (`keeper` and `admin`)
73
- happen inside. On commit, the token file is deleted — if that fails,
74
- `token_file_deleted: false` is returned and the caller is expected to
75
- surface an error (the `/bootstrap` handler throws so the operator gets a
76
- loud signal). Provided tokens are **not** trimmed only `expected_token`
77
- is (tokens must match on disk exactly).
78
-
79
- ## Schemas, types, and DDL
80
-
81
- Convention `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL constants and
82
- index strings. Mixed modules split into a `_schema` + `_ddl` pair.
83
-
84
- | Module | What's inside |
85
- | ----------------------------------- | ----------------------------------------------------------------------------------------- |
86
- | `account_schema.ts` | Runtime types + client-safe Zod schemas for identity entities |
87
- | `role_schema.ts` | Role vocabulary and extensibility |
88
- | `auth_ddl.ts` | Raw `CREATE TABLE` / index / seed SQL strings for the core identity tables |
89
- | `invite_schema.ts` | `Invite`, `InviteJson`, `InviteWithUsernamesJson`, `CreateInviteInput` |
90
- | `app_settings_schema.ts` | `AppSettings`, `AppSettingsJson`, `AppSettingsWithUsernameJson`, `UpdateAppSettingsInput` |
91
- | `audit_log_schema.ts` | Event-type enum, per-type metadata schemas, client-safe Zod |
92
- | `audit_log_ddl.ts` | `audit_log` table DDL + index strings |
93
- | `role_grant_offer_schema.ts` | Role grant offer types and client-safe Zod |
94
- | `role_grant_offer_ddl.ts` | `role_grant_offer` table DDL, indexes, and the index-side sentinel constants |
95
- | `role_grant_offer_notifications.ts` | WS notification specs for the consentful-role-grant lifecycle |
96
-
97
- ### Identity entities (`account_schema.ts`)
98
-
99
- - `Account` (primary identity, holds `password_hash`), `Actor` (the entity
100
- that acts — owns cells, holds role_grants, appears in audit trails; an account
101
- may host one or more actors, with the dispatcher's authorization phase
102
- resolving the acting actor per-request via `acting?: ActingActor` on
103
- inputs), `RoleGrant` (time-bounded, revocable grant of a role to an
104
- actor carries `scope_kind` + `scope_id` paired-null,
105
- `source_offer_id`, `revoked_reason`),
106
- `AuthSession` (server-side, keyed by blake3), `ApiToken`.
107
- - Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and
108
- `*Input` types is branded `Uuid` (from `@fuzdev/fuz_util/uuid.js`), except
109
- `AuthSessionJson.id` (`Blake3Hash`) and `ClientApiTokenJson.id`
110
- (`ApiTokenId` — `tok_`-prefixed).
111
- - `Username`: `[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]` (3–39, GitHub parity).
112
- `UsernameProvided`: `min(1).max(255)` — permissive for login/lookup so
113
- tightening creation rules won't lock out existing users.
114
- - `Email`: `z.email()`.
115
- - `ROLE_GRANT_REVOKED_REASON_LENGTH_MAX = 500` bounds both the admin input
116
- and the `role_grant_revoke` WS payload.
117
- - Client-safe Zod schemas (every exported schema has a same-named `z.infer`
118
- type export):
119
- - `SessionAccountJson` strips sensitive fields from `Account`
120
- - `AuthSessionJson` `id` is the blake3 hash (safe for client)
121
- - `ClientApiTokenJson` excludes `token_hash`
122
- - `RoleGrantSummaryJson` — the client-safe role_grant shape carried by
123
- `GET /api/account/status` and the admin account listing; includes
124
- `scope_kind` + `scope_id` (paired-null) so clients can make
125
- per-scope auth decisions. Excludes
126
- `revoked_at` / `revoked_by` / `revoked_reason` because the callers
127
- that return it already filter to active role_grants.
128
- - `ActorSummaryJson`
129
- - `AdminAccountJson` extends `SessionAccountJson` with `updated_at` / `updated_by`
130
- - `PendingOfferSummaryJson` — narrower than `RoleGrantOfferJson`; omits
131
- `message` and `decline_reason` so cross-admin visibility of the listing
132
- does not expose grantor-authored text beyond what the audit log
133
- discloses. `from_username` is resolved server-side so admins can see
134
- whose pending offer is blocking a "+ role" button.
135
- - `AdminAccountEntryJson` composes `{account, actor, role_grants, pending_offers}`
136
- - Converters: `to_session_account(account)`, `to_admin_account(account)`,
137
- `is_role_grant_active(p, now?)`.
138
- - Input types: `CreateAccountInput`, `CreateRoleGrantInput` (with optional
139
- `scope_kind`, `scope_id`, `source_offer_id` `scope_kind` paired-null
140
- with `scope_id` per the `role_grant_scope_kind_paired` CHECK).
141
-
142
- ### Scope-kind system (`scope_kind_schema.ts`)
143
-
144
- Open string registry tagging the polymorphic `role_grant.scope_id` /
145
- `role_grant_offer.scope_id` with a machine-readable kind. Mirrors the open
146
- registry pattern used for `RoleName` / `AuditEventTypeName` /
147
- `CredentialType`.
148
-
149
- - `SCOPE_KIND_NAME_REGEX` / `ScopeKindName`: lowercase letters and
150
- underscores (`^[a-z][a-z_]*[a-z]$|^[a-z]$`), no leading/trailing
151
- underscore. Same shape as `RoleName`. Uppercase `'GLOBAL'` is
152
- structurally rejectedit appears only as an index-side token in
153
- `COALESCE(scope_kind, 'GLOBAL')` inside the partial unique indexes,
154
- never as a column value.
155
- - `ScopeKindMeta`: `{description?: string}` admin-UI-facing copy.
156
- Open shape so v2 can extend without breaking change.
157
- - `create_scope_kind_schema(consumer_kinds: Record<string, ScopeKindMeta>)`
158
- `{ScopeKind, scope_kinds: ReadonlyMap}`. No builtins. Construction-
159
- time guards: regex on every name, duplicate detection. Empty registry
160
- returns `z.never()` — every parse fails. Pass the result into
161
- `create_role_schema` to validate `RoleSpec.applicable_scope_kinds`
162
- entries (informative-only in v1; INSERT-time `(role, scope_kind)`
163
- enforcement reserved for v2).
164
- - Encoding: paired-null with `scope_id`. Both null = global, both
165
- non-null = scoped, mismatch rejected by the
166
- `role_grant_scope_kind_paired` / `role_grant_offer_scope_kind_paired` CHECK
167
- constraints.
168
-
169
- ### Credential-type system (`credential_type_schema.ts`)
170
-
171
- Open string registry over the credential types that can authenticate a
172
- request. Three builtins (`session`, `api_token`, `daemon_token`); the
173
- wire-validated `CredentialType` Zod enum in `hono_context.ts` mirrors
174
- those three. Mirrors the open-registry pattern used for `RoleName` /
175
- `ScopeKindName` / `GrantPathName` / `AuditEventTypeName`.
176
-
177
- - `CREDENTIAL_TYPE_NAME_REGEX` / `CredentialTypeName`: lowercase letters
178
- and underscores. Same shape as `RoleName`.
179
- - `CREDENTIAL_TYPE_SESSION` / `CREDENTIAL_TYPE_API_TOKEN` /
180
- `CREDENTIAL_TYPE_DAEMON_TOKEN` the three builtin literals. The
181
- constant is named `_API_TOKEN` (not `_BEARER`) so wire literal and
182
- the `api_token` storage table stay in lockstep.
183
- - `BUILTIN_CREDENTIAL_TYPES` const tuple, `BuiltinCredentialType` Zod
184
- enum, `builtin_credential_type_meta` admin-UI-facing descriptions.
185
- - `create_credential_type_schema(consumer_types?)`
186
- `{CredentialType, credential_types: ReadonlyMap}`. Builtins always
187
- present; consumer collisions / regex failures / duplicates throw at
188
- construction. Pass the result into `create_role_schema`'s optional
189
- `credential_types` parameter to validate every
190
- `RoleSpec.required_credential_types` entry at construction time.
191
-
192
- ### Grant-path system (`grant_path_schema.ts`)
193
-
194
- Open string registry over the surfaces through which a role can be
195
- granted. Four builtins (`admin`, `self_service`, `system`, `bootstrap`).
196
-
197
- - `GRANT_PATH_NAME_REGEX` / `GrantPathName`: lowercase letters and
198
- underscores, mirrors `RoleName`.
199
- - `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP` —
200
- builtin literal constants.
201
- - `BUILTIN_GRANT_PATHS` const tuple, `BuiltinGrantPath` Zod enum,
202
- `builtin_grant_path_meta` descriptions.
203
- - `create_grant_path_schema(consumer_paths?)`
204
- `{GrantPath, grant_paths: ReadonlyMap}`. Same construction-time
205
- guards as the credential-type schema. Pass the result into
206
- `create_role_schema`'s optional `grant_paths` parameter to validate
207
- every `RoleSpec.grant_paths` entry at construction time.
208
-
209
- Drives downstream defaults:
210
-
211
- - `admin_actions.grantable_roles` ⊇ `{role : 'admin' ∈ grant_paths}`.
212
- - `self_service_role_actions` default eligibility ⊇
213
- `{role : 'self_service' ∈ grant_paths}`.
214
-
215
- ### Role system (`role_schema.ts`)
216
-
217
- `RoleSpec` is the structured per-role configuration that replaced the
218
- flat `RoleOptions` shape (no `requires_daemon_token` / `web_grantable`
219
- booleans). Each role declares the credential types its holders must
220
- use, the scope kinds it applies to, and the grant paths through which
221
- it can be granted; the factory validates every cross-axis field
222
- against the corresponding open registries at construction time.
223
-
224
- - `RoleName`: lowercase letters + underscores, no leading/trailing
225
- underscore.
226
- - `ROLE_KEEPER = 'keeper'` — bootstrap-only via daemon token; `grant_paths: ['bootstrap']`,
227
- `required_credential_types: ['daemon_token']`.
228
- - `ROLE_ADMIN = 'admin'` — admin-grantable; `grant_paths: ['admin']`.
229
- - `BUILTIN_ROLES`, `BuiltinRole` (Zod enum), `builtin_role_specs_by_name`
230
- (`ReadonlyMap<string, RoleSpec>`) — not overridable by consumers.
231
- - `RoleSpec`: `{name, description?, required_credential_types?, applicable_scope_kinds?, grant_paths?}`
232
- — every cross-axis field is an open-registry string array. Empty
233
- arrays carry meaning (`grant_paths: []` ⇒ role unreachable through
234
- any registered path; `applicable_scope_kinds: []` ⇒ global only).
235
- - `create_role_schema(consumer_roles, options?)` — call once at startup;
236
- returns `{Role, role_specs}`. Construction-time guards: name regex,
237
- duplicate detection, builtin-collision rejection, registry-membership
238
- check on every `required_credential_types` / `applicable_scope_kinds` /
239
- `grant_paths` entry when the corresponding registry is supplied via
240
- `options.{credential_types, scope_kinds, grant_paths}`. Omitting a
241
- registry skips its membership check (incremental adoption hatch).
242
- - `role_has_grant_path(role_specs, role, path)` /
243
- `list_roles_with_grant_path(role_specs, path)` — predicate /
244
- filter helpers used by `admin_actions` and
245
- `self_service_role_actions` to derive their default eligibility.
246
-
247
- ### Raw DDL (`auth_ddl.ts`)
248
-
249
- Separated from runtime types to isolate DDL concerns. Consumed by
250
- `migrations.ts`:
251
-
252
- - `ACCOUNT_SCHEMA` (plus `ACCOUNT_EMAIL_INDEX`, `ACCOUNT_USERNAME_CI_INDEX`
253
- — both case-insensitive partial uniques)
254
- - `ACTOR_SCHEMA`, `ACTOR_INDEX`
255
- - `ROLE_GRANT_SCHEMA`, `ROLE_GRANT_INDEXES` — v0 has `role_grant_actor_role_active_unique`
256
- which is replaced in v1 with the scope-aware
257
- `role_grant_actor_role_scope_active_unique` keyed on
258
- `(actor_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel))`.
259
- v1 also adds `scope_kind TEXT NULL` (paired-null with `scope_id` via
260
- the `role_grant_scope_kind_paired` CHECK; idempotent DO-block guards
261
- re-runs).
262
- - `AUTH_SESSION_SCHEMA`, `AUTH_SESSION_INDEXES`
263
- - `API_TOKEN_SCHEMA`, `API_TOKEN_INDEX`
264
- - `BOOTSTRAP_LOCK_SCHEMA`, `BOOTSTRAP_LOCK_SEED` — seeded as `bootstrapped`
265
- iff accounts already exist (fresh install: false; restoring into a
266
- bootstrapped DB: true).
267
- - `INVITE_SCHEMA`, `INVITE_INDEXES` — three partial uniques covering
268
- email-unclaimed, username-unclaimed, plus a `claimed_at` index.
269
- - `APP_SETTINGS_SCHEMA`, `APP_SETTINGS_SEED` — single-row via
270
- `CHECK (id = 1)` constraint; seed is `ON CONFLICT DO NOTHING`.
271
-
272
- ### Audit log (`audit_log_schema.ts` + `audit_log_ddl.ts`)
273
-
274
- #### Audit event types
275
-
276
- `AUDIT_EVENT_TYPES` — 21 events covering auth + role_grant + offer + invite +
277
- settings mutations. Offer lifecycle: `role_grant_offer_create` / `_accept` /
278
- `_decline` / `_retract` / `_expire` / `_supersede`. `AuditEventType` is the
279
- Zod enum; `AuditOutcome` is `'success' | 'failure'`.
280
-
281
- | Event type |
282
- | ---------------------------- |
283
- | `login` |
284
- | `logout` |
285
- | `bootstrap` |
286
- | `signup` |
287
- | `password_change` |
288
- | `session_revoke` |
289
- | `session_revoke_all` |
290
- | `token_create` |
291
- | `token_revoke` |
292
- | `token_revoke_all` |
293
- | `role_grant_create` |
294
- | `role_grant_revoke` |
295
- | `role_grant_offer_create` |
296
- | `role_grant_offer_accept` |
297
- | `role_grant_offer_decline` |
298
- | `role_grant_offer_retract` |
299
- | `role_grant_offer_expire` |
300
- | `role_grant_offer_supersede` |
301
- | `invite_create` |
302
- | `invite_delete` |
303
- | `app_settings_update` |
304
-
305
- #### Metadata schemas
306
-
307
- - `audit_metadata_schemas` — per-type `z.looseObject`. Notable shapes:
308
- - `role_grant_create` — `scope_id`, optional `role_grant_id` (failed grants
309
- omit — admin-grant-path denial never produces a row), optional
310
- `source_offer_id`, optional `self_service` (set by
311
- `self_service_role_actions.ts`; declared on the schema rather than
312
- riding on `z.looseObject` so the field is part of the documented surface).
313
- - `role_grant_revoke` — `scope_id`, optional `reason`, optional
314
- `self_service` (same self-service toggle).
315
- - `role_grant_offer_create` — optional `offer_id` (failed creates omit).
316
- - `role_grant_offer_supersede` — `reason: 'sibling_accepted' | 'role_grant_revoked' | 'scope_destroyed'`
317
- plus `cause_id` (accepted offer id, revoked role_grant id, or destroyed
318
- parent scope row id respectively). The `scope_destroyed` variant is
319
- emitted by callers of `query_role_grant_revoke_for_scope` when a polymorphic
320
- parent scope row is deleted.
321
- - `AuditLogEvent` (row); `AuditLogInput<T extends string = AuditEventType>`
322
- (narrow metadata when `T` is builtin, generic record otherwise);
323
- `AuditLogListOptions` (supports `since_seq` for SSE reconnection gap fill);
324
- `AUDIT_LOG_DEFAULT_LIMIT = 50` (default page size, lives on the schema
325
- side so client codegen can import it without dragging in the query layer).
326
- `target_actor_id` lives parallel to `target_account_id` on both row
327
- and input. **Rule** — `target_actor_id` is populated when the event
328
- subject is bound to a specific actor. Concretely: `role_grant_revoke`
329
- and `role_grant_create` (admin direct-grant, self-service toggle, and
330
- in-tx accept all populate both target columns — the grantee is the
331
- subject regardless of initiator), in-tx `role_grant_offer_accept` on
332
- accept, and `role_grant_offer_decline` always populate both target
333
- columns (decline joins `from_account_id` into the RETURNING so the
334
- "both populated → same account" invariant holds uniformly).
335
- Offer-shape events (`role_grant_offer_create`, `_expire`, `_retract`,
336
- `_supersede`) populate `target_actor_id` when the offer was
337
- actor-targeted at create time (`role_grant_offer.to_actor_id` set),
338
- null when the offer was account-grain (any actor on
339
- `to_account_id` may accept). Account-shape events (login, logout,
340
- signup, bootstrap, password change, session/token revoke,
341
- app_settings update, invite events) stay account-grain on both
342
- `target_actor_id` **and** `actor_id` — the operation is performed
343
- by the account, and a multi-actor user must be able to log out
344
- (or change password, or revoke sessions) without first picking an
345
- acting actor. Role-grant/admin/offer events keep recording the
346
- initiator's actor in `actor_id`.
347
- SSE/WS socket-close keys on `target_account_id ?? account_id`
348
- (sessions stay account-grain at the routing layer even though
349
- they bind to a specific actor at request-context resolution time —
350
- see request_context.ts).
351
- - **Actor-targetable offers** — `role_grant_offer.to_actor_id` is the
352
- optional column that flips an offer from account-grain (null,
353
- default) to actor-grain (non-null). `query_role_grant_offer_create`
354
- validates the actor↔account binding in one SELECT and rejects with
355
- `RoleGrantOfferActorAccountMismatchError` when the supplied actor isn't
356
- on `to_account_id`. `query_accept_offer` rejects wrong-actor accepts
357
- on actor-targeted offers with `RoleGrantOfferActorMismatchError` —
358
- surfaced to RPC callers as `role_grant_offer_actor_mismatch`. Closes the
359
- audit hole where offer-shape events left `target_actor_id` null even
360
- when the recipient binding was known at offer time.
361
- - **`AuditEmitter.emit_role_grant_target` method** — the canonical entry
362
- point for role-grant-shape audit emissions. Takes
363
- `(ctx, auth, {event_type, target_account_id, target_actor_id, metadata, outcome?})`
364
- and lifts the `actor_id` / `account_id` / `ip` boilerplate that every
365
- `role_grant_*` audit emit site repeats. Use this instead of
366
- `deps.audit.emit` for any event populating one of the
367
- `target_*_id` columns; reach for the lower-level `emit` only when the
368
- event is non-role-grant-shape (e.g., `app_settings_update`, bootstrap,
369
- signup).
370
- - Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`,
371
- `RoleGrantHistoryEventJson`, `AdminSessionJson`.
372
- - `get_audit_metadata(event)` type-narrows after checking `event_type`.
373
- - DDL: `AUDIT_LOG_SCHEMA` (includes monotonically-increasing `seq SERIAL`
374
- for cursor-based gap fill), `AUDIT_LOG_INDEXES`.
375
- - **Consumer extensibility**: `create_audit_log_config({extra_events})`
376
- builds an `AuditLogConfig` merging builtins with consumer event-type
377
- strings keyed to a Zod schema (validates metadata) or `null` (registers
378
- without validation). Pass the result to `create_app_backend({audit_log_config})`
379
- — it gets captured inside the bound `AppDeps.audit` emitter, and every
380
- call to `audit.emit` validates against it (defaults to
381
- `builtin_audit_log_config` when absent). `query_audit_log` still accepts
382
- the trailing `config` positional arg for in-transaction emit sites that
383
- hold a transaction-scoped DB only. Builtin collisions and
384
- `AuditEventTypeName` format failures throw at construction. The DB
385
- column is `TEXT NOT NULL` (no enum), so consumer types round-trip
386
- through list queries, the `audit_log_list` RPC, and SSE identically to
387
- builtins.
388
- `AuditLogEvent.event_type` (row interface), `AuditLogEventJson.event_type`,
389
- and the `audit_log_list` filter input are all `AuditEventTypeName`
390
- (regex-validated string) — widened from the closed enum so consumer rows
391
- round-trip through DB queries, `on_audit_event` callbacks, and
392
- `spec.output.safeParse` identically to builtins. `AuditLogInput<T>` and
393
- `AuditMetadataMap` stay closed-enum on the write side — metadata-narrowing
394
- helpers like `get_audit_metadata` continue to require a builtin type guard.
395
- - **Drift counters**: `audit_metadata_validation_failures` (schema mismatch)
396
- and `audit_unknown_event_type_failures` (`event_type` not in active
397
- config). Both fail-open. Independent in implementation; under the
398
- factory they track the same config, but a hand-rolled `AuditLogConfig`
399
- (or a cast escape) can fire both on a single emission. Sample via
400
- `get_*` getters; `reset_*` are test-only. `AUDIT_EVENT_TYPES`,
401
- `audit_metadata_schemas`, `builtin_audit_log_config`, and the configs
402
- returned by `create_audit_log_config` are `Object.freeze`'d to convert
403
- accidental mutation (bugs, test cross-contamination, cast escapes)
404
- into loud TypeErrors — not a security boundary.
405
-
406
- ### Role grant offer (`role_grant_offer_schema.ts` + `role_grant_offer_ddl.ts`)
407
-
408
- The consentful-role-grants surface. Key constants:
409
-
410
- - `ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
411
- inside `COALESCE(scope_id, sentinel)` in partial unique indexes to collapse
412
- NULL scopes into a comparable value. Without this, Postgres's NULL-in-
413
- unique-index quirk would allow duplicate global pending offers.
414
- - `ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN = 'GLOBAL'` — index-side token
415
- for the global case in the partial unique indexes. Uppercase, so it
416
- cannot collide with consumer-declared `ScopeKindName` values
417
- (lowercase by regex). Never a column value — both null encodes
418
- global at the row level.
419
- - `ROLE_GRANT_OFFER_MESSAGE_LENGTH_MAX = 500`.
420
- - `ROLE_GRANT_OFFER_DEFAULT_TTL_MS` = 30 days (GitHub org-invite parity).
421
-
422
- DDL:
423
-
424
- - `ROLE_GRANT_OFFER_SCHEMA` carries four nullable terminal timestamps:
425
- `accepted_at`, `declined_at`, `retracted_at`, **`superseded_at`** (fourth
426
- terminal — obsoleted by sibling accept or revoke of the resulting role_grant).
427
- Four CHECK constraints:
428
- - `role_grant_offer_single_terminal` — at most one terminal timestamp set.
429
- - `role_grant_offer_role_grant_iff_accepted` — `(accepted_at IS NOT NULL) = (resulting_role_grant_id IS NOT NULL)`.
430
- - `role_grant_offer_reason_iff_declined` — `decline_reason` only on declined rows.
431
- - `role_grant_offer_scope_kind_paired` — `(scope_kind IS NULL) = (scope_id IS NULL)`
432
- (both null = global, both non-null = scoped, mismatch rejected).
433
- - `ROLE_GRANT_OFFER_PENDING_UNIQUE_INDEX` — partial unique on
434
- `(to_account_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel), from_actor_id)`
435
- where all four terminal timestamps are null. Including `from_actor_id`
436
- lets multiple grantors coexist (teacher A and B can both offer the same
437
- student role). A same-grantor re-offer upserts the pending row. The
438
- `ON CONFLICT` target in `query_role_grant_offer_create` must match this
439
- expression literally; the paired-null CHECK keeps the two COALESCE
440
- expressions in lockstep so global rows collide identically whether the
441
- scope columns are written or omitted.
442
- - `ROLE_GRANT_OFFER_INBOX_INDEX` — `(to_account_id, expires_at)` partial on
443
- pending rows, soonest-expiry first.
444
-
445
- Types:
446
-
447
- - `RoleGrantOffer` (row), `SupersededOffer` (row + `from_account_id` joined
448
- via `actor` — carried so callers fan out `role_grant_offer_supersede`
449
- notifications without a second round trip).
450
- - `CreateRoleGrantOfferInput` (`expires_at` is required — query layer applies
451
- no default).
452
- - `RoleGrantOfferJson` (with `.meta({description})` on every field) paired
453
- with `to_role_grant_offer_json(offer)`.
454
-
455
- ### WS notifications (`role_grant_offer_notifications.ts`)
456
-
457
- Six `RemoteNotificationActionSpec`s fan notifications to affected sockets:
458
-
459
- | Method | Fires to | Payload |
460
- | ---------------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
461
- | `role_grant_offer_received` | Recipient | `{offer: RoleGrantOfferJson}` |
462
- | `role_grant_offer_retracted` | Recipient | `{offer: RoleGrantOfferJson}` |
463
- | `role_grant_offer_accepted` | Grantor | `{offer: RoleGrantOfferJson}` |
464
- | `role_grant_offer_declined` | Grantor | `{offer: RoleGrantOfferJson}` (decline reason on `offer.decline_reason`) |
465
- | `role_grant_offer_supersede` | Grantor (sibling / revoked-role_grant) | `{offer, reason: 'sibling_accepted' \| 'role_grant_revoked', cause_id}` |
466
- | `role_grant_revoke` | Revokee | `{role_grant_id, role, scope_id, reason?}` |
467
-
468
- Method constants: `ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
469
- `_RETRACTED_`, `_ACCEPTED_`, `_DECLINED_`, `_SUPERSEDE_`,
470
- `ROLE_GRANT_REVOKE_NOTIFICATION_METHOD`. Zod params schemas with inferred type
471
- exports: `RoleGrantOfferReceivedParams`, `_RetractedParams`, `_AcceptedParams`,
472
- `_DeclinedParams`, `_SupersedeParams`, `RoleGrantRevokeParams`. Notification
473
- builders: `build_role_grant_offer_received_notification(params)` etc.
474
-
475
- `role_grant_offer_notification_specs: Array<EventSpec>` — pass to
476
- `create_app_server`'s `event_specs` so the attack surface reflects them
477
- and DEV-mode `create_validated_broadcaster` catches payload drift.
478
-
479
- `NotificationSender` is the narrow structural capability:
480
- `send_to_account(account_id, message): number`. `BackendWebsocketTransport`
481
- structurally satisfies it (its signature accepts the broader
482
- `JsonrpcMessageFromServerToClient`, contravariantly compatible). Target
483
- account travels via the send argument, not the payload — `revoked_by` is
484
- deliberately not in the `role_grant_revoke` payload (the revokee doesn't need
485
- to learn the admin's identity).
486
-
487
- ## Queries
488
-
489
- All take `deps: QueryDeps = {db}` as their first arg (except
490
- `query_validate_api_token` which uses `ApiTokenQueryDeps` — adds `log`).
491
-
492
- ### `account_queries.ts`
493
-
494
- CRUD + listing:
495
-
496
- - `query_create_account`, `query_create_actor`, `query_create_account_with_actor`.
497
- - `query_account_by_id` / `_username` / `_email` — case-insensitive via
498
- `LOWER()` (relies on the `idx_account_email` / `idx_account_username_ci`
499
- indexes).
500
- - `query_account_by_username_or_email(deps, input)` — if `@` in input, tries
501
- email first; else username first. Single login field accepting either.
502
- - `query_update_account_password(deps, id, new_hash, updated_by, expected_hash) → boolean` —
503
- conditional UPDATE keyed on `password_hash = expected_hash`; closes the
504
- verify-write race where two concurrent password changes both verify
505
- against the pre-update hash (loaded by the auth phase outside the
506
- txn). Returns `false` when the racer already moved the row.
507
- - `query_delete_account` — cascades to actors, role_grants, sessions, tokens.
508
- - `query_account_has_any` — used by bootstrap for belt-and-suspenders check.
509
- - `query_actors_by_account` — list every actor on an account, ordered
510
- by `created_at`. Used by `resolve_acting_actor` to pick the unique
511
- actor on single-actor accounts or surface `actor_required` when the
512
- account has multiple actors.
513
- - `query_actor_by_id` — direct lookup by id; preferred when the caller
514
- already has an actor id in scope.
515
- - `query_admin_account_list(deps, options?)` — composes accounts + actors +
516
- active role_grants + pending inbound offers. Paged (`limit` defaults to
517
- `ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT`; pass `limit: null` for unbounded
518
- internal use). Two round-trips: 1 (account page) → 3 parallel scoped to
519
- `account_ids`. The role_grants and offers queries push the page bound
520
- through to the DB via `actor_id IN (SELECT id FROM actor WHERE
521
- account_id = ANY(...))` so `actor.id`s never round-trip back to the
522
- application. Pending offers exclude `message` on purpose (cross-admin
523
- visibility). Returns `Array<AdminAccountEntryJson>`, sorted by
524
- `created_at`.
525
-
526
- ### `actor_lookup_queries.ts`
527
-
528
- - `query_actors_by_ids(deps, ids) → Array<ActorLookupRow>` — batched
529
- `actor` ⨝ `account` INNER JOIN, returns
530
- `{id, username, display_name}` per resolved actor. Empty input
531
- fast-paths to `[]`; hard-deleted (or cascade-orphaned) rows silently
532
- drop. Row shape omits `account_id` — the join is control-plane, not
533
- wire-visible. Caller bounds `ids.length` (the action spec enforces
534
- `ACTOR_LOOKUP_IDS_MAX`); SQL does not.
535
-
536
- ### `actor_search_queries.ts`
537
-
538
- - `query_actor_search(deps, {query, scope_ids?, limit}) → Array<ActorLookupRow>` —
539
- case-insensitive LIKE-prefix on `actor.name`, backed by the
540
- `idx_actor_name_lower` functional index in `auth_ddl.ts`. Returns the
541
- same `{id, username, display_name}` row shape as `query_actors_by_ids`
542
- so the labels arc stays uniform. LIKE wildcards (`%`, `_`, `\`) in
543
- the user-supplied `query` are escaped before substitution so the
544
- prefix-only contract is enforceable. When `scope_ids` is non-empty,
545
- the result is filtered to actors holding an **active** role_grant
546
- (`revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())`)
547
- on one of the supplied scopes; `DISTINCT` collapses multi-grant
548
- duplicates. When `scope_ids` is empty, no role_grant join — the handler
549
- enforces admin for that path.
550
-
551
- ### `role_grant_queries.ts`
552
-
553
- - `query_create_role_grant` — idempotent; `ON CONFLICT` target and fallback
554
- `SELECT` both use `COALESCE(scope_id, sentinel)`. The fallback `SELECT`
555
- uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict
556
- case).
557
- - `query_role_grant_find_active_role_for_actor(deps, role_grant_id, actor_id)` —
558
- actor-scoped read, so IDOR protection is consistent with revoke.
559
- Returns `{role, account_id}` (the actor's `account_id` joined in) or
560
- `null`. The `account_id` flows into the audit envelope's
561
- `target_account_id` and the SSE/WS socket-close fan-out target —
562
- collapsing what used to be a second `query_actor_by_id` round-trip in
563
- the revoke handler into one read closes the small TOCTOU window
564
- where the actor row could be deleted between the IDOR check and the
565
- actor lookup.
566
- - **`query_revoke_role_grant(deps, role_grant_id, actor_id, revoked_by, reason?)`** —
567
- actor-scoped IDOR guard (returns `null` if the role_grant belongs to a
568
- different actor). Supersedes pending offers for the revoked role_grant's
569
- `(to_account, role, scope)` in the **same transaction** via a CTE that
570
- joins `actor` to surface each sibling's `from_account_id`. Returns
571
- `RevokeRoleGrantResult = {id, role, scope_id, superseded_offers}`. Closes the
572
- "accept a pre-revoke offer to bypass the revoke" path — the stale offer
573
- becomes terminal at revoke time.
574
- - `query_role_grant_find_active_for_actor`, `query_role_grant_list_for_actor`.
575
- - `query_role_grant_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
576
- handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
577
- callers keep semantics). Use only when checking an arbitrary `actor_id`
578
- that isn't the request actor (e.g., post-mutation verification, scripts,
579
- audit-time checks). For the request actor, prefer `has_scoped_role` /
580
- `has_any_scoped_role` on the in-memory `auth.role_grants` snapshot.
581
- - `query_account_has_global_role(deps, account_id, role)` — account-grain
582
- sibling: does any actor on `account_id` hold an active **global**
583
- (`scope_id IS NULL`) role_grant for `role`? For surfaces with
584
- `auth: actor: 'none'` that don't load `auth.role_grants` and can't use
585
- `has_scoped_role`. EXISTS over the `idx_role_grant_actor`-backed
586
- subquery, stops at the first match.
587
- - `query_role_grant_find_account_id_for_role(deps, role)` — joins
588
- role_grant → actor → account, returns first match. Used by daemon token
589
- middleware to resolve the keeper account.
590
- - `query_role_grant_revoke_role(deps, actor_id, role, ...)` — revokes every
591
- active role_grant for `(actor, role)` across all scopes and supersedes all
592
- matching pending offers. Returns `RevokeRoleResult = {revoked, superseded_offers}`.
593
- - **`query_role_grant_revoke_for_scope(deps, scope_id, revoked_by, reason?)`** —
594
- parent-scope cascade for polymorphic `scope_id` consumers. Revokes every
595
- active role_grant at `scope_id` (role-agnostic) and supersedes every pending
596
- offer at `scope_id` (tuple-matched and orphan, undifferentiated) in the
597
- caller's transaction. Returns `RevokeForScopeResult = {revoked, superseded_offers}`
598
- — `revoked` carries both `actor_id` (drives `target_actor_id` audit
599
- envelopes) and `account_id` (drives `target_account_id` for socket-close
600
- fan-out); `superseded_offers` carries `from_account_id`. Caller emits
601
- `role_grant_offer_supersede` audits with `reason: 'scope_destroyed'` and
602
- `cause_id: <destroyed scope row id>` per superseded offer (the cause is
603
- the scope deletion, not any individual role_grant revoke). Use from a
604
- consumer's parent-row delete handler when `role_grant.scope_id` /
605
- `role_grant_offer.scope_id` reference rows in a polymorphic table the
606
- consumer is about to drop.
607
-
608
- ### `role_grant_offer_queries.ts`
609
-
610
- Error classes (all extend `Error` with stable `.name` — never use
611
- `instanceof` against plain messages):
612
-
613
- - `RoleGrantOfferSelfTargetError` — grantor offered themselves. Enforced
614
- via a single SELECT on the grantor's `actor.account_id` in
615
- `query_role_grant_offer_create` (resolving from the grantor side keeps
616
- the check multi-actor-correct — the grantor → account binding stays
617
- 1:1 by definition of `actor`, while the recipient account may host
618
- many actors under multi-actor).
619
- - `RoleGrantOfferAlreadyTerminalError` — offer exists for the caller but is
620
- accepted / declined / retracted / superseded.
621
- - `RoleGrantOfferExpiredError` — pending but past `expires_at` (distinct from
622
- terminal; different user-facing story: "ask the grantor to re-send").
623
- - `RoleGrantOfferNotFoundError` — not found or belongs to a different recipient
624
- (standard 404-over-403 IDOR mask; callers never reveal which).
625
-
626
- Queries:
627
-
628
- - `query_role_grant_offer_create` — INSERT with upsert-on-pending keyed by
629
- `(to_account, role, scope, from_actor)`. Same-grantor re-offer refreshes
630
- `message` + `expires_at` only. A terminal-state row with the same tuple
631
- does not block a fresh INSERT.
632
- - `query_role_grant_offer_decline(deps, id, to_account_id, reason)` — IDOR
633
- guarded by `to_account_id`. `resolve_terminal_or_missing` helper
634
- distinguishes "not found / different recipient" from "already terminal".
635
- - `query_role_grant_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
636
- grantor actor.
637
- - `query_role_grant_offer_list(deps, to_account_id)` — pending + non-expired +
638
- non-superseded, soonest expiry first.
639
- - `query_role_grant_offer_history_for_account(deps, account_id, limit?, offset?)` —
640
- both directions (recipient or grantor), includes terminal rows, newest
641
- first.
642
- - `query_role_grant_offer_find_pending`.
643
- - `query_role_grant_offer_sweep_expired` — returns pending offers past
644
- `expires_at`; the caller emits `role_grant_offer_expire` audit events
645
- per-row (no tombstone — caller is responsible for idempotency).
646
- - **`query_accept_offer(deps, input)`** — atomic, must run inside a
647
- transaction. Row-locks with `SELECT ... FOR UPDATE` (concurrent callers
648
- block until commit / rollback, then branch idempotently). Inserts the
649
- role_grant with normal idempotency (`ON CONFLICT DO NOTHING`), stamps
650
- `accepted_at` + `resulting_role_grant_id` in one UPDATE (satisfying the
651
- `role_grant_offer_role_grant_iff_accepted` CHECK), supersedes sibling pending
652
- offers for `(to_account, role, scope)` via CTE joined to `actor` for
653
- grantor `account_id`, and emits `role_grant_offer_accept` + `role_grant_create`
654
- - one `role_grant_offer_supersede` per sibling. On race, returns the
655
- pre-existing role_grant with `created: false` and empty `superseded_offers`
656
- / `audit_events`. Error map: `RoleGrantOfferNotFoundError`,
657
- `RoleGrantOfferAlreadyTerminalError`, `RoleGrantOfferExpiredError`. Sibling
658
- supersede is what forecloses the "accept a pre-revoke sibling later to
659
- get the role back" path.
660
-
661
- ### `session_queries.ts`
662
-
663
- Server-side sessions, keyed by blake3 hash of the session token:
664
-
665
- - `AUTH_SESSION_LIFETIME_MS` (30 days), `AUTH_SESSION_EXTEND_THRESHOLD_MS` (1 day).
666
- - `hash_session_token`, `generate_session_token`.
667
- - `query_create_session(deps, token_hash, account_id, expires_at)`.
668
- - `query_session_get_valid` — implicit `expires_at > NOW()` filter.
669
- - `query_session_touch` — updates `last_seen_at`; extends `expires_at` only
670
- when less than `AUTH_SESSION_EXTEND_THRESHOLD_MS` remains (avoids a write
671
- on every request).
672
- - **`query_session_revoke_by_hash_unscoped`** — unscoped DELETE. The
673
- `_unscoped` suffix is the safety signal — there is no `account_id`
674
- constraint, so this is only safe from the authenticated session cookie
675
- path (logout). For user-facing revocation by ID, use
676
- `query_session_revoke_for_account`.
677
- - `query_session_revoke_for_account(deps, hash, account_id)` — IDOR guarded.
678
- - `query_session_revoke_all_for_account` — returns count.
679
- - `query_session_list_for_account`, `query_session_list_all_active` (admin).
680
- - `query_session_enforce_limit(deps, account_id, max_sessions)` — keeps
681
- newest N, evicts the rest. **Must run in a transaction** with the INSERT
682
- that created the new session. All callers satisfy this: `POST /login`
683
- via `transaction: true`; `account_token_create` RPC via the dispatcher's
684
- `side_effects: true` transaction path; `/bootstrap` / `/signup` via
685
- explicit `db.transaction` wrappers.
686
- - `query_session_cleanup_expired`.
687
- - `session_touch_fire_and_forget(deps, hash, pending_effects?, log)` —
688
- errors logged, never thrown.
689
-
690
- ### `api_token_queries.ts`
691
-
692
- - `ApiTokenQueryDeps = QueryDeps & {log}`.
693
- - `query_create_api_token` — caller provides `id`, `token_hash` (already
694
- computed via `api_token.ts`).
695
- - `query_validate_api_token(deps, raw_token, ip, pending_effects?)` — hashes,
696
- looks up, checks expiry, fires a fire-and-forget UPDATE for `last_used_at`
697
- / `last_used_ip` (errors logged via `deps.log`).
698
- - `query_revoke_all_api_tokens_for_account` (returns count),
699
- `query_revoke_api_token_for_account` (IDOR guarded).
700
- - `query_api_token_list_for_account` — columns enumerated explicitly to
701
- exclude `token_hash`. Must be kept in sync when `api_token` gains columns.
702
- - `query_api_token_enforce_limit` — same transaction-safety requirement as
703
- the session variant.
704
-
705
- ### `invite_queries.ts`
706
-
707
- - `query_create_invite` (requires at least one of `email` / `username` —
708
- enforced by `CHECK constraint invite_has_identifier`).
709
- - `query_invite_find_unclaimed_by_email`, `_by_username`.
710
- - `query_invite_find_unclaimed_match(deps, email, username)` — three scoping
711
- modes: email-only invite needs signup-email match; username-only invite
712
- needs signup-username match; both-field invite requires both to match.
713
- - **`query_invite_claim_unscoped`** — sets `claimed_by` + `claimed_at` only
714
- if still unclaimed. Return is a boolean for race-detection. The
715
- `_unscoped` suffix is the safety signal — the SQL only checks the row
716
- state, not whether the claiming account's email/username matches the
717
- invite. Production scoping is enforced upstream in `signup_routes.ts`
718
- via `query_invite_find_unclaimed_match`. Mirrors the
719
- `query_session_revoke_by_hash_unscoped` precedent — there is no scoped
720
- sibling because scoping is provided by a separate find query, not an
721
- alternate variant of this query.
722
- - `query_invite_list_all`, `query_invite_list_all_with_usernames` (joins to
723
- `actor` for `created_by_username` and `account` for `claimed_by_username`).
724
- - `query_invite_delete_unclaimed` — IDOR not a concern (admin-only surface),
725
- but rejects already-claimed invites.
726
-
727
- ### `app_settings_queries.ts`
728
-
729
- - `query_app_settings_load`, `query_app_settings_load_with_username`,
730
- `query_app_settings_update(deps, open_signup, actor_id)`.
731
- - All three throw `'app_settings row not found — migration may not have
732
- run'` if the seed somehow missed (defensive — migrations always seed).
733
-
734
- ### `audit_log_queries.ts`
735
-
736
- - `query_audit_log<T>(deps, input, config?)` — `config` defaults to
737
- `builtin_audit_log_config`. Membership check runs against
738
- `config.event_types`; metadata validation runs independently against
739
- `config.metadata_schemas[event_type]` when present. Mismatches and
740
- unknown types log + bump their counters (see schema section);
741
- never throws. Returns the inserted row via `RETURNING *`.
742
- - Drift counters live alongside in this module:
743
- `get_audit_metadata_validation_failures()` /
744
- `get_audit_unknown_event_type_failures()` (read);
745
- `reset_*` (test-only). In-process; reset on restart.
746
- - `query_audit_log_list(deps, options?)` — supports `event_type`,
747
- `event_type_in`, `account_id` (matches `account_id` OR
748
- `target_account_id`), `outcome`, `since_seq`, `limit`, `offset`.
749
- `target_actor_id` filtering is not yet exposed; will land alongside
750
- the admin-viewer's actor-grain forensics pass.
751
- - `query_audit_log_list_with_usernames` — joins twice to `account`
752
- (chains `target_account_id` for the `target_username` field).
753
- `target_actor_id` is on the row but not currently joined to actor
754
- for a name; the admin viewer will resolve via `actor_lookup` /
755
- `actor.name` when the actor-grain forensics pass lands.
756
- - `query_audit_log_list_role_grant_history` (filters to `role_grant_create` / `role_grant_revoke`).
757
- - `query_audit_log_cleanup_before`.
758
- - **Audit fan-out runs through `AppDeps.audit`** (the bound emitter built
759
- by `create_audit_emitter` at backend assembly — see §`audit_emitter.ts`).
760
- `audit.emit(ctx, input)` writes via the captured pool, so audit entries
761
- persist even when the request transaction rolls back. The emitter
762
- closes over `on_audit_event` + `audit_log_config` so handlers can never
763
- silently fall back to the builtin config or a stale callback. Write
764
- failures and listener-callback failures are logged separately. Pushes
765
- onto `ctx.pending_effects` for test flushing.
766
-
767
- ### `audit_emitter.ts`
768
-
769
- `AuditEmitter` is the bound capability that lives on `AppDeps.audit`,
770
- built once at `create_app_backend` time.
771
-
772
- Four methods:
773
-
774
- - `emit(ctx, input)` — fire-and-forget pool write. Pushes the in-flight
775
- promise onto `ctx.pending_effects`; errors logged, never thrown.
776
- Returns `void` (the promise handle is already on `pending_effects`).
777
- - `emit_role_grant_target(ctx, auth, input)` — wrapper that lifts
778
- `actor_id` / `account_id` / `ip` boilerplate. Use for any event
779
- populating one of the `target_*_id` columns; reach for `emit` only on
780
- non-role-grant-shape events (`app_settings_update`, bootstrap, signup).
781
- - `emit_pool(input)` — awaitable pool write for code paths without a
782
- `pending_effects` queue (cleanup sweeps, ad-hoc maintenance scripts).
783
- Same write-then-notify semantics as `emit`; errors logged + swallowed.
784
- - `notify(event)` — fan out an already-written audit row to the listener
785
- chain. Used by `query_accept_offer`'s in-transaction audit batch (see
786
- the role-grant-offer accept handler) — the row is already in the DB,
787
- this just walks the chain.
788
-
789
- Per-call `ctx` shape:
790
-
791
- - `emit` requires `{pending_effects: Array<Promise<void>>}` — the eager
792
- queue only. Both `RouteContext` and `ActionContext` satisfy this
793
- structurally; `audit.emit` pushes its in-flight pool-write promise
794
- onto the eager queue. See `http/CLAUDE.md` §Pending Effects for
795
- the eager / deferred split.
796
- - `emit_role_grant_target` adds `client_ip: string` (also on `ActionContext`;
797
- REST handlers pass `{pending_effects, client_ip: get_client_ip(c)}`).
798
-
799
- `on_event_chain` is the mutable subscriber list. `create_app_server`
800
- appends `audit_sse.on_audit_event` here when `audit_log_sse` is enabled,
801
- without rebuilding `AppDeps`.
802
-
803
- ### `migrations.ts`
804
-
805
- - `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `auth_migration_ns` (pre-composed), `reserved_migration_namespaces: ReadonlyArray<string>` (membership list `create_app_backend` rejects on; consumer-discoverable instead of probing the runtime throw).
806
- - `auth_migrations`:
807
- - **v0 `full_auth_schema`** — every table + index + seed for the v1
808
- identity system (account, actor, role_grant, auth_session, api_token,
809
- audit_log, bootstrap_lock, invite, app_settings). All
810
- `IF NOT EXISTS` — idempotent replay.
811
- - **v1 `role_grant_offer_and_scoped_role_grants`** — adds `role_grant_offer` table
812
- plus its two partial indexes; adds `role_grant.scope_id` /
813
- `role_grant.scope_kind` / `role_grant.source_offer_id` /
814
- `role_grant.revoked_reason`; installs the
815
- `role_grant_scope_kind_paired` CHECK (DO-block guarded for re-runs
816
- since Postgres has no `ADD CONSTRAINT IF NOT EXISTS` for CHECKs);
817
- drops `role_grant_actor_role_active_unique` (and the prior
818
- `role_grant_actor_role_scope_active_unique` if present) and installs the
819
- scope-kind-aware variant keyed on
820
- `(actor_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel))`.
821
- `role_grant_offer` is created with `scope_kind` already in the CREATE
822
- TABLE (its CHECK + index are inline, not ALTERed).
823
- - Forward-only (no down). Migrations are `{name, up}` objects; the name
824
- surfaces in error messages.
825
-
826
- #### Runner contract (`db/migrate.ts`)
827
-
828
- The `schema_version` table stores **one row per applied migration**, keyed
829
- by `(namespace, name)` with a monotonically-increasing per-namespace
830
- `sequence` and `applied_at`. `run_migrations` reads applied rows ordered
831
- by `sequence`, then enforces:
832
-
833
- 1. **Length check first.** If `applied.length > code.length`, throw
834
- `binary-older-than-db` listing the unknown names. Short-circuits
835
- before name verify so a binary-older case with a rename in the overlap
836
- doesn't fire `name-divergence-at-N` first and send the operator chasing
837
- a phantom source-revert.
838
- 2. **Name-prefix verify.** For each `i < applied.length`, assert
839
- `applied[i].name === code[i].name`; mismatch throws
840
- `name-divergence-at-N` with `at_index`.
841
- 3. **Run the pending tail** (`code[applied.length..]`) inside a single
842
- chain transaction; each `INSERT` uses `sequence = max(sequence) + 1`.
843
-
844
- **Schema is not stabilized yet — append-only is NOT the rule today.**
845
- While fuz_app is pre-stable, migration bodies, names, and positions can
846
- change freely between versions and consumers upgrading across a schema
847
- change are expected to drop and re-bootstrap their dev/test databases.
848
- **No consumer has a stable production DB at the time of writing** —
849
- vissiones, zap, mageguild, undying, and fuz_template are all dev-mode
850
- only. The pre-stable contract assumes this; once a consumer ships a
851
- production DB, the upgrade story changes shape (operator-side
852
- migrations, double-emit windows, etc.) and the schema-stability
853
- declaration becomes load-bearing. Bias toward editing existing
854
- migration entries rather than appending patch migrations until that
855
- declaration lands. Once the schema is declared stable, a hard
856
- append-only-after-publish rule will apply (with the cliff called out in
857
- that release's notes).
858
-
859
- `MigrationError` is the only error class thrown from `run_migrations` /
860
- `baseline`; branch on `.kind` (never on message text). Kinds:
861
- `binary-older-than-db`, `name-divergence-at-N`, `old-tracker-shape`,
862
- `migration-failed`, `baseline-name-not-in-code`,
863
- `baseline-name-out-of-order`, `baseline-namespace-already-populated`.
864
-
865
- `baseline(db, ns, names)` is the only sanctioned non-execution path —
866
- INSERTs tracker rows for a name-prefix of `ns.migrations` without running
867
- their `up` functions. Used to promote an existing schema (e.g. preserved
868
- through a tracker-shape upgrade) into the new tracker. Per-namespace
869
- populated guard lets multi-call cutover scripts resume after partial
870
- failure. `baseline()` does **not** verify the schema actually matches
871
- what the named migrations would have produced — pair with a
872
- schema-assertion script post-baseline.
873
-
874
- There is **no programmatic bypass on the main `run_migrations` path**.
875
- No `--force`, no `skip_verification`. If you need to deviate, reach for
876
- `baseline()` (named, narrow) or direct SQL on the tracker (operator
877
- explicitly states intent).
878
-
879
- #### Operator recipes (run with the service stopped — these bypass the advisory lock)
880
-
881
- **Rename a migration** (typo fix, etc.). This is a coordinated code+SQL
882
- change, not just SQL:
883
-
884
- 1. Stop the service. Disable auto-restart for the cutover window.
885
- 2. Run the SQL `UPDATE` first — old code on disk doesn't read `name`, so
886
- running this with the old build still deployed is harmless and the
887
- safer order.
888
- 3. Deploy the build with the renamed migration in the code array.
889
- 4. Start the service — boot's name-prefix verify passes.
890
-
891
- The bad order is "deploy code with new name, then SQL UPDATE" — boot
892
- fires `name-divergence-at-N` and refuses to start in between.
893
-
894
- ```sql
895
- UPDATE schema_version SET name = 'new_name'
896
- WHERE namespace = $ns AND name = 'old_name';
3
+ > Auth domain: identity, crypto, schema + DDL, queries, middleware, routes,
4
+ > RPC actions, cleanup.
5
+
6
+ For design rationale and threat model: ../../../docs/identity.md and
7
+ ../../../docs/security.md. For server assembly and middleware ordering:
8
+ ../../../docs/architecture.md and the root ../../../CLAUDE.md. For migration
9
+ runner contract + operator recipes: ../../../docs/migrations.md. For
10
+ workspace-wide DI vocabulary: Skill(fuz-stack) §Dependency Injection.
11
+
12
+ **CLAUDE.md is a map; TSDoc is the detail.** Per-symbol semantics
13
+ (parameters, error shapes, invariants, fire-and-forget contracts) live on
14
+ TSDoc next to the code. This file orients you across the ~60 modules and
15
+ documents the cross-cutting invariants that don't fit on any single symbol.
16
+
17
+ ## AppDeps split
18
+
19
+ | Bucket | Type | Lifetime |
20
+ | ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------- |
21
+ | **Capabilities** | `AppDeps` | Stateless, injectable per env: `stat`, `read_text_file`, `delete_file`, `keyring`, `password`, `db`, `log`, `audit` |
22
+ | **Route caps** | `RouteFactoryDeps` | `Omit<AppDeps, 'db'>` — handlers get `db` via `RouteContext` |
23
+ | **Action caps** | inline | Action factories take `Pick<RouteFactoryDeps, 'log' \| 'audit'>` (role-grant-offer adds `notification_sender?`) |
24
+ | **Parameters** | `*Options` | Static startup values, per-factory |
25
+ | **Runtime state** | inline ref | Mutable values: `bootstrap_status`, `app_settings` ref, `DaemonTokenState` — NOT in deps or options |
26
+
27
+ `audit: AuditEmitter` is the bound emitter built once at backend assembly by
28
+ the consumer's `audit_factory` callback over `create_audit_emitter`; closes
29
+ over the pool so rows persist when request transactions roll back. See root
30
+ ../../../CLAUDE.md §AppDeps Vocabulary for the workspace-wide split.
31
+
32
+ ## Module map
33
+
34
+ ### Crypto primitives (pure, I/O-free)
35
+
36
+ | Module | Exports |
37
+ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | `auth/keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring` |
39
+ | `auth/session_cookie.ts` | `SessionOptions<T>`, `parse_session`, `process_session_cookie`, `create_session_config`, `fuz_session_config`, `SESSION_AGE_MAX`, `SESSION_REFRESH_THRESHOLD_S` |
40
+ | `auth/password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
41
+ | `auth/password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
42
+ | `auth/api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
43
+ | `auth/daemon_token.ts` | `DaemonToken`, `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
44
+ | `auth/bootstrap_account.ts` | `bootstrap_account` (one-shot, `bootstrap_lock`-protected) |
45
+
46
+ Cross-cutting notes that don't live on any single symbol:
47
+
48
+ - **Password schemas are split deliberately.** `Password` (length min 12)
49
+ gates creation + change; `PasswordProvided` (length min 1) gates
50
+ login/verify so tightening creation rules doesn't lock out existing
51
+ accounts. Both carry `sensitivity: 'secret'` meta.
52
+ - **Argon2id parameters** track OWASP guidance (`memoryCost: 19456`,
53
+ `timeCost: 2`, `parallelism: 1`); `verify_dummy` equalizes timing on
54
+ account-lookup miss.
55
+ - **API token format** `secret_fuz_token_<base64url>` prefix enables
56
+ secret scanning (GitHub, TruffleHog); public `id` is `tok_<12 chars>`;
57
+ storage key is the blake3 hash. Raw token returned once.
58
+
59
+ ### Schemas, types, DDL
60
+
61
+ Convention `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
62
+
63
+ | Module | What's inside |
64
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `auth/account_schema.ts` | `Account`, `Actor`, `RoleGrant`, `AuthSession`, `ApiToken` + client-safe JSON shapes |
66
+ | `auth/role_schema.ts` | `RoleName`, `RoleSpec`, `ROLE_KEEPER`, `ROLE_ADMIN`, `create_role_schema`, `builtin_role_specs_by_name`, `role_has_grant_path`, `list_roles_with_grant_path` |
67
+ | `auth/scope_kind_schema.ts` | `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins) |
68
+ | `auth/credential_type_schema.ts` | `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema` |
69
+ | `auth/grant_path_schema.ts` | `GrantPathName`, `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP`, `create_grant_path_schema` |
70
+ | `auth/auth_ddl.ts` | `CREATE TABLE` / index / seed strings for the core identity tables |
71
+ | `auth/audit_log_schema.ts` | `AUDIT_EVENT_TYPES` (21 builtins), `AuditEventType` / `AuditEventTypeName`, `audit_metadata_schemas`, `AuditLogEvent`, `AuditLogInput`, `AuditLogConfig`, `create_audit_log_config` |
72
+ | `auth/audit_log_ddl.ts` | `audit_log` table DDL with `seq BIGSERIAL` for cursor-based gap fill (BIGSERIAL converges with the Rust spine; `create_db` registers a `pg.types` int8 parser so `seq` still reads as a JS number) |
73
+ | `auth/invite_schema.ts` | `Invite`, `CreateInviteInput` |
74
+ | `auth/app_settings_schema.ts` | `AppSettings`, `UpdateAppSettingsInput` (single-row via `CHECK (id = 1)`) |
75
+ | `auth/role_grant_offer_schema.ts` | `RoleGrantOffer`, `RoleGrantOfferJson`, `to_role_grant_offer_json`, scope-sentinel constants |
76
+ | `auth/role_grant_offer_ddl.ts` | `role_grant_offer` table + indexes + `ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID` / `_GLOBAL_TOKEN` |
77
+ | `auth/role_grant_offer_notifications.ts` | Six WS notification specs for the consentful-grant lifecycle |
78
+
79
+ ### Queries
80
+
81
+ All take `deps: QueryDeps = {db}` first; `query_validate_api_token` adds `log`.
82
+
83
+ | Module | Coverage |
84
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
85
+ | `auth/account_queries.ts` | Account CRUD, actor resolution, password update with verify-write race guard, paged `query_admin_account_list` |
86
+ | `auth/actor_lookup_queries.ts` | Batched `actor` `account` for the labels arc |
87
+ | `auth/actor_search_queries.ts` | Case-insensitive prefix search on `actor.name`, scope-filtered when not admin |
88
+ | `auth/role_grant_queries.ts` | Idempotent create, IDOR-guarded revoke (with in-tx supersede), scope-aware lookup, role/account predicates, `query_role_grant_revoke_for_scope` parent-scope cascade |
89
+ | `auth/role_grant_offer_queries.ts` | Offer create/decline/retract/list/history/sweep, atomic `query_accept_offer` with sibling supersede; error classes `RoleGrantOfferSelfTargetError` / `_AlreadyTerminalError` / `_ExpiredError` / `_NotFoundError` / `_ActorAccountMismatchError` / `_ActorMismatchError` |
90
+ | `auth/session_queries.ts` | Server-side sessions (blake3-hashed), `query_session_revoke_by_hash_unscoped` (logout only), `query_session_enforce_limit` (transaction-required) |
91
+ | `auth/api_token_queries.ts` | Token validation with fire-and-forget usage tracking, IDOR-guarded revoke, `query_api_token_enforce_limit` (transaction-required) |
92
+ | `auth/invite_queries.ts` | Invite create/find/claim/list/delete; `query_invite_claim_unscoped` (scoping enforced upstream by `_find_unclaimed_match`) |
93
+ | `auth/app_settings_queries.ts` | Load/update for the single-row settings table |
94
+ | `auth/audit_log_queries.ts` | `query_audit_log` (in-tx insert), `_list` / `_list_with_usernames` / `_list_role_grant_history` / `_cleanup_before`, drift counters (`get_audit_metadata_validation_failures` / `get_audit_unknown_event_type_failures`) |
95
+
96
+ `_unscoped` suffix on `query_session_revoke_by_hash_unscoped` and
97
+ `query_invite_claim_unscoped` is the safety signal: SQL only checks row state,
98
+ caller is responsible for scoping. Production scoping for invites is enforced
99
+ upstream in `auth/signup_routes.ts` via `query_invite_find_unclaimed_match`.
100
+
101
+ ### Audit emitter
102
+
103
+ `auth/audit_emitter.ts` defines the `AuditEmitter` capability that lives on
104
+ `AppDeps.audit`. Built once at backend assembly via the consumer's
105
+ `audit_factory` callback over `create_audit_emitter`; closes over the pool +
106
+ `on_audit_event` chain + optional `AuditLogConfig`. Four methods:
107
+
108
+ - `emit(ctx, input)` fire-and-forget pool write, pushes to `ctx.pending_effects`
109
+ - `emit_role_grant_target(ctx, auth, input)` — lifts `actor_id` / `account_id` / `ip` boilerplate for role-grant-shape events
110
+ - `emit_pool(input)` — awaitable pool write for code paths without `pending_effects` (cleanup sweeps)
111
+ - `notify(event)` — fan out an already-written row to listeners (used by in-tx audit batches like `query_accept_offer.audit_events`)
112
+
113
+ `on_event_chain` is the mutable subscriber list. `create_app_server` appends
114
+ the audit-log SSE listener and per-endpoint WS auth guards / logout closers
115
+ here so SSE + WS fan-out compose on top of the consumer's `on_audit_event`
116
+ callback without shallow-copying `AppDeps`.
117
+
118
+ **Drift counters** (`auth/audit_log_queries.ts`) — `audit_metadata_validation_failures`
119
+ and `audit_unknown_event_type_failures` are process-wide, fail-open
120
+ (write the row anyway). Independent in implementation; under the factory
121
+ they track the same config. Sample via `get_*`; `reset_*` are test-only.
122
+
123
+ ### Routes
124
+
125
+ | Module | Surface |
126
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `auth/account_routes.ts` | `POST /login` / `/logout` / `/password`, `GET /verify` (nginx `auth_request` shim), `GET /api/account/status`. Constants: `DEFAULT_MAX_SESSIONS = 5`, `DEFAULT_MAX_TOKENS = 10`, `DEFAULT_LOGIN_FAIL_FLOOR_MS = 250`, `DEFAULT_LOGIN_FAIL_JITTER_MS = 25` |
128
+ | `auth/bootstrap_routes.ts` | `POST /bootstrap` + `check_bootstrap_status`; `BootstrapStatus` runtime ref |
129
+ | `auth/signup_routes.ts` | `POST /signup` (open or invite-gated) |
130
+ | `auth/audit_log_routes.ts` | Optional `GET /audit/stream` (SSE) list/history are on the RPC surface |
131
+ | `auth/auth_guard_resolver.ts` | `fuz_auth_guard_resolver` injected into `apply_route_specs` so the framework stays auth-agnostic |
132
+
133
+ **`POST /login` timing floor.** Login 401s are floored to
134
+ `DEFAULT_LOGIN_FAIL_FLOOR_MS` (250ms) + uniform jitter (±25ms) via
135
+ `Promise.all(work, setTimeout)` so observed time is `max(work, delay)` and
136
+ found-wrong-password and not-found paths converge. 429 stays fast by design;
137
+ `verify_dummy` equalizes Argon2id timing on not-found.
138
+
139
+ **`POST /password` revokes everything.** Revokes all sessions + all API
140
+ tokens (force re-auth everywhere), then clears the session cookie. Declares
141
+ `credential_types: ['session']` (see ../../../docs/security.md
142
+ §Credential-channel gating).
143
+
144
+ REST-only post RPC migration: `/login`, `/logout`, `/password`, `/signup`,
145
+ `/bootstrap`, `/verify` (empty-body shim), optional `/audit/stream`.
146
+ Everything else listed under §RPC action surfaces.
147
+
148
+ ### Middleware
149
+
150
+ | Module | Role |
151
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
+ | `auth/middleware.ts` | `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token` |
153
+ | `auth/request_context.ts` | `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role`), guards (`require_auth`, `require_role`, `require_credential_types`), `refresh_role_grants` |
154
+ | `auth/session_middleware.ts` | `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap) |
155
+ | `auth/bearer_auth.ts` | Soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context) |
156
+ | `auth/daemon_token_middleware.ts` | `start_daemon_token_rotation` + `create_daemon_token_middleware` (atomic file write, fail-closed validation, keeper account resolution) |
157
+
158
+ See root ../../../CLAUDE.md §Middleware Ordering for canonical assembly
159
+ order. The auth-specific invariants are described below in §Cross-cutting
160
+ invariants.
161
+
162
+ ## Cross-cutting invariants
163
+
164
+ The things that span multiple files and don't fit on any one symbol's TSDoc.
165
+
166
+ ### Two-phase identity
167
+
168
+ **Authentication runs in middleware** (session / bearer / daemon token).
169
+ Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid credential.
170
+ Account-only — never loads actor or role_grants, never populates
171
+ `REQUEST_CONTEXT_KEY`.
172
+
173
+ **Authorization runs after input validation**, matching the dispatcher's
174
+ 401 400 403 phase order (see `http/CLAUDE.md` §Validation pipeline).
175
+ When the route's input declares `acting?: ActingActor` or its auth requires
176
+ role_grants, the authorization phase calls `resolve_acting_actor` over the
177
+ validated `acting` value and builds an actor-bound `RequestContext`.
178
+ Account-grain routes run with `RequestContext.actor: null`.
179
+
180
+ `apply_authorization_phase` is pure data returns `AuthorizationResult`
181
+ (`{ok: true, request_context: RequestContext | null} | {ok: false, status, body}`)
182
+ without touching the Hono context. Each transport binds the same failure to
183
+ its wire shape: REST `c.json(body, status)`; HTTP RPC + WS fold into a
184
+ JSON-RPC envelope where `error.message` is the reason string and
185
+ `error.data: {reason, ...rest}` flattens diagnostic fields. The 500 reasons
186
+ stay distinct: `no_actors_on_account` (signup invariant violation),
187
+ `account_vanished` (torn read after resolve).
188
+
189
+ **Production-middleware invariant.** No production middleware on the auth
190
+ path populates `REQUEST_CONTEXT_KEY`; it sets only `ACCOUNT_ID_KEY`,
191
+ `CREDENTIAL_TYPE_KEY`, and (for sessions / bearer) `AUTH_SESSION_TOKEN_HASH_KEY` /
192
+ `AUTH_API_TOKEN_ID_KEY`. Test harnesses pre-populate `REQUEST_CONTEXT_KEY` +
193
+ `TEST_CONTEXT_PRESET_KEY` to bypass DB-backed actor resolution; production
194
+ code that reads `REQUEST_CONTEXT_KEY` is reading test escape-hatch state.
195
+
196
+ ### Open-registry composition
197
+
198
+ Four open string registries — `RoleName`, `ScopeKindName`,
199
+ `CredentialTypeName`, `GrantPathName` share the same factory shape:
200
+ construction-time guards (name regex, duplicate detection, builtin-collision
201
+ rejection), `ReadonlyMap` output, pass into `create_role_schema` for
202
+ cross-axis validation.
203
+
204
+ Dependency flow:
205
+
206
+ ```
207
+ create_credential_type_schema()
208
+ create_scope_kind_schema() → create_role_schema({roles, options}) → role_specs
209
+ create_grant_path_schema()
897
210
  ```
898
211
 
899
- **Mark a single migration applied without running it** (extreme repair —
900
- prefer `baseline()` when promoting a whole prefix):
212
+ `role_specs` drives downstream defaults:
901
213
 
902
- ```sql
903
- INSERT INTO schema_version (namespace, name, sequence, applied_at)
904
- VALUES ($ns, $name,
905
- (SELECT COALESCE(MAX(sequence), -1) + 1
906
- FROM schema_version WHERE namespace = $ns),
907
- NOW());
908
- ```
214
+ - `admin_actions.grantable_roles` ⊇ `{role : 'admin' ∈ grant_paths}`
215
+ - `self_service_role_actions` default eligibility `{role : 'self_service' ∈ grant_paths}`
216
+
217
+ `AuditEventTypeName` is the fifth open registry but composes differently —
218
+ via `create_audit_log_config({extra_events})` into the bound emitter.
219
+
220
+ ### Audit `target_*_id` rules
221
+
222
+ The two target columns on `AuditLogEvent` populate by a single rule:
223
+ **`target_actor_id` is set when the event subject is bound to a specific
224
+ actor**; `target_account_id` is always populated when there's an account
225
+ subject. SSE/WS socket-close keys on `target_account_id ?? account_id`
226
+ (sessions stay account-grain at the routing layer even after multi-actor).
227
+
228
+ The full per-event-type table lives in `AuditLogEvent.target_actor_id`
229
+ TSDoc. The pattern that spans emit sites:
909
230
 
910
- **Reset a namespace** (drop tracker rows; idempotent migrations re-apply
911
- on next boot):
231
+ - **Role-grant-shape events** populate both targets (the grantee actor is
232
+ the subject regardless of initiator). Use `audit.emit_role_grant_target`
233
+ to lift the `actor_id` / `account_id` / `ip` boilerplate.
234
+ - **Offer-shape events** (`role_grant_offer_create` / `_expire` / `_retract` /
235
+ `_supersede`) populate `target_actor_id` only when the offer was
236
+ actor-targeted at create time (`role_grant_offer.to_actor_id` set).
237
+ - **Account-shape events** (login, logout, signup, bootstrap, password
238
+ change, session/token revoke, app_settings update, invite events) stay
239
+ account-grain on **both** `target_actor_id` and `actor_id` — the
240
+ operation is performed by the account, and a multi-actor user must be
241
+ able to log out without first picking an acting actor.
912
242
 
913
- ```sql
914
- DELETE FROM schema_version WHERE namespace = $ns;
243
+ ### Audit event extensibility
244
+
245
+ Consumers extend the closed `AUDIT_EVENT_TYPES` enum via
246
+ `create_audit_log_config({extra_events})` — Zod schema or `null` per type;
247
+ collisions with builtins or name-format failures throw at construction. The
248
+ DB column is `TEXT NOT NULL` (no enum), so consumer types round-trip through
249
+ list queries, the `audit_log_list` RPC, and SSE identically to builtins.
250
+
251
+ `AuditLogEvent.event_type` / `AuditLogEventJson.event_type` / the
252
+ `audit_log_list` filter input are all `AuditEventTypeName` (regex-validated
253
+ string) — widened from the closed enum so consumer rows round-trip. The
254
+ write side (`AuditLogInput<T>`, `AuditMetadataMap`) stays closed-enum so
255
+ metadata-narrowing helpers like `get_audit_metadata` keep their type guard.
256
+
257
+ ### `AUDIT_EVENT_TYPES` builtins
258
+
259
+ For quick reference; the source-of-truth list is the `Object.freeze`d
260
+ constant in `auth/audit_log_schema.ts`.
261
+
262
+ ```
263
+ login role_grant_create
264
+ logout role_grant_revoke
265
+ bootstrap role_grant_offer_create
266
+ signup role_grant_offer_accept
267
+ password_change role_grant_offer_decline
268
+ session_revoke role_grant_offer_retract
269
+ session_revoke_all role_grant_offer_expire
270
+ token_create role_grant_offer_supersede
271
+ token_revoke invite_create
272
+ token_revoke_all invite_delete
273
+ app_settings_update
915
274
  ```
916
275
 
917
- A `set_applied()` / `rename_applied()` helper was considered and
918
- rejected even one sanctioned bypass that doesn't name the operator's
919
- intent invites use as a regular tool. Direct SQL forces the operator to
920
- consciously violate the contract.
921
-
922
- ## Middleware
923
-
924
- See the root ../../../CLAUDE.md §Middleware Ordering for the canonical
925
- assembly order. Two-phase identity:
926
-
927
- - **Authentication** runs in middleware (session / bearer / daemon
928
- token). Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid
929
- credential. Account-only never loads actor or role_grants, never
930
- populates `REQUEST_CONTEXT_KEY`. **Production-middleware invariant**:
931
- no production middleware on the auth path (session / bearer / daemon
932
- token) populates `REQUEST_CONTEXT_KEY`; identity-related context vars
933
- it does set are `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, and (for
934
- sessions / bearer) `AUTH_SESSION_TOKEN_HASH_KEY` /
935
- `AUTH_API_TOKEN_ID_KEY`. Other middleware (proxy, app server,
936
- session-cookie parser) sets unrelated vars like `client_ip`,
937
- `pending_effects`, and the session-token slot keyed by
938
- `session_options.context_key` (default `auth_session_id`) those
939
- are out of scope for this invariant. Test harnesses pre-populate
940
- `REQUEST_CONTEXT_KEY` + `TEST_CONTEXT_PRESET_KEY` to bypass DB-backed
941
- actor resolution; production code that consults
942
- `REQUEST_CONTEXT_KEY` is reading test escape-hatch state, never live
943
- middleware output.
944
- - **Authorization** runs after input validation (matches the dispatcher's
945
- 401 400 403 order so unauthenticated callers don't leak
946
- `invalid_params` for methods with required input, and the authorization
947
- phase reads `acting` as a typed Zod field rather than the raw body).
948
- When the route's input declares `acting?: ActingActor` or its auth
949
- requires role_grants (`role` / `credential_types`), the authorization
950
- phase calls `resolve_acting_actor` over the validated `acting` value
951
- and builds the actor-bound `RequestContext`. Account-grain routes
952
- skip resolution and run with `RequestContext.actor: null`.
953
- `apply_authorization_phase` is pure datait takes
954
- `account_id: string | null` and returns `AuthorizationResult`
955
- (`{ok: true, request_context: RequestContext | null} | {ok: false, status, body}`)
956
- without touching the Hono context.
957
- Public actions and the unauthenticated-optional axis collapse to
958
- `request_context: null`; resolved actor / account-only contexts set it
959
- non-null. The REST wrapper (`create_fuz_authorization_handler`) sets
960
- `REQUEST_CONTEXT_KEY` when `request_context !== null` for downstream
961
- `require_role` / `require_credential_types`; the HTTP RPC and WS
962
- dispatchers consume the resolved context directly via `perform_action`.
963
- Resolution failures surface as `{ok: false, status, body}` — the auth
964
- domain stops short of constructing a `Response` so each transport
965
- binds the same failure to its wire shape: REST emits
966
- `c.json(body, status)`; the WS upgrade does the same; the RPC + WS
967
- dispatchers fold it into a JSON-RPC envelope inside `perform_action`
968
- (`{jsonrpc, id, error: {code, message, data}}`) with `error.message`
969
- carrying the reason string and `error.data: {reason, ...rest}`
970
- flattening any diagnostic fields (e.g. `available[]` for
971
- `actor_required`). The 500 reasons stay distinct in `body.error`:
972
- `no_actors_on_account` (signup invariant violation
973
- `resolve_acting_actor` enumerated zero actors); `account_vanished`
974
- (torn-read race `build_request_context` / `build_account_context`
975
- returned null after a successful resolve, meaning the account or
976
- actor row was deleted between credential validation and the
977
- follow-up read). The named per-error shape `AuthorizationFailureBody`
978
- is still exported for callers that want to bind the failure body
979
- by type. See the root ../../../CLAUDE.md §Cleanest architecture
980
- takes priority for the rationale.
981
-
982
- Session parsing is separate from auth enforcement login / bootstrap
983
- participate in cookie refresh without being blocked. `require_auth`,
984
- `require_role(roles)`, and `require_credential_types(types)` are the
985
- gates; the keeper case composes the credential-type gate with the role
986
- gate (no dedicated `require_keeper` helper see `request_context.ts`).
987
-
988
- ### `request_context.ts`
989
-
990
- - `RequestContext = {account, actor: Actor | null, role_grants}`. `actor`
991
- is null on account-grain routes (no `acting`, no role_grant-requiring
992
- auth); `role_grants` is empty in that case.
993
- - `REQUEST_CONTEXT_KEY`Hono context variable name.
994
- - **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on
995
- successful session lookup; `null` for unauthenticated or non-session
996
- credentials. Exposed so SSE endpoints can scope per-session resource
997
- identity (the audit-log SSE uses this to close only the revoked session's
998
- stream on `session_revoke`).
999
- - `get_request_context(c)`, `require_request_context(c)` (throws on
1000
- misuse handler ran without authorization phase wiring).
1001
- - **In-memory role_grant predicates** `has_role(ctx, role, now?)`,
1002
- `has_scoped_role(ctx, role, scope_id, now?)`,
1003
- `has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take
1004
- `RequestContext | null` and return `false` for null ctx and for
1005
- account-grain ctx (`actor: null`, empty `role_grants`); they drop into
1006
- public (`{account: 'none', actor: 'none'}`) and account-grain
1007
- (`{account: 'required', actor: 'none'}`) handlers without a manual
1008
- narrow.
1009
- `scope_id === null` matches global role_grants only; UUID matches that
1010
- exact scope. Empty `roles` short-circuits `has_any_scoped_role` to
1011
- `false`. Decide-time predicates only the predicate / mutation
1012
- race window is the same as the SQL `query_role_grant_has_role` style and
1013
- only a transactional re-check inside the UPDATE/INSERT closes it.
1014
- - `build_request_context(deps, account_id, actor_id)` loads
1015
- `account` + the named `actor` + active role_grants. Verifies
1016
- `actor.account_id === account.id`; returns `null` when the account
1017
- or actor is missing, or when they don't bind to each other. Called
1018
- by the authorization phase after `resolve_acting_actor` succeeds
1019
- a null return there is a torn read (account/actor deleted mid-request)
1020
- rather than the missing-actor invariant `resolve_acting_actor` would
1021
- have caught upstream, so the phase surfaces `ERROR_ACCOUNT_VANISHED`
1022
- on null. Not called from middleware.
1023
- - `resolve_acting_actor(deps, account_id, acting_actor_id)` — uniform
1024
- resolver. Resolves to `{ok: true, actor_id}` for 1 actor (any
1025
- `acting`) or matching supplied id; `actor_required` with the
1026
- available list when multi-actor and `acting` is missing;
1027
- `actor_not_on_account` when supplied id doesn't belong; `no_actors`
1028
- defensively.
1029
- - `refresh_role_grants(ctx, deps)` — reloads role_grants without mutating the
1030
- original (concurrent-safe). Useful for long-lived WebSocket
1031
- connections that have an acting actor.
1032
- - `create_request_context_middleware(deps, log, session_context_key?)` —
1033
- validates the session and sets `c.var.account_id` +
1034
- `CREDENTIAL_TYPE_KEY = 'session'` + `AUTH_SESSION_TOKEN_HASH_KEY`.
1035
- Touches the session fire-and-forget. Does not load actor / role_grants.
1036
- - `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) when
1037
- `account_id` is null. Does not require an acting actor.
1038
- - `require_role(roles: ReadonlyArray<string>)` — 401 on no auth, 403
1039
- (`ERROR_INSUFFICIENT_PERMISSIONS` + `required_roles: ReadonlyArray<string>`)
1040
- when role_grants don't carry any of `roles` at **global / unscoped**
1041
- scope. Implies the authorization phase ran (a role-gated route always
1042
- resolves an actor). Implemented via `has_any_scoped_role(ctx, roles, null)`
1043
- — a scoped role_grant (`{role: 'admin', scope_id: <uuid>}`) does **not**
1044
- unlock unscoped role gates. Single-role specs pass `[role_name]`;
1045
- multi-role specs pass `[r1, r2, ...]` for any-of disjunction. The
1046
- same scope-aware semantics are mirrored in the HTTP RPC dispatcher
1047
- (`actions/action_rpc.ts`), the WS dispatcher
1048
- (`actions/register_action_ws.ts`), and the admin bypasses inside
1049
- `role_grant_offer_actions.ts` so all four sites agree.
1050
- - `require_credential_types(types: ReadonlyArray<string>)` — 401 on no
1051
- auth, 403 (`ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types`
1052
- echoing the spec's allowlist — symmetric with the role gate's
1053
- `required_roles`) when `c.var.credential_type` is not in `types`.
1054
- Composed with `require_role` for keeper specs (credential gate runs
1055
- before role gate per `auth_guard_resolver.ts`). Replaces the deleted
1056
- `require_keeper` helper — keeper is now a composable shape:
1057
- `{roles: ['keeper'], credential_types: ['daemon_token']}`.
1058
-
1059
- ### `bearer_auth.ts`
1060
-
1061
- - `create_bearer_auth_middleware(deps, ip_rate_limiter, log)`.
1062
- - **Soft-fails** for invalid / expired / empty tokens — calls `next()`
1063
- without setting context. Lets downstream auth enforcement return a
1064
- consistent error and avoids leaking token-specific diagnostics. Only
1065
- 429 is a hard-fail.
1066
- - **Rejects bearer tokens when `Origin` or `Referer` is present** (both,
1067
- not just `Origin` — some browser requests omit `Origin`). Checked via
1068
- `!== undefined` so empty-string headers still count as browser context.
1069
- Discards rather than 403s so public actions remain reachable.
1070
- - Case-insensitive scheme matching per RFC 7235 §2.1.
1071
- - Rate limiter: `record` before async DB work to close the TOCTOU window;
1072
- `reset` on valid token.
1073
-
1074
- ### Keeper auth (no dedicated module)
1075
-
1076
- Keeper is a composable `RouteAuth` shape, not a dedicated guard:
1077
- `{account: 'required', actor: 'required', roles: ['keeper'],
1078
- credential_types: ['daemon_token']}`. The two-part check is
1079
- `require_credential_types(['daemon_token'])` (403
1080
- `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types: ['daemon_token']`)
1081
- followed by `require_role(['keeper'])` (403
1082
- `ERROR_INSUFFICIENT_PERMISSIONS`).
1083
-
1084
- ### `session_middleware.ts`
1085
-
1086
- - `get_session_cookie`, `set_session_cookie`, `clear_session_cookie`.
1087
- - `create_session_middleware(keyring, options)` — always sets the
1088
- identity on context (null when invalid/missing) for type-safe reads.
1089
- Acts on `process_session_cookie`'s `action` (`'clear'` / `'refresh'` /
1090
- `'none'`).
1091
- - `create_session_and_set_cookie({keyring, deps, c, account_id, session_options, max_sessions?})` —
1092
- shared by login, signup, and bootstrap: generates token, hashes,
1093
- persists `auth_session`, optionally enforces per-account cap, signs
1094
- the cookie.
1095
-
1096
- ### `daemon_token_middleware.ts`
1097
-
1098
- - `DEFAULT_ROTATION_INTERVAL_MS = 30_000`.
1099
- - `get_daemon_token_path(runtime, name)` → `~/.{name}/run/daemon_token`
1100
- or `null` if `$HOME` unset.
1101
- - `write_daemon_token(runtime, path, token)` — atomic (temp + rename);
1102
- `chmod 0600` if available.
1103
- - `resolve_keeper_account_id(deps)` — wraps `query_role_grant_find_account_id_for_role(ROLE_KEEPER)`.
1104
- - `start_daemon_token_rotation(runtime, deps, options, log)` — writes initial
1105
- token, resolves keeper, sets up interval. Returns `{state, stop}`. The
1106
- interval guard `writing` skips the next rotation if the prior write is
1107
- still in flight. `stop` clears the interval and removes the token file
1108
- (errors swallowed — already removed or never written).
1109
- - `create_daemon_token_middleware(state, deps)` — checks `X-Daemon-Token`:
1110
- - No header → pass through.
1111
- - Present + Zod-invalid → 401 `ERROR_INVALID_DAEMON_TOKEN`.
1112
- - Present + invalid value → 401 (fail-closed, no downgrade).
1113
- - Present + valid + no `keeper_account_id` → 503 `ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED`.
1114
- - Present + valid + keeper account missing → 500 `ERROR_KEEPER_ACCOUNT_NOT_FOUND`.
1115
- - Present + valid + ok → builds context from keeper account (overrides
1116
- any existing session / bearer context), sets `credential_type: 'daemon_token'`.
1117
-
1118
- ### `middleware.ts`
1119
-
1120
- - `create_auth_middleware_specs(deps, options)` — assembles the stack:
1121
- `[origin, session, request_context, bearer_auth]` plus an optional
1122
- `daemon_token` layer when `daemon_token_state` is passed. Returns
1123
- `Array<MiddlewareSpec>`. Dynamic imports keep heavy deps out of
1124
- consumers that only use types. `bearer_auth.errors: {429: RateLimitError}`
1125
- — bearer middleware only hard-fails on rate limit; `daemon_token.errors`
1126
- documents 401 / 500 / 503.
1127
-
1128
- ## Routes
1129
-
1130
- ### `account_routes.ts`
1131
-
1132
- Session-based auth route specs. Factory: `create_account_route_specs(deps, options)`.
1133
-
1134
- - `POST /login` — `UsernameProvided` + `PasswordProvided`. Two rate limiters:
1135
- per-IP and per-account (keyed by **canonical `account.id` after lookup**
1136
- — keying by submitted username would double the bucket when an attacker
1137
- alternates between username and email). **Login 401s are floored to
1138
- `DEFAULT_LOGIN_FAIL_FLOOR_MS` (250ms) + uniform jitter
1139
- `DEFAULT_LOGIN_FAIL_JITTER_MS` (±25ms)** via
1140
- `Promise.all(work, setTimeout)` — observed time is `max(work, delay)` so
1141
- found-wrong-password and not-found paths converge. 429 stays fast by
1142
- design. `verify_dummy` equalizes Argon2id timing on not-found.
1143
- - `POST /logout` — revokes session by hash, clears cookie.
1144
- - **`POST /password`** — `current_password: PasswordProvided` +
1145
- `new_password: Password`. Per-IP + per-account rate limited.
1146
- Declares `credential_types: ['session']` (see
1147
- `docs/security.md` §Credential-channel gating).
1148
- **Revokes all sessions + all API tokens** (force re-auth everywhere);
1149
- clears cookie.
1150
- - **`GET /verify`** — empty-body session-validity probe for nginx
1151
- `auth_request` subrequests. Status-code-only contract: 200 on valid
1152
- cookie, 401 otherwise. The auth middleware does the enforcement; the
1153
- handler is a one-line shim. Programmatic callers should use the
1154
- `account_verify` RPC action — that surface carries the typed
1155
- `SessionAccountJson` payload.
1156
- - `create_account_status_route_spec(options?)` — `GET /api/account/status`
1157
- returns `{account, actor, role_grants}` on 200 or 401 with optional
1158
- `bootstrap_available` flag. `actor` is the caller's own
1159
- `ActorSummaryJson` so clients don't need to derive `actor_id` from
1160
- the role_grant list. Lets the frontend fetch both session state
1161
- and bootstrap availability in one request (eliminates a separate `/health`
1162
- round trip).
1163
-
1164
- Session listing/revoke + revoke-all and API token CRUD live in
1165
- `account_actions.ts` (see `account_session_list` / `_revoke` /
1166
- `_revoke_all`, `account_token_create` / `_list` / `_revoke` below).
1167
- Each keeps its guards (IDOR via `query_session_revoke_for_account` /
1168
- `query_revoke_api_token_for_account`; `Blake3Hash` on session ids;
1169
- `ApiTokenId` regex on token ids; `max_tokens` enforcement via
1170
- `query_api_token_enforce_limit`).
1171
-
1172
- Constants:
1173
-
1174
- - `DEFAULT_MAX_SESSIONS = 5`, `DEFAULT_MAX_TOKENS = 10`.
1175
- - `DEFAULT_LOGIN_FAIL_FLOOR_MS = 250`, `DEFAULT_LOGIN_FAIL_JITTER_MS = 25`.
1176
- - `AuthSessionRouteOptions` — shared base (`session_options`,
1177
- `ip_rate_limiter`). Extended by `AccountRouteOptions` and
1178
- `SignupRouteOptions`.
1179
-
1180
- ### `bootstrap_routes.ts`
1181
-
1182
- - `BootstrapStatus = {available, token_path}` — runtime state (mutable ref).
1183
- - `check_bootstrap_status(deps, {token_path})` — returns `available: true`
1184
- iff the token path is configured, the file exists on disk, and
1185
- `bootstrap_lock.bootstrapped = false`.
1186
- - `create_bootstrap_route_specs(deps, options)` — `POST /bootstrap`. Short-
1187
- circuits on `!bootstrap_status.available`. `transaction: false` —
1188
- `bootstrap_account` manages its own. On success: flips
1189
- `bootstrap_status.available = false`, creates session, runs `on_bootstrap`
1190
- callback (for app-specific work like generating an API token), emits
1191
- audit event. **If token file deletion fails, throws** so the operator
1192
- gets a loud signal (all success side effects have already run).
1193
- - Rate limiter: per-IP only.
1194
- - Error shapes: 401 `ERROR_INVALID_TOKEN`, 403 `ERROR_ALREADY_BOOTSTRAPPED`,
1195
- 404 `ERROR_TOKEN_FILE_MISSING | ERROR_BOOTSTRAP_NOT_CONFIGURED`.
1196
-
1197
- ### `signup_routes.ts`
1198
-
1199
- - `SignupRouteOptions extends AuthSessionRouteOptions` with
1200
- `signup_account_rate_limiter` and a mutable `app_settings: AppSettings` ref.
1201
- - `POST /signup` — `transaction: false` (manages its own). When
1202
- `app_settings.open_signup` is false, requires a matching unclaimed invite.
1203
- On `open_signup: true` path, no invite check.
1204
- - Transaction body: `query_create_account_with_actor` → `query_invite_claim_unscoped`
1205
- (if invite present; throws `SignupConflictError` on race — another claim
1206
- won) → `create_session_and_set_cookie`. Catches
1207
- `is_pg_unique_violation(e)` → 409 `ERROR_SIGNUP_CONFLICT` (username or
1208
- email already exists).
1209
- - Error shapes: 403 `ERROR_NO_MATCHING_INVITE`, 409 `ERROR_SIGNUP_CONFLICT`.
1210
-
1211
- ### `auth_guard_resolver.ts`
1212
-
1213
- `fuz_auth_guard_resolver: AuthGuardResolver` — maps the four-axis
1214
- `RouteAuth` shape to two-phase middleware arrays. `pre_validation`
1215
- gets `require_auth` when `account === 'required'` or `actor === 'required'`;
1216
- `post_authorization` gets `require_credential_types(types)` when
1217
- `credential_types?.length` and `require_role(roles)` when `roles?.length`.
1218
- Injected into `apply_route_specs` so the generic HTTP framework stays
1219
- auth-agnostic (see `http/CLAUDE.md` §Validation pipeline for where it plugs in).
1220
-
1221
- ### `audit_log_routes.ts`
1222
-
1223
- Audit-log list + role_grant-history reads (plus admin session listing)
1224
- live on the RPC surface in `admin_actions.ts`. The REST surface this
1225
- module produces is now just the optional SSE stream:
1226
-
1227
- - **`GET /audit/stream`** — optional, wired only when
1228
- `AuditLogRouteOptions.stream` is passed. Streams aren't an RPC concern.
1229
- Uses `AUTH_SESSION_TOKEN_HASH_KEY` for SSE `scope` identity (so
1230
- `session_revoke` can close only that session's stream); `groups: [account_id]`
1231
- for coarse close on `role_grant_revoke` / `session_revoke_all` / `password_change`.
1232
-
1233
- `create_audit_log_route_specs(options?)` — returns an empty array when
1234
- `options.stream` is not set; `required_role` defaults to `'admin'`.
1235
-
1236
- ## RPC actions (SAES)
1237
-
1238
- Three action surfaces that mount on a consumer's JSON-RPC endpoint via
1239
- `create_rpc_endpoint` (see `actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint).
1240
- Each surface is split across two files:
1241
-
1242
- - `*_action_specs.ts` — Input/Output Zod schemas (paired with `z.infer` type
1243
- exports), module-scope specs declared via `satisfies RequestResponseActionSpec`
1244
- (no per-method `*_METHOD` string constants — read `.method` off the spec),
1245
- and `all_*_action_specs: Array<RequestResponseActionSpec>` codegen-ready
1246
- registry. Plus any reason-string constants exported to the wire contract
1247
- (e.g. `ERROR_ROLE_GRANT_OFFER_*` for role_grant offers).
1248
- - `*_actions.ts` — `create_*_actions(deps, options) => Array<RpcAction>` factory
1249
- containing handler closures, the `*ActionDeps` / `*ActionOptions` interfaces,
1250
- and any handler-only helpers. Imports the specs from its sibling.
1251
-
1252
- Client-side code that only needs the typed surface (codegen, attack-surface
1253
- reporting, form-state error matching) imports from `*_action_specs.ts` and
1254
- skips the handler module's transitive query-layer deps.
1255
-
1256
- ### `admin_action_specs.ts` + `admin_actions.ts` — eleven admin-only RPC actions
1257
-
1258
- Authorization is **spec-level** — every admin spec declares
1259
- `auth: {account: 'required', actor: 'required', roles: ['admin']}` so
1260
- the dispatcher enforces admin before the handler runs. `role_grant_revoke`
1261
- in `role_grant_offer_actions.ts` uses the same spec-level gate even
1262
- though its sibling methods are authenticated-but-not-admin — the
1263
- dispatcher checks auth per-spec, so mixed-auth endpoints compose
1264
- cleanly. Every admin input declares `acting?: ActingActor` per
1265
- registry-time invariant 2 (the `actor !== 'none' ⟺ input declares
1266
- acting?: ActingActor` biconditional).
1267
-
1268
- | Spec | Side effects | Rate limit | Input | Output |
1269
- | ------------------------------------------ | ------------ | ----------- | --------------------------------------------------------- | ----------------------------- |
1270
- | `admin_account_list_action_spec` | false | `'account'` | `{limit?, offset?}` | `{accounts, grantable_roles}` |
1271
- | `admin_session_list_action_spec` | false | `'account'` | `z.void()` | `{sessions}` |
1272
- | `admin_session_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
1273
- | `admin_token_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
1274
- | `audit_log_list_action_spec` | false | `'account'` | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
1275
- | `audit_log_role_grant_history_action_spec` | false | `'account'` | `{limit?, offset?}` | `{events}` |
1276
- | `invite_create_action_spec` | true | `'account'` | `{email?, username?}` | `{ok, invite}` |
1277
- | `invite_list_action_spec` | false | `'account'` | `z.void()` | `{invites}` |
1278
- | `invite_delete_action_spec` | true | `'account'` | `{invite_id}` | `{ok}` |
1279
- | `app_settings_get_action_spec` | false | | `z.void()` | `{settings}` |
1280
- | `app_settings_update_action_spec` | true | `'account'` | `{open_signup}` | `{ok, settings}` |
1281
-
1282
- Every admin spec declares `rate_limit: 'account'` — keyed on the
1283
- admin's `request_context.actor.id`. Mutations cap the
1284
- `invite_create`-style account-existence oracle (`LOWER()` lookup in
1285
- `query_account_by_username/_by_email`); reads cap admin-side scraping
1286
- of paginated cross-account listings (`admin_account_list`,
1287
- `audit_log_list`, `audit_log_role_grant_history`) and unbounded
1288
- cross-account reads (`admin_session_list`, `invite_list`). The
1289
- dispatcher's per-action hook (shared by HTTP RPC + WS) records every
1290
- invocation regardless of outcome so successful probes consume budget.
1291
- Default `default_action_account_rate_limit` is 1200/15min per actor —
1292
- permissive enough for any human admin workflow, slow enough that
1293
- scripted oracles surface in audit. Tighten downstream via
1294
- `AppServerOptions.action_account_rate_limiter`.
1295
-
1296
- `AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp. `ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT = 50` / `ADMIN_ACCOUNT_LIST_LIMIT_MAX = 200` — same shape on `admin_account_list`.
1297
-
1298
- Error reasons returned via `error.data.reason`:
1299
-
1300
- | Method | Error |
1301
- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1302
- | `admin_session_revoke_all` | `ERROR_ACCOUNT_NOT_FOUND` (404 via `jsonrpc_errors.not_found`) |
1303
- | `admin_token_revoke_all` | `ERROR_ACCOUNT_NOT_FOUND` |
1304
- | `invite_create` | `ERROR_INVITE_ACCOUNT_EXISTS_USERNAME`, `ERROR_INVITE_ACCOUNT_EXISTS_EMAIL`, `ERROR_INVITE_DUPLICATE` (conflict). Empty input is rejected at the schema via `.refine()` — surfaces as standard `invalid_params` with `error.data.issues` (Zod issues array), not a `reason` code. |
1305
- | `invite_delete` | `ERROR_INVITE_NOT_FOUND` (not_found) |
1306
-
1307
- Audit events fired by handlers (all pass `ip: ctx.client_ip` for
1308
- transport-uniform forensics — matches the REST convention and the
1309
- self-service `account_actions.ts` surface):
1310
-
1311
- - `session_revoke_all` / `token_revoke_all` via `deps.audit.emit`. Both
1312
- also emit an `outcome: 'failure'` row on the `ERROR_ACCOUNT_NOT_FOUND`
1313
- 404 path for
1314
- forensic visibility — `target_account_id` is null (FK to `account`
1315
- rejects references to missing ids), and the probed id is preserved
1316
- under `metadata.attempted_account_id`. Metadata schema widening in
1317
- `audit_log_schema.ts` allows `reason`, `attempted_account_id`, and
1318
- makes `count` optional for the failure shape.
1319
- - `invite_create` / `invite_delete`.
1320
- - `app_settings_update` — metadata `{setting: 'open_signup', old_value, new_value}`.
276
+ `role_grant_offer_supersede` carries
277
+ `reason: 'sibling_accepted' | 'role_grant_revoked' | 'scope_destroyed'`
278
+ plus `cause_id` pointing to the row that triggered the supersede.
279
+
280
+ ### Keeper auth shape
281
+
282
+ Keeper is not a dedicated guard — it's a composable `RouteAuth` shape:
283
+ `{account: 'required', actor: 'required', roles: ['keeper'], credential_types: ['daemon_token']}`.
284
+ The two-part check is `require_credential_types(['daemon_token'])` (403
285
+ `ERROR_CREDENTIAL_TYPE_REQUIRED`) followed by `require_role(['keeper'])`
286
+ (403 `ERROR_INSUFFICIENT_PERMISSIONS`). Same scope-aware semantics mirrored
287
+ in the HTTP RPC dispatcher (`actions/action_rpc.ts`), the WS dispatcher
288
+ (`actions/register_action_ws.ts`), and the admin bypasses inside
289
+ `auth/role_grant_offer_actions.ts`.
290
+
291
+ ### Migrations
292
+
293
+ Schema migrations live in `auth/migrations.ts` — two namespaces today (`full_auth_schema`,
294
+ `role_grant_offer_and_scoped_role_grants`) under the reserved
295
+ `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`. Consumer namespaces must avoid
296
+ `reserved_migration_namespaces`. Runner contract, error vocabulary, and
297
+ operator recipes (rename, mark applied, reset, baseline) are in
298
+ ../../../docs/migrations.md.
299
+
300
+ ## RPC action surfaces
301
+
302
+ Each registry splits across `*_action_specs.ts` (schemas + specs + registry,
303
+ codegen-importable) and `*_actions.ts` (`create_*_actions(deps, options)`
304
+ factory with handlers). Client codegen imports the specs and skips the
305
+ handler module's transitive query-layer deps.
306
+
307
+ | Factory | Registry | Bundle in `create_standard_rpc_actions` |
308
+ | ---------------------------------- | ------------------------------------ | --------------------------------------- |
309
+ | `create_admin_actions` | `all_admin_action_specs` | yes |
310
+ | `create_role_grant_offer_actions` | `all_role_grant_offer_action_specs` | yes |
311
+ | `create_account_actions` | `all_account_action_specs` | yes |
312
+ | `create_self_service_role_actions` | `all_self_service_role_action_specs` | no `eligible_roles` is app-specific |
313
+ | `create_actor_lookup_actions` | `all_actor_lookup_action_specs` | no opt-in batched id → label resolver |
314
+ | `create_actor_search_actions` | `all_actor_search_action_specs` | no opt-in prefix-search picker |
315
+
316
+ `auth/all_action_spec_registries.ts` exposes `all_fuz_auth_action_spec_registries`
317
+ for registry-wide invariant tests. Not a mounting surface; protocol specs
318
+ are excluded.
319
+
320
+ ### Authorization patterns
321
+
322
+ - **Spec-level enforcement.** Every admin spec declares
323
+ `auth: {account: 'required', actor: 'required', roles: ['admin']}`; the
324
+ dispatcher checks per-spec, so mixed-auth bundles compose cleanly
325
+ (`role_grant_revoke` uses the admin gate alongside non-admin offer
326
+ siblings in the same factory).
327
+ - **Input-dependent elevation.** `role_grant_offer_list` and `_history` use
328
+ `side_effects: false` so they're GET-addressable. Spec-level auth is
329
+ `{account: 'required', actor: 'required'}` so any caller reaches their
330
+ own inbox; the handler additionally requires admin when `{account_id}`
331
+ refers to another account. The spec can't express this because auth runs
332
+ before input parsing.
333
+ - **Account-grain self-service.** `account_*` specs declare
334
+ `auth: {account: 'required', actor: 'none'}` no `acting` on input, so
335
+ the actor axis stays `'none'` per registry-time invariant 2. IDOR via
336
+ `query_session_revoke_for_account` / `query_revoke_api_token_for_account`.
337
+ - **Credential-channel gating.** `account_token_create` / `_revoke`,
338
+ `account_session_revoke` / `_revoke_all`, and REST `POST /password` all
339
+ declare `credential_types: ['session']`. `account_session_revoke` is
340
+ gated alongside `_revoke_all` because a leaked bearer can otherwise
341
+ compose `account_session_list` + N×revoke to reach the same lockout.
342
+ Admin token/session revoke specs deliberately stay unrestricted (admin
343
+ scripting from CLI/bearer is legitimate operator workflow). See
344
+ ../../../docs/security.md §Credential-channel gating.
345
+ - **Rate-limit posture.** Admin specs and authed-spam-prone surfaces
346
+ (`role_grant_offer_create`, `role_grant_revoke`, `account_token_create`,
347
+ `self_service_role_set`, `actor_lookup`, `actor_search`) declare
348
+ `rate_limit: 'account'`. Throttle-requests semantics — every invocation
349
+ records, regardless of outcome. Default
350
+ `default_action_account_rate_limit` is 1200/15min per actor.
351
+
352
+ ### Admin actions eleven specs
353
+
354
+ `create_admin_actions(deps, options?)` in `auth/admin_actions.ts`.
355
+
356
+ | Spec | Side effects | Input | Output |
357
+ | ------------------------------------------ | ------------ | --------------------------------------------------------- | ----------------------------- |
358
+ | `admin_account_list_action_spec` | false | `{limit?, offset?}` | `{accounts, grantable_roles}` |
359
+ | `admin_session_list_action_spec` | false | `z.void()` | `{sessions}` |
360
+ | `admin_session_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
361
+ | `admin_token_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
362
+ | `audit_log_list_action_spec` | false | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
363
+ | `audit_log_role_grant_history_action_spec` | false | `{limit?, offset?}` | `{events}` |
364
+ | `invite_create_action_spec` | true | `{email?, username?}` | `{ok, invite}` |
365
+ | `invite_list_action_spec` | false | `z.void()` | `{invites}` |
366
+ | `invite_delete_action_spec` | true | `{invite_id}` | `{ok}` |
367
+ | `app_settings_get_action_spec` | false | `z.void()` | `{settings}` |
368
+ | `app_settings_update_action_spec` | true | `{open_signup}` | `{ok, settings}` |
369
+
370
+ Constants: `AUDIT_LOG_LIST_LIMIT_MAX = 200`, `ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT = 50`,
371
+ `ADMIN_ACCOUNT_LIST_LIMIT_MAX = 200`.
372
+
373
+ Error reasons via `error.data.reason`: `ERROR_ACCOUNT_NOT_FOUND` (404 via
374
+ `jsonrpc_errors.not_found`) on admin revoke-all, `ERROR_INVITE_ACCOUNT_EXISTS_USERNAME` /
375
+ `_EMAIL` / `ERROR_INVITE_DUPLICATE` on invite create, `ERROR_INVITE_NOT_FOUND`
376
+ on invite delete. `invite_create` empty input is rejected at the schema via
377
+ `.refine()` and surfaces as `invalid_params` with `error.data.issues`.
1321
378
 
1322
379
  Closure state:
1323
380
 
1324
- - `grantable_roles` is derived once from `options.roles?.role_specs ?? builtin_role_specs_by_name`
1325
- via `list_roles_with_grant_path(_, GRANT_PATH_ADMIN)` and closed over
1326
- by the `admin_account_list` handler.
1327
- - `options.app_settings` when provided, captured by the
1328
- `app_settings_get` / `app_settings_update` handlers. Update handler
1329
- **mutates the ref** (`open_signup`, `updated_at`, `updated_by`) so
1330
- `signup_routes.ts` reads the new value **without a DB round trip**.
1331
- When absent, those two specs are still present in `all_admin_action_specs`
1332
- (surface-wise) but the handlers are not wired RPC dispatch returns
1333
- `method_not_found`.
1334
-
1335
- `all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
1336
- registry of all eleven specs (always includes the two app-settings specs).
1337
-
1338
- Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>` — `log` for handler-side reporting, `audit` for the bound emitter (which captures `on_audit_event` + the optional `AuditLogConfig` so consumer-extended event-type metadata gets validated).
1339
-
1340
- ### `role_grant_offer_action_specs.ts` + `role_grant_offer_actions.ts` seven RPC actions
1341
-
1342
- > **Hazardadmin `role_grant_offer_create` does not auto-accept.** The action
1343
- > returns `{offer}` only — no `role_grant` is inserted. Acceptance is a separate
1344
- > RPC call (`role_grant_offer_accept`); admin-side tests that need to materialize
1345
- > a role_grant synchronously call `query_accept_offer` directly (see the
1346
- > `offer_and_accept` helper in `testing/admin_integration.ts`). The CHANGELOG
1347
- > v0.31 entry "admin create_role_grant routes emit offers instead of direct
1348
- > grants" was the first signal of this two-step flow; consumers reading the
1349
- > standard admin suite assume auto-accept and have to redesign their tests
1350
- > when they discover otherwise. If you need direct grant for a programmatic
1351
- > path that already proves consent, reach for `query_create_role_grant` rather
1352
- > than the RPC action.
1353
-
1354
- Six offer-lifecycle methods plus `role_grant_revoke`. Every input
1355
- declares `acting?: ActingActor` so every spec maps to
1356
- `{account: 'required', actor: 'required', ...}` per registry-time
1357
- invariant 2. Authorization tier is the differentiator:
1358
-
1359
- - `role_grant_offer_create` `auth: {account: 'required', actor: 'required'}`.
1360
- The **admin-grant-path gate runs first** (the offered role's
1361
- `RoleSpec.grant_paths` must include `'admin'` /
1362
- `GRANT_PATH_ADMIN`), then the `RoleGrantOfferCreateAuthorize`
1363
- callback (default: caller holds the offered role globally).
1364
- Consumers can only tighten, never loosen past the admin-grant-path
1365
- gate.
1366
- - `role_grant_offer_accept` / `_decline` / `_retract`
1367
- `{account: 'required', actor: 'required'}`; IDOR guards in the
1368
- `query_*` layer.
1369
- - `role_grant_offer_list` / `_history` `side_effects: false` so GET-addressable;
1370
- **input-dependent elevation** `{account: 'required', actor: 'required'}`
1371
- at the spec level so any caller reaches their own inbox, then the
1372
- handler requires admin when `{account_id}` refers to another account.
1373
- The spec can't express this because auth runs before input parsing.
1374
- `role_grant_offer_history` accepts `limit` (1–500, default 100) + `offset`.
1375
- - **`role_grant_revoke`** spec-level
1376
- `auth: {account: 'required', actor: 'required', roles: ['admin']}`;
1377
- the RPC dispatcher rejects non-admin callers before the handler runs.
1378
- Keys on **`actor_id`, not `account_id`**role_grants are
1379
- actor-scoped and deriving actor from account collapses under
1380
- multi-actor accounts.
1381
-
1382
- Every input row below also carries the shared `acting?: ActingActor`
1383
- field that the dispatcher's authorization phase reads off the raw
1384
- params (omitted from the table for brevity).
1385
-
1386
- | Spec | Rate limit | Input | Output |
1387
- | -------------------------------------- | ----------- | ---------------------------------------------------------- | ---------------------------------------------- |
1388
- | `role_grant_offer_create_action_spec` | `'account'` | `{to_account_id, to_actor_id?, role, scope_id?, message?}` | `{offer}` |
1389
- | `role_grant_offer_accept_action_spec` | | `{offer_id}` | `{role_grant_id, offer, superseded_offer_ids}` |
1390
- | `role_grant_offer_decline_action_spec` | | `{offer_id, reason?}` | `{ok}` |
1391
- | `role_grant_offer_retract_action_spec` | | `{offer_id}` | `{ok}` |
1392
- | `role_grant_offer_list_action_spec` | | `{account_id?}` | `{offers}` |
1393
- | `role_grant_offer_history_action_spec` | | `{account_id?, limit?, offset?}` | `{offers}` |
1394
- | `role_grant_revoke_action_spec` | `'account'` | `{actor_id, role_grant_id, reason?}` | `{ok, revoked}` |
1395
-
1396
- `role_grant_offer_create` carries the same shape as `invite_create` —
1397
- hostile authed callers can iterate `to_account_id` to spam offers and
1398
- probe `ERROR_ACCOUNT_NOT_FOUND` /
1399
- `ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH` as account-existence
1400
- oracles, so the rate cap fires on the same threat model the admin
1401
- `invite_create` spec addresses upstream. `role_grant_revoke` keeps its
1402
- cap because it's an admin mutation. The accept / decline / retract /
1403
- list / history specs are recipient-side or caller-own-data — no
1404
- enumeration vector, no rate cap.
1405
-
1406
- Error reason constants (exported as `as const` literals):
1407
-
1408
- - `ERROR_ROLE_GRANT_OFFER_SELF_TARGET` (`'role_grant_offer_self_target'`)
1409
- - `ERROR_ROLE_GRANT_OFFER_TERMINAL` (`'role_grant_offer_terminal'`)
1410
- - `ERROR_ROLE_GRANT_OFFER_EXPIRED` (`'role_grant_offer_expired'`)
1411
- - `ERROR_ROLE_GRANT_OFFER_NOT_FOUND` (`'role_grant_offer_not_found'` — 404-over-403 IDOR mask)
1412
- - `ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE` (`'role_grant_offer_role_not_grantable'`)
1413
- - `ERROR_ROLE_GRANT_OFFER_NOT_AUTHORIZED` (`'role_grant_offer_not_authorized'`)
1414
- - `ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH` (`'role_grant_offer_actor_account_mismatch'` —
1415
- `role_grant_offer_create` was called with a `to_actor_id` that does not
1416
- belong to `to_account_id`)
1417
- - `ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH` (`'role_grant_offer_actor_mismatch'` —
1418
- actor-targeted offer was accepted by an actor other than `to_actor_id`)
381
+ - `grantable_roles` derived once from `options.roles?.role_specs ?? builtin_role_specs_by_name`
382
+ via `list_roles_with_grant_path(_, GRANT_PATH_ADMIN)`.
383
+ - `options.app_settings` mutable ref — `app_settings_update` mutates so
384
+ `auth/signup_routes.ts` reads the new value without a DB round trip. When
385
+ absent, the two app-settings specs are still in the registry but unwired
386
+ (dispatch returns `method_not_found`).
387
+ - `options.connection_closer?` handler-side eager WS close on
388
+ `admin_session_revoke_all` / `admin_token_revoke_all` BEFORE the audit
389
+ emit so revocation lands even on audit INSERT failure. Listener-based
390
+ close (`transports_ws_auth_guard`) stays as a fail-safe. Failure outcomes
391
+ skip the eager close.
392
+
393
+ Failure-outcome audit rows: `admin_session_revoke_all` and `_token_revoke_all`
394
+ emit an `outcome: 'failure'` row on `ERROR_ACCOUNT_NOT_FOUND` for forensic
395
+ visibility — `target_account_id` is null (FK rejects missing ids), and the
396
+ probed id is preserved under `metadata.attempted_account_id`. Every gated
397
+ event additionally records `credential_type` in metadata (defense in depth).
398
+
399
+ ### Role-grant-offer actions seven specs
400
+
401
+ `create_role_grant_offer_actions(deps, options?)` in
402
+ `auth/role_grant_offer_actions.ts`.
403
+
404
+ > **Hazard admin `role_grant_offer_create` does not auto-accept.** The
405
+ > action returns `{offer}` only. Acceptance is a separate
406
+ > `role_grant_offer_accept` call; admin-side tests that materialize a
407
+ > role_grant drive the full offer + accept RPCs (see
408
+ > `testing/admin_integration.ts` §`offer_and_accept`), or skip the consent
409
+ > path entirely via `create_test_role_grant_direct` from
410
+ > `testing/db_entities.ts` when the test focuses on revoke / isolation
411
+ > rather than the grant path itself. The v0.31 CHANGELOG entry was the
412
+ > first signal of this two-step flow; consumers reading the standard admin
413
+ > suite assume auto-accept and have to redesign their tests when they
414
+ > discover otherwise.
415
+
416
+ | Spec | Input | Output |
417
+ | -------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
418
+ | `role_grant_offer_create_action_spec` | `{to_account_id, to_actor_id?, role, scope_id?, message?}` | `{offer}` |
419
+ | `role_grant_offer_accept_action_spec` | `{offer_id}` | `{role_grant_id, offer, superseded_offer_ids}` |
420
+ | `role_grant_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` |
421
+ | `role_grant_offer_retract_action_spec` | `{offer_id}` | `{ok}` |
422
+ | `role_grant_offer_list_action_spec` | `{account_id?}` | `{offers}` |
423
+ | `role_grant_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` |
424
+ | `role_grant_revoke_action_spec` | `{actor_id, role_grant_id, reason?}` | `{ok, revoked}` |
425
+
426
+ Every input carries `acting?: ActingActor` (registry-time invariant 2).
427
+ `role_grant_revoke` keys on **`actor_id`**, not `account_id` role_grants
428
+ are actor-scoped and deriving actor from account collapses under multi-actor
429
+ accounts.
430
+
431
+ `role_grant_offer_create` runs the **admin-grant-path gate first** (offered
432
+ role's `RoleSpec.grant_paths` must include `'admin'`), then the
433
+ `RoleGrantOfferCreateAuthorize` callback. Default: caller holds the offered
434
+ role globally. Pre-built `authorize_admin_or_holder` admits any admin and
435
+ otherwise falls back to the default drop into `create_role_grant_offer_actions({authorize: authorize_admin_or_holder})`
436
+ or `create_standard_rpc_actions` for "admins offer anything; users offer
437
+ what they hold."
438
+
439
+ Error reasons (`as const` literals):
440
+
441
+ - `ERROR_ROLE_GRANT_OFFER_SELF_TARGET`
442
+ - `ERROR_ROLE_GRANT_OFFER_TERMINAL`
443
+ - `ERROR_ROLE_GRANT_OFFER_EXPIRED`
444
+ - `ERROR_ROLE_GRANT_OFFER_NOT_FOUND` (404-over-403 IDOR mask)
445
+ - `ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE`
446
+ - `ERROR_ROLE_GRANT_OFFER_NOT_AUTHORIZED`
447
+ - `ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH` (supplied `to_actor_id` doesn't belong to `to_account_id`)
448
+ - `ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH` (actor-targeted offer accepted by wrong actor)
1419
449
 
1420
450
  Plus re-uses from `http/error_schemas.ts`: `ERROR_ROLE_GRANT_NOT_FOUND`,
1421
451
  `ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`,
1422
- `ERROR_ACCOUNT_NOT_FOUND`.
1423
-
1424
- Each spec declares the reason codes its handler may surface (see
1425
- `actions/CLAUDE.md` §Action specs for the field semantics). Only
1426
- domain reasons returned via `error.data.reason` are listed; standard
1427
- transport errors (validation, auth, rate-limit) stay implicit. Drift
1428
- between declared reasons and handler throws is caught by
452
+ `ERROR_ACCOUNT_NOT_FOUND`. Each spec declares the reason codes its handler
453
+ may surface via `spec.error_reasons`; drift is caught per-module by
1429
454
  ../../test/auth/role_grant_offer_actions.error_reasons.test.ts.
1430
455
 
1431
- Failure-outcome audit events emitted (success and failure rows both carry
1432
- `ip: ctx.client_ip` uniform with the admin and self-service surfaces):
1433
-
1434
- - `role_grant_offer_create` failure — admin-grant-path denial, `authorize`
1435
- denial, self-target rejection, and actor-account mismatch all emit
1436
- the same audit row via `emit_create_failure_audit`. `target_account_id`
1437
- carries `input.to_account_id`; `target_actor_id` echoes
1438
- `input.to_actor_id` when supplied so failure rows match the
1439
- success-shape envelope of actor-targeted offers (null on
1440
- account-grain offers — see audit_log_schema rule).
1441
- - `role_grant_revoke` failure admin-grant-path denial after IDOR / role
1442
- lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit,
1443
- matching the middleware auth-guard precedent. `target_account_id` +
1444
- `target_actor_id` both populated (the IDOR-passing branch resolves
1445
- the target actor before the gate; the subject is an actor-bound
1446
- role_grant).
1447
-
1448
- WS notifications (post-commit via `emit_after_commit` from
1449
- `http/pending_effects.js` swallows exceptions so one failed send
1450
- can't starve others; see `http/CLAUDE.md` §Pending Effects):
1451
-
1452
- - Create → `role_grant_offer_received` to recipient.
1453
- - Retract → `role_grant_offer_retracted` to recipient.
1454
- - Accept `role_grant_offer_accepted` to grantor + one
1455
- `role_grant_offer_supersede` per superseded sibling to that sibling's grantor.
1456
- - Decline `role_grant_offer_declined` to grantor.
1457
- - Revoke `role_grant_revoke` to revokee + one `role_grant_offer_supersede` per
1458
- superseded sibling.
1459
-
1460
- Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'> & {notification_sender?: NotificationSender | null}`
1461
- inline on the param. Notification sender is optional — when absent, WS
1462
- fan-out is silently skipped (DB-only side effects still happen).
1463
-
1464
- Options:
1465
-
1466
- - `roles?: RoleSchemaResult`drives the admin-grant-path lookup
1467
- (`role_has_grant_path(_, role, GRANT_PATH_ADMIN)`); defaults to
1468
- `builtin_role_specs_by_name`.
1469
- - `default_ttl_ms?: number` — applied to new offers (defaults to
1470
- `ROLE_GRANT_OFFER_DEFAULT_TTL_MS`).
1471
- - `authorize?: RoleGrantOfferCreateAuthorize` custom policy for
1472
- `role_grant_offer_create`. Signature:
1473
- `(auth, input: {to_account_id, role, scope_id}, deps: Pick<RouteFactoryDeps, 'log'>, ctx: ActionContext) => boolean | Promise<boolean>`.
1474
- Pre-built option: `authorize_admin_or_holder` admits any admin and
1475
- otherwise falls back to the symmetric default (caller must hold the
1476
- offered role globally). Drop into
1477
- `create_role_grant_offer_actions({authorize: authorize_admin_or_holder})`
1478
- or any factory that forwards `authorize` (e.g. `create_standard_rpc_actions`)
1479
- for the common "admins offer anything on the admin grant path; users
1480
- offer what they hold" pattern.
1481
-
1482
- `all_role_grant_offer_action_specs: Array<RequestResponseActionSpec>`
1483
- codegen-ready registry.
1484
-
1485
- ### `standard_rpc_actions.ts` — combined admin + role-grant-offer + account factory
1486
-
1487
- `create_standard_rpc_actions(deps, options)` spreads
1488
- `create_admin_actions`, `create_role_grant_offer_actions`, and
1489
- `create_account_actions` into a single `Array<RpcAction>` the
1490
- canonical fuz_app "standard" RPC surface (25 actions with
1491
- `app_settings` wired, 23 without). Consumers that want a narrower
1492
- surface drop down to the per-domain factories directly.
1493
-
1494
- Option routing: `roles` is shared between admin and role-grant-offer;
1495
- `app_settings` flows to admin only; `default_ttl_ms` and `authorize`
1496
- flow to role-grant-offer only; `max_tokens` flows to account only;
1497
- `notification_sender` is wired through to role-grant-offer (admin +
1498
- account ignore it).
1499
-
1500
- `StandardRpcActionsOptions` composes `AdminActionOptions` +
1501
- `RoleGrantOfferActionOptions` + `AccountActionOptions`.
1502
- `StandardRpcActionsDeps extends Pick<RouteFactoryDeps, 'log' | 'audit'>`
1503
- plus optional `notification_sender` consumed only by the
1504
- role-grant-offer sub-factory; admin and account sub-factories ignore it.
1505
- The interface is declared inline rather than aliased so future
1506
- role-grant-offer-internal deps additions can't silently widen the
1507
- standard surface.
1508
-
1509
- Pair this with `create_app_server`'s `rpc_endpoints` factory form
1510
- (`(ctx) => Array<RpcEndpointSpec>`) so the combined action list gets
1511
- `ctx.deps` + `ctx.app_settings` — `create_app_server` auto-mounts the
1512
- endpoint via `create_rpc_endpoint`, so consumers don't need to mount it
1513
- again in `create_route_specs`. See ../../../docs/usage.md §Server
1514
- Assembly.
1515
-
1516
- Pre-bundle consumers spread `create_admin_actions` and
1517
- `create_role_grant_offer_actions` separately, then also
1518
- `create_account_actions`. The bundled helper replaces all three
1519
- bundling account actions into the "standard" surface is deliberate:
1520
- the admin integration suite exercises `account_token_create` /
1521
- `account_token_revoke` (cross-account isolation scenarios), so a
1522
- consumer wiring the admin surface without account actions will hit
1523
- `method not found` on first admin-suite run.
1524
-
1525
- Frontend mirror: `all_standard_action_specs` (in
1526
- `auth/standard_action_specs.ts`) bundles `all_admin_action_specs +
1527
- all_role_grant_offer_action_specs + all_account_action_specs` into one
1528
- `ReadonlyArray<RequestResponseActionSpec>` for typed-client codegen
1529
- and `create_frontend_rpc_client({specs})` wiring. Self-service role
1530
- specs are not included (opt-in, app-specific `eligible_roles`)
1531
- spread `all_self_service_role_action_specs` separately when needed.
1532
-
1533
- ### `all_action_spec_registries.ts` — walker-only registry-of-registries
1534
-
1535
- `all_fuz_auth_action_spec_registries` — walker/codegen entry for every
1536
- fuz-auth action-spec bundle (`admin`, `role_grant_offer`, `account`,
1537
- `self_service_role`, `actor_lookup`, `actor_search`). Not a mounting
1538
- surface; protocol specs are excluded. Iterated by registry-wide
1539
- invariant tests in ../../test/auth/.
1540
-
1541
- ### `account_action_specs.ts` + `account_actions.ts` — seven self-service RPC actions
1542
-
1543
- Counterpart to `account_routes.ts`. Cookie-lifecycle flows (`login`,
1544
- `logout`, `password`, `signup`, `bootstrap`) stay on REST, as does
1545
- `GET /verify` (empty-body nginx `auth_request` probe). Everything else
1546
- that was `/api/account/*` is on the RPC endpoint.
1547
-
1548
- `account_verify` is intentionally on both surfaces: the REST shim is a
1549
- status-only probe, the RPC action returns `SessionAccountJson` for
1550
- programmatic callers.
1551
-
1552
- Authorization is **spec-level** —
1553
- `auth: {account: 'required', actor: 'none'}` (no `acting` on input, so
1554
- the actor axis stays `'none'` per registry-time invariant 2). Revoke
1555
- operations are account-scoped via `query_session_revoke_for_account` /
1556
- `query_revoke_api_token_for_account` — passing another account's session
1557
- or token id returns `revoked: false` rather than revealing whether the id
1558
- exists.
1559
-
1560
- **Credential-channel gating** — `account_token_create`,
1561
- `account_token_revoke`, `account_session_revoke`, and
1562
- `account_session_revoke_all` declare `credential_types: ['session']`
1563
- on their `auth` axis (same gate as REST `POST /password`).
1564
- `account_session_revoke` is gated alongside `_revoke_all` because a
1565
- leaked bearer can otherwise compose `account_session_list` + N×revoke
1566
- to reach the same lockout. Admin token/session revoke specs in
1567
- `admin_action_specs.ts` deliberately stay unrestricted (admin
1568
- scripting from CLI/bearer is legitimate operator workflow). For the
1569
- threat model, the trust-bar rationale, and the defense-in-depth audit
1570
- metadata see `docs/security.md` §Credential-channel gating.
1571
-
1572
- | Spec | Side effects | Rate limit | Input | Output |
1573
- | ---------------------------------------- | ------------ | ----------- | -------------- | ----------------------- |
1574
- | `account_verify_action_spec` | false | | `z.void()` | `SessionAccountJson` |
1575
- | `account_session_list_action_spec` | false | | `z.void()` | `{sessions}` |
1576
- | `account_session_revoke_action_spec` | true | | `{session_id}` | `{ok, revoked}` |
1577
- | `account_session_revoke_all_action_spec` | true | | `z.void()` | `{ok, count}` |
1578
- | `account_token_create_action_spec` | true | `'account'` | `{name?}` | `{ok, token, id, name}` |
1579
- | `account_token_list_action_spec` | false | | `z.void()` | `{tokens}` |
1580
- | `account_token_revoke_action_spec` | true | | `{token_id}` | `{ok, revoked}` |
1581
-
1582
- `account_token_create` declares `rate_limit: 'account'` to bound the
1583
- _rate_ of token churn. The outstanding-token count is already capped by
1584
- `max_tokens` via `query_api_token_enforce_limit`, but the per-account
1585
- burn rate is not — without this cap a caller could rotate tokens in a
1586
- tight loop to amplify `token_create` audit churn. The other six specs
1587
- are IDOR-guarded reads/revokes of caller-own state with no enumeration
1588
- vector, so rate caps are symmetry-only and skipped.
1589
-
1590
- `session_id` validates as `Blake3Hash`; `token_id` validates as
1591
- `ApiTokenId` (`tok_[A-Za-z0-9_-]{12}`).
1592
-
1593
- Audit events emitted (via `deps.audit.emit` with `ip: ctx.client_ip`):
1594
- `session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. The
1595
- IP is the resolved trusted-proxy value from `ActionContext.client_ip`,
1596
- matching the REST handler convention. Every gated event additionally
1597
- records `credential_type` (read from `ActionContext.credential_type`)
1598
- in metadata — defense in depth so forensics survive if the
1599
- `credential_types: ['session']` spec gate is ever loosened or bypassed.
1600
- The REST `password_change` audit row mirrors the same field on all
1601
- three outcomes (success, wrong-password, concurrent-change).
1602
-
1603
- Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
1604
- Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS`
1605
- from `account_routes.ts`; `null` disables the cap.
1606
-
1607
- `all_account_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
1608
- registry of all seven specs.
1609
-
1610
- ### `self_service_role_action_specs.ts` + `self_service_role_actions.ts` — opt-in self-service role toggle
1611
-
1612
- Same split as the other registries: `*_action_specs.ts` holds the input/output
1613
- Zod schemas, the `satisfies RequestResponseActionSpec` literal, the
1614
- `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE` reason constant, and the
1615
- `all_self_service_role_action_specs` registry — all client-safe. The
1616
- `*_actions.ts` factory imports the spec and pairs it with the handler.
1617
-
1618
- One static `request_response` action — `self_service_role_set` — that
1619
- takes `{role, enabled: boolean}` and toggles a global role_grant on the
1620
- caller. Idempotent in both directions: `changed: false` when the
1621
- post-call state already matched the request (already-held when
1622
- enabling; not-held when disabling). Output is `{ok, enabled, changed}` —
1623
- `enabled` echoes the post-call state for self-describing responses.
456
+ Failure-outcome audits use `emit_create_failure_audit` /
457
+ `emit_revoke_failure_audit` so all denial paths land uniform rows; the
458
+ admin-role-denied path (pre-IDOR) on `role_grant_revoke` emits no audit,
459
+ matching the middleware auth-guard precedent.
460
+
461
+ #### WS notifications
462
+
463
+ Post-commit via `emit_after_commit` (see `http/CLAUDE.md` §Pending Effects):
464
+
465
+ | Event | Fan-out |
466
+ | ------- | ------------------------------------------------------------------- |
467
+ | Create | `role_grant_offer_received` recipient |
468
+ | Retract | `role_grant_offer_retracted` → recipient |
469
+ | Accept | `role_grant_offer_accepted` grantor + `_supersede` per sibling |
470
+ | Decline | `role_grant_offer_declined` grantor |
471
+ | Revoke | `role_grant_revoke` → revokee + `_supersede` per superseded sibling |
472
+
473
+ Spec module is `auth/role_grant_offer_notifications.ts` — six
474
+ `RemoteNotificationActionSpec`s with Zod params schemas and notification
475
+ builders, plus `role_grant_offer_notification_specs: Array<EventSpec>` for
476
+ `create_app_server`'s `event_specs` (drives surface generation and
477
+ DEV-mode `create_validated_broadcaster` payload validation).
478
+
479
+ Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'> & {notification_sender?: NotificationSender | null}`.
480
+ `NotificationSender` is the narrow structural capability
481
+ (`send_to_account(account_id, message): number`); `BackendWebsocketTransport`
482
+ satisfies it structurally. Target account travels via the send argument, not
483
+ the payload — `revoked_by` is deliberately not in the `role_grant_revoke`
484
+ payload (the revokee doesn't need to learn the admin's identity). When
485
+ `notification_sender` is absent, WS fan-out is silently skipped.
486
+
487
+ Options: `roles?: RoleSchemaResult` (drives admin-grant-path lookup),
488
+ `default_ttl_ms?` (defaults to `ROLE_GRANT_OFFER_DEFAULT_TTL_MS` = 30 days),
489
+ `authorize?: RoleGrantOfferCreateAuthorize`.
490
+
491
+ ### Account actionsseven self-service specs
492
+
493
+ `create_account_actions(deps, options?)` in `auth/account_actions.ts`.
494
+
495
+ | Spec | Side effects | Input | Output |
496
+ | ---------------------------------------- | ------------ | -------------- | ----------------------- |
497
+ | `account_verify_action_spec` | false | `z.void()` | `SessionAccountJson` |
498
+ | `account_session_list_action_spec` | false | `z.void()` | `{sessions}` |
499
+ | `account_session_revoke_action_spec` | true | `{session_id}` | `{ok, revoked}` |
500
+ | `account_session_revoke_all_action_spec` | true | `z.void()` | `{ok, count}` |
501
+ | `account_token_create_action_spec` | true | `{name?}` | `{ok, token, id, name}` |
502
+ | `account_token_list_action_spec` | false | `z.void()` | `{tokens}` |
503
+ | `account_token_revoke_action_spec` | true | `{token_id}` | `{ok, revoked}` |
504
+
505
+ `account_verify` is intentionally on both surfaces: the REST `GET /verify`
506
+ shim is a status-only nginx probe; the RPC action returns
507
+ `SessionAccountJson` for programmatic callers.
508
+
509
+ `session_id` validates as `Blake3Hash`; `token_id` as `ApiTokenId`
510
+ (`tok_[A-Za-z0-9_-]{12}`).
511
+
512
+ Audit events via `deps.audit.emit` with `ip: ctx.client_ip`:
513
+ `session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. Every
514
+ gated event also records `credential_type` in metadata (mirrors REST
515
+ `password_change`).
516
+
517
+ Options: `max_tokens?: number | null` (defaults to `DEFAULT_MAX_TOKENS`;
518
+ `null` disables), `connection_closer?: ConnectionCloser | null`. Each handler
519
+ fires `close_sockets_for_*` synchronously BEFORE the audit emit. Failure
520
+ outcomes (`revoked: false`) skip the eager close mirrors the listener's
521
+ `outcome === 'failure'` guard so attacker-guessable ids can't target
522
+ arbitrary sockets.
523
+
524
+ ### Standard RPC bundle
525
+
526
+ `create_standard_rpc_actions(deps, options)` in `auth/standard_rpc_actions.ts`
527
+ spreads `create_admin_actions`, `create_role_grant_offer_actions`, and
528
+ `create_account_actions` into a single `Array<RpcAction>` — the canonical
529
+ fuz_app "standard" surface (25 actions with `app_settings` wired, 23
530
+ without). Frontend mirror is `all_standard_action_specs` in
531
+ `auth/standard_action_specs.ts`.
532
+
533
+ Option routing — `roles` is shared between admin + role-grant-offer;
534
+ `app_settings` admin only; `default_ttl_ms` + `authorize` role-grant-offer
535
+ only; `max_tokens` account only; `connection_closer` admin + account;
536
+ `notification_sender` role-grant-offer only.
537
+
538
+ Pair with `create_app_server`'s `rpc_endpoints` factory form
539
+ `(ctx) => Array<RpcEndpointSpec>` so the combined action list gets
540
+ `ctx.deps` + `ctx.app_settings`. `create_app_server` auto-mounts the
541
+ endpoint via `create_rpc_endpoint`. To expose the standard surface over
542
+ WebSocket as well, spread `protocol_actions` and the same factory into
543
+ `ws_endpoints` per-message authorization and rate limiting fire
544
+ identically across HTTP RPC and WS.
545
+
546
+ Bundling account actions into the "standard" surface is deliberate: the
547
+ admin integration suite exercises `account_token_create` / `_revoke` for
548
+ cross-account isolation, so a consumer wiring the admin surface without
549
+ account actions hits `method_not_found` on first admin-suite run.
550
+
551
+ ### Self-service role toggle
552
+
553
+ `create_self_service_role_actions(deps, {eligible_roles?, roles?})` in
554
+ `auth/self_service_role_actions.ts`. One static action
555
+ `self_service_role_set({role, enabled})` toggles a global role_grant on the
556
+ caller. Idempotent in both directions (`changed: false` when the post-call
557
+ state already matched).
558
+
1624
559
  Audit metadata carries `self_service: true` so admin reviewers can
1625
- distinguish self-toggled role_grants from admin grants/offers. The
1626
- `role_grant_create` / `role_grant_revoke` metadata schemas declare
1627
- `self_service: z.boolean().optional()` explicitly, so the field is
1628
- part of the documented surface rather than riding on `z.looseObject`
1629
- permissiveness.
1630
-
1631
- Declares `rate_limit: 'account'` — every call writes a
1632
- `role_grant_create` / `role_grant_revoke` audit row regardless of
1633
- `changed`, so a flapping loop could inflate the log and obscure
1634
- unrelated activity. The toggle's idempotency doesn't bound the burn
1635
- rate; the dispatcher's per-action hook does.
1636
-
1637
- Method name is static — `role` lives in the input, not the method
1638
- name. Mirrors the `role_grant_offer_create({role})` precedent. Per-role
560
+ distinguish self-toggled role_grants. Eligibility derives from
561
+ `roles.role_specs` by selecting roles with `'self_service' grant_paths`;
562
+ override via `eligible_roles`. Roles outside the eligible set are rejected
563
+ with `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`.
564
+
565
+ Method name is static (`role` lives in input, not method) — per-role
1639
566
  parameterized methods would break the `satisfies RequestResponseActionSpec`
1640
- codegen invariant and grow the surface linearly per role.
1641
-
1642
- `create_self_service_role_actions(deps, options)`:
1643
-
1644
- - `eligible_roles?: ReadonlyArray<string>` — optional override
1645
- allowlist. When omitted, eligibility is derived from
1646
- `roles.role_specs` (or `builtin_role_specs_by_name` when `roles` is
1647
- also omitted) by selecting every role whose `RoleSpec.grant_paths`
1648
- includes `'self_service'` (`GRANT_PATH_SELF_SERVICE`). Roles outside
1649
- the eligible set are rejected with `forbidden` + reason
1650
- `role_not_self_service_eligible` (exported as
1651
- `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`). The eligibility check fires
1652
- before the `enabled` branch same rejection regardless of direction.
1653
- - `roles?: RoleSchemaResult` drives default-eligibility derivation
1654
- from `RoleSpec.grant_paths`. When `eligible_roles` is also supplied,
1655
- every entry is checked against `roles.role_specs` at factory time so
1656
- typos throw at startup instead of at first call.
1657
-
1658
- Grant branch uses `has_scoped_role(auth, role, null)` for a
1659
- benign-TOCTOU pre-check (distinguishes new grant from idempotent
1660
- re-grant) reads from the in-memory `auth.role_grants` snapshot, no DB
1661
- roundtrip then `query_create_role_grant` for the actual insert. Revoke branch filters
1662
- `query_role_grant_find_active_for_actor` in JS for the matching
1663
- `(actor, role, scope_id IS NULL)` row before calling
1664
- `query_revoke_role_grant`. Bundle is **not** included in
1665
- `create_standard_rpc_actions` — `eligible_roles` is app-specific, opt-in,
1666
- spread alongside the standard bundle when needed.
1667
-
1668
- Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
1669
-
1670
- `all_self_service_role_action_specs: ReadonlyArray<RequestResponseActionSpec>`
1671
- codegen-ready registry of the single unified spec.
1672
-
1673
- ### `actor_lookup_action_specs.ts` + `actor_lookup_actions.ts` — opt-in batched actor → label resolver
1674
-
1675
- One static `request_response` action — `actor_lookup({ids}) → {actors:
1676
- [{id, username, display_name?}]}` — powers the labels arc for surfaces
1677
- that stamp an actor id (bylines, owner columns, grantor labels, audit
1678
- "by" cells). One round trip resolves a batch to display strings;
1679
- `ACTOR_LOOKUP_IDS_MAX = 50` cap per call.
1680
-
1681
- **Auth + rate-limit posture.** `{account: 'required', actor: 'none'}` +
1682
- `rate_limit: 'account'`. Account-grain — the caller need only be signed
1683
- in; resolution skips the actor phase. The auth gate + per-account rate
1684
- limit + per-call cap bound the batched username-enumeration surface that
1685
- a `cell_list` ↔ `actor_lookup` pair would otherwise present. Don't loosen
1686
- to public — a public-surface byline should resolve via SSR-stamped labels
1687
- or per-cell embedded actor labels, not by widening this gate.
1688
-
1689
- **Wire shape — info-leak audit.** Deliberately omitted from
1690
- `ActorLookupEntryJson`:
1691
-
1692
- - `account_id` — the actor↔account join is a control-plane detail.
1693
- - `email`, password/credential fields — never queried.
1694
- - `created_at` / `updated_at` — timing-oracle avoidance.
1695
- - role / role_grants / session state — separation of concern.
1696
-
1697
- `display_name` is omitted (not `null`) when `actor.name` is blank, so
1698
- clients see `undefined` rather than a sentinel string. Unknown ids are
1699
- silently absent — by construction this is an existence-oracle (caller
1700
- diffs response ids against request ids), bounded by rate-limit, the
1701
- 50-id cap, actor-uuid intractability (122-bit random), and the
1702
- hard-delete-cascade indistinguishability from never-existed (no
1703
- tombstone oracle). Response order is unspecified — callers index by
1704
- `id` when needed.
1705
-
1706
- `create_actor_lookup_actions(deps)` — `deps:
1707
- Pick<RouteFactoryDeps, 'log'>`. Pure read; no audit, no side effects.
1708
- Backed by `query_actors_by_ids` (see Queries §
1709
- [`actor_lookup_queries.ts`](#actor_lookup_queriests)).
1710
-
1711
- Bundle is **not** included in `create_standard_rpc_actions` — consumers
1712
- without a byline surface can skip it. Spread
1713
- `all_actor_lookup_action_specs` alongside the standard bundle when the
1714
- labels arc is needed.
1715
-
1716
- ### `actor_search_action_specs.ts` + `actor_search_actions.ts` — opt-in prefix-search picker
1717
-
1718
- One static `request_response` action — `actor_search({query, scope_ids?, limit?}) → {actors: [{id, username, display_name?}]}` —
1719
- powers person-target pickers. Sibling to `actor_lookup`: that resolves a
1720
- batch of known ids to labels; this resolves a partial name to candidate
1721
- actors. Reuses `ActorLookupEntryJson` from
1722
- `auth/actor_lookup_action_specs.ts` so the labels arc on the consumer side
1723
- stays uniform. Default limit `ACTOR_SEARCH_LIMIT_DEFAULT = 20`, hard cap
1724
- `ACTOR_SEARCH_LIMIT_MAX = 50`. Query string capped at
1725
- `ACTOR_SEARCH_QUERY_LENGTH_MAX = 50`.
1726
-
1727
- **Auth + rate-limit posture.** `{account: 'required', actor: 'none'}` +
1728
- `rate_limit: 'account'`. Same shape as `actor_lookup`. The handler
1729
- additionally requires the caller to be admin when `scope_ids` is empty —
1730
- unbounded global search is the admin-only arm; non-admin callers must
1731
- always pass at least one scope_id and get filtered to actors with active
1732
- role_grants on those scopes. The admin check is **account-grain** (any
1733
- actor on the caller's account holds a global `admin` role_grant) via
1734
- `query_account_has_global_role` — the `actor: 'none'` posture means
1735
- `auth.role_grants` is empty and the in-memory `has_scoped_role` helper
1736
- doesn't apply.
1737
-
1738
- **Caller-passes-scope_ids design.** `scope_ids` is trusted as a filter,
1739
- not as an authority claim — the SQL filters to actors with role_grants on
1740
- those scopes regardless of whether the caller has authority over them.
1741
- Consumers (e.g. visiones' `CellGrantsEditor.svelte`) pre-filter
1742
- `scope_ids` against their own authority (the teacher-predicate stays in
1743
- the consumer layer, not in fuz_app). This does **not** widen the
1744
- scope-existence oracle: an attacker passing a random scope_id never
1745
- learns the scope existed if no member happens to match the query, and
1746
- even on a match the result row only carries the matching actor — never
1747
- "which scope matched".
1748
-
1749
- **Wire shape — additional info-leak posture beyond `actor_lookup`'s.**
1750
-
1751
- - Prefix match (`LOWER(name) LIKE LOWER(query) || '%' ESCAPE '\\'`),
1752
- **not** full `%query%`. LIKE wildcards in the user-supplied query are
1753
- escaped at the JS layer so a `%xyz` input can't widen to full LIKE
1754
- and defeat the per-call cap.
1755
- - Empty result set on no-match — fail-soft like `cell_list`. No "no
1756
- actor matches" error that would leak an existence boundary on the
1757
- search-term axis.
1758
- - Hard-deleted actors silently drop (same `account_id` cascade as
1759
- `actor_lookup`).
1760
-
1761
- Reason constant exported for failed-arm matching: `ERROR_ACTOR_SEARCH_SCOPE_REQUIRED`
1762
- (`'actor_search_scope_required'`) — fired with `invalid_params` when a
1763
- non-admin caller omits `scope_ids` or passes `[]`. Surfaced on
1764
- `spec.error_reasons` so codegen + form-state matching can read it
1765
- declaratively.
1766
-
1767
- `create_actor_search_actions(deps)` — `deps:
1768
- Pick<RouteFactoryDeps, 'log'>`. Pure read; no audit, no side effects.
1769
- Backed by `query_actor_search` (see Queries §`actor_search_queries.ts`).
1770
-
1771
- Bundle is **not** included in `create_standard_rpc_actions` — consumers
1772
- without a person-target picker can skip it. Spread
1773
- `all_actor_search_action_specs` alongside the standard bundle when the
1774
- picker is needed.
567
+ codegen invariant.
568
+
569
+ Bundle **not** included in `create_standard_rpc_actions` — `eligible_roles`
570
+ is app-specific.
571
+
572
+ ### Actor lookup / actor search
573
+
574
+ Two opt-in helpers for surfaces that stamp actor ids (bylines, owner
575
+ columns, grantor labels, picker UIs):
576
+
577
+ - `create_actor_lookup_actions(deps)` — `actor_lookup({ids}) → {actors}`,
578
+ batched id → label resolver. `ACTOR_LOOKUP_IDS_MAX = 50`.
579
+ - `create_actor_search_actions(deps)` — `actor_search({query, scope_ids?, limit?}) {actors}`,
580
+ prefix search. Default limit `ACTOR_SEARCH_LIMIT_DEFAULT = 20`, cap
581
+ `_MAX = 50`. Non-admin callers must pass `scope_ids` (filtered to actors
582
+ holding active role_grants on those scopes); admin-only when `scope_ids`
583
+ is empty. `ERROR_ACTOR_SEARCH_SCOPE_REQUIRED` on non-admin + empty
584
+ `scope_ids`.
585
+
586
+ Both: `auth: {account: 'required', actor: 'none'}` + `rate_limit: 'account'`,
587
+ pure reads (no audit, no side effects). `ActorLookupEntryJson` deliberately
588
+ omits `account_id`, `email`, credentials, timestamps, and role state
589
+ control-plane details, timing-oracle avoidance, separation of concern. LIKE
590
+ wildcards in the user-supplied query are escaped at the JS layer so
591
+ `%xyz`-style inputs can't widen the per-call cap.
592
+
593
+ Bundle **not** included in `create_standard_rpc_actions`.
594
+
595
+ ### `admin_rpc_adapters.ts` (in `ui/`)
596
+
597
+ `create_admin_rpc_adapters(api)` + `provide_admin_rpc_contexts(adapters)`
598
+ single-call wiring for the four admin RPC contexts (`admin_accounts`,
599
+ `admin_invites`, `audit_log`, `app_settings`). One line at the admin shell
600
+ drops the hand-maintained method-name mappings:
601
+ `provide_admin_rpc_contexts(create_admin_rpc_adapters(api))`.
1775
602
 
1776
603
  ## Cleanup
1777
604
 
1778
- `cleanup.ts` — periodic auth maintenance:
1779
-
1780
- - `AuthCleanupDeps = QueryDeps & {log, audit: AuditEmitter}`.
1781
- Required production wiring always has a bound emitter (built once
1782
- at `create_app_backend`); tests that need a no-op pass
1783
- `create_test_audit_emitter()` from `testing/stubs.ts`. Single slot
1784
- carries both row persistence and SSE/WS fan-out.
1785
- - `cleanup_expired_role_grant_offers(deps)` — wraps `query_role_grant_offer_sweep_expired`,
1786
- emits one `role_grant_offer_expire` audit row per expired offer
1787
- through `audit.emit_pool` (the captured pool + config + chain). Both
1788
- write errors and per-listener throws are logged + swallowed inside
1789
- `emit_pool`, so a single bad row never starves sibling sweeps.
1790
- - `run_auth_cleanup(deps)` — one-shot consumer entry point: expired
1791
- sessions + expired offers. Returns `{expired_sessions, expired_offers}`.
1792
- **Re-throws sweep errors** so the caller's scheduler can log / alert.
1793
- Call from `setInterval` / cron / similar.
1794
-
1795
- Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
1796
- concurrent sweep runs double-audit. Deploy a single scheduled invocation
1797
- per instance — matches `query_session_cleanup_expired`'s expected pattern.
1798
- Expired offer rows are **preserved** (not deleted) — they carry audit value
1799
- for the history view, and accepted rows are the provenance for the
1800
- resulting role_grant.
1801
-
1802
- ## Deps
1803
-
1804
- `deps.ts` defines:
1805
-
1806
- - **`AppDeps`** — the stateless capabilities bundle. Seven members:
1807
- - `stat`, `read_text_file`, `delete_file` — filesystem.
1808
- - `keyring: Keyring` — HMAC-SHA256 signing.
1809
- - `password: PasswordHashDeps` — use `argon2_password_deps` in production.
1810
- - `db: Db` — pool-level instance (middleware uses this; route handlers
1811
- get a transaction-scoped `Db` via `RouteContext`).
1812
- - `log: Logger`.
1813
- - `audit: AuditEmitter` — bound emitter built once at `create_app_backend`
1814
- via `create_audit_emitter`. Closes over the pool, the
1815
- `on_audit_event` subscriber chain, and the optional
1816
- `AuditLogConfig` so handlers reach `audit.emit(ctx, input)` /
1817
- `audit.emit_role_grant_target(ctx, auth, input)` and never see the
1818
- pool. Pass `on_audit_event` and `audit_log_config` to
1819
- `create_app_backend` — both fold into `audit`'s closure and the slot
1820
- is the single seam for SSE/WS fan-out (additional listeners append
1821
- via `audit.on_event_chain.push(...)` at server assembly).
1822
- - **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
1823
- handlers receive DB access via `RouteContext`, so factories don't capture
1824
- a pool-level `Db`.
1825
-
1826
- Action factories take `Pick<RouteFactoryDeps, 'log' | 'audit'>` directly
1827
- (role-grant-offer adds `notification_sender?` inline).
1828
-
1829
- See root ../../../CLAUDE.md §AppDeps Vocabulary for the
1830
- capability / options / runtime-state split across the whole project.
605
+ `auth/cleanup.ts` — `run_auth_cleanup(deps)` runs every sweep (expired
606
+ sessions + expired offers) and returns counts. Re-throws sweep errors so the
607
+ caller's scheduler can log/alert. Idempotency: audit log has no tombstone on
608
+ `role_grant_offer_expire`, so concurrent runs double-audit deploy a single
609
+ scheduled invocation per instance. Expired offer rows are preserved (audit
610
+ value for the history view).
611
+
612
+ `AuthCleanupDeps` requires `audit: AuditEmitter` — production wiring always
613
+ has a bound emitter; tests pass `create_test_audit_emitter()` from
614
+ `testing/stubs.ts`.