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