@fuzdev/fuz_app 0.30.0 → 0.32.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 (222) hide show
  1. package/dist/actions/CLAUDE.md +630 -0
  2. package/dist/actions/action_rpc.d.ts +29 -0
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +42 -6
  5. package/dist/actions/action_types.d.ts +2 -2
  6. package/dist/actions/cancel.d.ts +12 -13
  7. package/dist/actions/cancel.d.ts.map +1 -1
  8. package/dist/actions/cancel.js +10 -13
  9. package/dist/actions/heartbeat.d.ts +8 -13
  10. package/dist/actions/heartbeat.d.ts.map +1 -1
  11. package/dist/actions/heartbeat.js +5 -8
  12. package/dist/actions/register_action_ws.d.ts +3 -3
  13. package/dist/actions/register_action_ws.js +2 -2
  14. package/dist/actions/register_ws_endpoint.d.ts +4 -4
  15. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  16. package/dist/actions/register_ws_endpoint.js +3 -3
  17. package/dist/actions/rpc_client.d.ts +29 -0
  18. package/dist/actions/rpc_client.d.ts.map +1 -1
  19. package/dist/actions/rpc_client.js +31 -0
  20. package/dist/actions/socket.svelte.d.ts +16 -16
  21. package/dist/actions/socket.svelte.d.ts.map +1 -1
  22. package/dist/actions/socket.svelte.js +15 -15
  23. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  24. package/dist/auth/CLAUDE.md +945 -0
  25. package/dist/auth/account_action_specs.d.ts +216 -0
  26. package/dist/auth/account_action_specs.d.ts.map +1 -0
  27. package/dist/auth/account_action_specs.js +159 -0
  28. package/dist/auth/account_actions.d.ts +51 -0
  29. package/dist/auth/account_actions.d.ts.map +1 -0
  30. package/dist/auth/account_actions.js +119 -0
  31. package/dist/auth/account_queries.d.ts +6 -2
  32. package/dist/auth/account_queries.d.ts.map +1 -1
  33. package/dist/auth/account_queries.js +40 -4
  34. package/dist/auth/account_routes.d.ts +94 -16
  35. package/dist/auth/account_routes.d.ts.map +1 -1
  36. package/dist/auth/account_routes.js +108 -180
  37. package/dist/auth/account_schema.d.ts +85 -30
  38. package/dist/auth/account_schema.d.ts.map +1 -1
  39. package/dist/auth/account_schema.js +40 -8
  40. package/dist/auth/admin_action_specs.d.ts +674 -0
  41. package/dist/auth/admin_action_specs.d.ts.map +1 -0
  42. package/dist/auth/admin_action_specs.js +287 -0
  43. package/dist/auth/admin_actions.d.ts +69 -0
  44. package/dist/auth/admin_actions.d.ts.map +1 -0
  45. package/dist/auth/admin_actions.js +256 -0
  46. package/dist/auth/admin_rpc_actions.d.ts +49 -0
  47. package/dist/auth/admin_rpc_actions.d.ts.map +1 -0
  48. package/dist/auth/admin_rpc_actions.js +32 -0
  49. package/dist/auth/api_token.d.ts +10 -0
  50. package/dist/auth/api_token.d.ts.map +1 -1
  51. package/dist/auth/api_token.js +9 -0
  52. package/dist/auth/api_token_queries.d.ts +3 -3
  53. package/dist/auth/api_token_queries.js +3 -3
  54. package/dist/auth/app_settings_schema.d.ts +4 -3
  55. package/dist/auth/app_settings_schema.d.ts.map +1 -1
  56. package/dist/auth/app_settings_schema.js +2 -1
  57. package/dist/auth/audit_log_routes.d.ts +14 -6
  58. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  59. package/dist/auth/audit_log_routes.js +22 -79
  60. package/dist/auth/audit_log_schema.d.ts +100 -29
  61. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  62. package/dist/auth/audit_log_schema.js +83 -11
  63. package/dist/auth/bootstrap_routes.d.ts +14 -0
  64. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  65. package/dist/auth/bootstrap_routes.js +10 -3
  66. package/dist/auth/cleanup.d.ts +63 -0
  67. package/dist/auth/cleanup.d.ts.map +1 -0
  68. package/dist/auth/cleanup.js +80 -0
  69. package/dist/auth/invite_schema.d.ts +11 -10
  70. package/dist/auth/invite_schema.d.ts.map +1 -1
  71. package/dist/auth/invite_schema.js +4 -3
  72. package/dist/auth/migrations.d.ts +6 -0
  73. package/dist/auth/migrations.d.ts.map +1 -1
  74. package/dist/auth/migrations.js +28 -0
  75. package/dist/auth/permit_offer_action_specs.d.ts +364 -0
  76. package/dist/auth/permit_offer_action_specs.d.ts.map +1 -0
  77. package/dist/auth/permit_offer_action_specs.js +216 -0
  78. package/dist/auth/permit_offer_actions.d.ts +96 -0
  79. package/dist/auth/permit_offer_actions.d.ts.map +1 -0
  80. package/dist/auth/permit_offer_actions.js +428 -0
  81. package/dist/auth/permit_offer_notifications.d.ts +361 -0
  82. package/dist/auth/permit_offer_notifications.d.ts.map +1 -0
  83. package/dist/auth/permit_offer_notifications.js +179 -0
  84. package/dist/auth/permit_offer_queries.d.ts +165 -0
  85. package/dist/auth/permit_offer_queries.d.ts.map +1 -0
  86. package/dist/auth/permit_offer_queries.js +390 -0
  87. package/dist/auth/permit_offer_schema.d.ts +103 -0
  88. package/dist/auth/permit_offer_schema.d.ts.map +1 -0
  89. package/dist/auth/permit_offer_schema.js +142 -0
  90. package/dist/auth/permit_queries.d.ts +77 -14
  91. package/dist/auth/permit_queries.d.ts.map +1 -1
  92. package/dist/auth/permit_queries.js +119 -24
  93. package/dist/auth/session_queries.d.ts +4 -2
  94. package/dist/auth/session_queries.d.ts.map +1 -1
  95. package/dist/auth/session_queries.js +4 -2
  96. package/dist/auth/signup_routes.d.ts +13 -0
  97. package/dist/auth/signup_routes.d.ts.map +1 -1
  98. package/dist/auth/signup_routes.js +14 -7
  99. package/dist/http/CLAUDE.md +584 -0
  100. package/dist/http/pending_effects.d.ts +29 -0
  101. package/dist/http/pending_effects.d.ts.map +1 -0
  102. package/dist/http/pending_effects.js +31 -0
  103. package/dist/http/route_spec.d.ts.map +1 -1
  104. package/dist/http/route_spec.js +4 -3
  105. package/dist/rate_limiter.d.ts +30 -0
  106. package/dist/rate_limiter.d.ts.map +1 -1
  107. package/dist/rate_limiter.js +25 -2
  108. package/dist/realtime/sse_auth_guard.d.ts +2 -0
  109. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  110. package/dist/realtime/sse_auth_guard.js +5 -3
  111. package/dist/server/app_server.d.ts +13 -2
  112. package/dist/server/app_server.d.ts.map +1 -1
  113. package/dist/server/app_server.js +12 -1
  114. package/dist/testing/CLAUDE.md +668 -1
  115. package/dist/testing/admin_integration.d.ts +10 -7
  116. package/dist/testing/admin_integration.d.ts.map +1 -1
  117. package/dist/testing/admin_integration.js +382 -482
  118. package/dist/testing/app_server.d.ts +7 -6
  119. package/dist/testing/app_server.d.ts.map +1 -1
  120. package/dist/testing/attack_surface.d.ts +9 -3
  121. package/dist/testing/attack_surface.d.ts.map +1 -1
  122. package/dist/testing/attack_surface.js +4 -4
  123. package/dist/testing/audit_completeness.d.ts +11 -0
  124. package/dist/testing/audit_completeness.d.ts.map +1 -1
  125. package/dist/testing/audit_completeness.js +169 -134
  126. package/dist/testing/auth_apps.d.ts.map +1 -1
  127. package/dist/testing/auth_apps.js +4 -33
  128. package/dist/testing/db.d.ts +1 -1
  129. package/dist/testing/db.d.ts.map +1 -1
  130. package/dist/testing/db.js +2 -0
  131. package/dist/testing/entities.d.ts +35 -13
  132. package/dist/testing/entities.d.ts.map +1 -1
  133. package/dist/testing/entities.js +17 -0
  134. package/dist/testing/integration.d.ts +10 -0
  135. package/dist/testing/integration.d.ts.map +1 -1
  136. package/dist/testing/integration.js +352 -340
  137. package/dist/testing/integration_helpers.d.ts +16 -5
  138. package/dist/testing/integration_helpers.d.ts.map +1 -1
  139. package/dist/testing/integration_helpers.js +24 -4
  140. package/dist/testing/rate_limiting.d.ts +7 -0
  141. package/dist/testing/rate_limiting.d.ts.map +1 -1
  142. package/dist/testing/rate_limiting.js +41 -10
  143. package/dist/testing/rpc_helpers.d.ts +153 -1
  144. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  145. package/dist/testing/rpc_helpers.js +184 -8
  146. package/dist/testing/sse_round_trip.d.ts +8 -0
  147. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  148. package/dist/testing/sse_round_trip.js +10 -3
  149. package/dist/testing/standard.d.ts +9 -1
  150. package/dist/testing/standard.d.ts.map +1 -1
  151. package/dist/testing/standard.js +6 -2
  152. package/dist/testing/stubs.d.ts +10 -2
  153. package/dist/testing/stubs.d.ts.map +1 -1
  154. package/dist/testing/stubs.js +17 -2
  155. package/dist/testing/surface_invariants.d.ts +7 -3
  156. package/dist/testing/surface_invariants.d.ts.map +1 -1
  157. package/dist/testing/surface_invariants.js +5 -4
  158. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  159. package/dist/testing/ws_round_trip.js +9 -38
  160. package/dist/ui/AccountSessions.svelte +8 -4
  161. package/dist/ui/AccountSessions.svelte.d.ts.map +1 -1
  162. package/dist/ui/AdminAccounts.svelte +61 -33
  163. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  164. package/dist/ui/AdminAuditLog.svelte +3 -2
  165. package/dist/ui/AdminAuditLog.svelte.d.ts.map +1 -1
  166. package/dist/ui/AdminInvites.svelte +3 -2
  167. package/dist/ui/AdminInvites.svelte.d.ts.map +1 -1
  168. package/dist/ui/AdminOverview.svelte +14 -9
  169. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  170. package/dist/ui/AdminPermitHistory.svelte +3 -2
  171. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +1 -1
  172. package/dist/ui/AdminSessions.svelte +29 -25
  173. package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
  174. package/dist/ui/CLAUDE.md +363 -0
  175. package/dist/ui/OpenSignupToggle.svelte +6 -3
  176. package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
  177. package/dist/ui/PermitOfferForm.svelte +141 -0
  178. package/dist/ui/PermitOfferForm.svelte.d.ts +14 -0
  179. package/dist/ui/PermitOfferForm.svelte.d.ts.map +1 -0
  180. package/dist/ui/PermitOfferHistory.svelte +109 -0
  181. package/dist/ui/PermitOfferHistory.svelte.d.ts +11 -0
  182. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +1 -0
  183. package/dist/ui/PermitOfferInbox.svelte +121 -0
  184. package/dist/ui/PermitOfferInbox.svelte.d.ts +12 -0
  185. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +1 -0
  186. package/dist/ui/account_sessions_state.svelte.d.ts +53 -3
  187. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  188. package/dist/ui/account_sessions_state.svelte.js +39 -16
  189. package/dist/ui/admin_accounts_state.svelte.d.ts +118 -2
  190. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  191. package/dist/ui/admin_accounts_state.svelte.js +99 -23
  192. package/dist/ui/admin_invites_state.svelte.d.ts +47 -1
  193. package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
  194. package/dist/ui/admin_invites_state.svelte.js +38 -26
  195. package/dist/ui/admin_rpc_adapters.d.ts +94 -0
  196. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -0
  197. package/dist/ui/admin_rpc_adapters.js +100 -0
  198. package/dist/ui/admin_sessions_state.svelte.d.ts +26 -0
  199. package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
  200. package/dist/ui/admin_sessions_state.svelte.js +35 -21
  201. package/dist/ui/app_settings_state.svelte.d.ts +39 -0
  202. package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
  203. package/dist/ui/app_settings_state.svelte.js +34 -18
  204. package/dist/ui/audit_log_state.svelte.d.ts +40 -3
  205. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  206. package/dist/ui/audit_log_state.svelte.js +36 -42
  207. package/dist/ui/auth_state.svelte.d.ts +4 -3
  208. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  209. package/dist/ui/auth_state.svelte.js +4 -1
  210. package/dist/ui/permit_offers_state.svelte.d.ts +125 -0
  211. package/dist/ui/permit_offers_state.svelte.d.ts.map +1 -0
  212. package/dist/ui/permit_offers_state.svelte.js +197 -0
  213. package/package.json +3 -3
  214. package/dist/auth/admin_routes.d.ts +0 -29
  215. package/dist/auth/admin_routes.d.ts.map +0 -1
  216. package/dist/auth/admin_routes.js +0 -226
  217. package/dist/auth/app_settings_routes.d.ts +0 -27
  218. package/dist/auth/app_settings_routes.d.ts.map +0 -1
  219. package/dist/auth/app_settings_routes.js +0 -66
  220. package/dist/auth/invite_routes.d.ts +0 -18
  221. package/dist/auth/invite_routes.d.ts.map +0 -1
  222. package/dist/auth/invite_routes.js +0 -129
@@ -0,0 +1,945 @@
1
+ # auth/
2
+
3
+ > Auth domain: identity, crypto primitives, schema + DDL, queries, middleware, routes, RPC actions, cleanup.
4
+
5
+ Forty source files, grouped below by theme. For design rationale and threat
6
+ model, see `../../../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`.
9
+
10
+ The DI vocabulary is the stack standard: stateless capabilities in
11
+ `AppDeps` / `RouteFactoryDeps`; static config in `*Options`; runtime state
12
+ (e.g. `DaemonTokenState`, mutable `AppSettings` ref, `BootstrapStatus`) is
13
+ inline, never in `deps`. All `query_*` functions take `deps: QueryDeps = {db}`
14
+ as their first arg.
15
+
16
+ ## Crypto primitives
17
+
18
+ Pure, I/O-free operations. Framework-dependent middleware lives in later
19
+ sections.
20
+
21
+ | Module | Exports |
22
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult` |
24
+ | `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `SESSION_COOKIE_OPTIONS`, `SESSION_AGE_MAX`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
25
+ | `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
26
+ | `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
27
+ | `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
28
+ | `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
29
+ | `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult` |
30
+
31
+ Design notes:
32
+
33
+ - **Keyring** encapsulates secrets — only `sign` / `verify` are exposed, keys
34
+ never leave the closure. `__` separator splits multiple rotation keys;
35
+ first key signs, all keys verify. Old keys remain valid for verification
36
+ indefinitely — rotating `SECRET_COOKIE_KEYS` is a security-critical deploy.
37
+ Minimum key length is 32 chars.
38
+ - **Session cookie** encodes `${identity}:${expires_at}` and HMAC-SHA256
39
+ signs the concatenation. Expiration is embedded in the signed value (not
40
+ only in the cookie `Max-Age`) for defense-in-depth. `TIdentity` is generic:
41
+ `string` for session-id references (server-side sessions, per-session
42
+ revocation), `number` for direct account-id references (no server state).
43
+ The canonical fuz pattern is `SessionOptions<string>` via
44
+ `create_session_config(name)`.
45
+ - **Password** has two schemas deliberately. `Password` enforces the current
46
+ length policy (used at account creation and password change);
47
+ `PasswordProvided` is minimal (`min(1)`) for login / verification so a
48
+ tightened policy does not lock out existing accounts. Both carry
49
+ `sensitivity: 'secret'` meta.
50
+ - **Argon2id** uses OWASP parameters (`memoryCost: 19456`, `timeCost: 2`,
51
+ `parallelism: 1`) via `@node-rs/argon2`. `verify_dummy` returns `false` but
52
+ takes the same time as a real verification — call on account-lookup miss
53
+ to equalize timing. The dummy hash is memoized.
54
+ - **API token** format is `secret_fuz_token_<base64url>`. Prefix enables
55
+ secret scanning (GitHub, TruffleHog, etc.); public `id` is `tok_<12 chars>`;
56
+ storage key is the blake3 hash. Raw token is returned exactly once.
57
+ - **Daemon token** is a 43-char base64url (256 bits). Validation is
58
+ timing-safe and accepts both `current_token` and `previous_token` during
59
+ the rotation race window. Pure primitives only — rotation lifecycle lives
60
+ in `daemon_token_middleware.ts`.
61
+ - **Bootstrap account** is one-shot; protected by the `bootstrap_lock` table
62
+ via atomic `UPDATE ... WHERE id = 1 AND bootstrapped = false RETURNING id`.
63
+ Token read + password hash happen outside the transaction (CPU + I/O);
64
+ lock acquisition + account + actor + two permits (`keeper` and `admin`)
65
+ happen inside. On commit, the token file is deleted — if that fails,
66
+ `token_file_deleted: false` is returned and the caller is expected to
67
+ surface an error (the `/bootstrap` handler throws so the operator gets a
68
+ loud signal). Provided tokens are **not** trimmed — only `expected_token`
69
+ is (tokens must match on disk exactly).
70
+
71
+ ## Schemas, types, and DDL
72
+
73
+ | Module | What's inside |
74
+ | ------------------------------- | ----------------------------------------------------------------------------------------- |
75
+ | `account_schema.ts` | Runtime types + client-safe Zod schemas for identity entities |
76
+ | `role_schema.ts` | Role vocabulary and extensibility |
77
+ | `ddl.ts` | Raw `CREATE TABLE` / index / seed SQL strings |
78
+ | `invite_schema.ts` | `Invite`, `InviteJson`, `InviteWithUsernamesJson`, `CreateInviteInput` |
79
+ | `app_settings_schema.ts` | `AppSettings`, `AppSettingsJson`, `AppSettingsWithUsernameJson`, `UpdateAppSettingsInput` |
80
+ | `audit_log_schema.ts` | Event-type enum, per-type metadata schemas, table DDL |
81
+ | `permit_offer_schema.ts` | Permit offer DDL, types, and client-safe schemas |
82
+ | `permit_offer_notifications.ts` | WS notification specs for the consentful-permits lifecycle |
83
+
84
+ ### Identity entities (`account_schema.ts`)
85
+
86
+ - `Account` (primary identity, holds `password_hash`), `Actor` (the entity
87
+ that acts — owns cells, holds permits, appears in audit trails; 1:1 with
88
+ account in v1), `Permit` (time-bounded, revocable grant of a role to an
89
+ actor — carries `scope_id`, `source_offer_id`, `revoked_reason`),
90
+ `AuthSession` (server-side, keyed by blake3), `ApiToken`.
91
+ - Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and
92
+ `*Input` types is branded `Uuid` (from `../uuid.ts`), except
93
+ `AuthSessionJson.id` (`Blake3Hash`) and `ClientApiTokenJson.id`
94
+ (`ApiTokenId` — `tok_`-prefixed).
95
+ - `Username`: `[a-zA-Z][0-9a-zA-Z_-]*[0-9a-zA-Z]` (3–39, GitHub parity).
96
+ `UsernameProvided`: `min(1).max(255)` — permissive for login/lookup so
97
+ tightening creation rules won't lock out existing users.
98
+ - `Email`: `z.email()`.
99
+ - `PERMIT_REVOKED_REASON_LENGTH_MAX = 500` — bounds both the admin input
100
+ and the `permit_revoke` WS payload.
101
+ - Client-safe Zod schemas (every exported schema has a same-named `z.infer`
102
+ type export):
103
+ - `SessionAccountJson` — strips sensitive fields from `Account`
104
+ - `AuthSessionJson` — `id` is the blake3 hash (safe for client)
105
+ - `ClientApiTokenJson` — excludes `token_hash`
106
+ - `PermitSummaryJson` — the client-safe permit shape carried by
107
+ `GET /api/account/status` and the admin account listing; includes
108
+ `scope_id` so clients can make per-scope auth decisions. Excludes
109
+ `revoked_at` / `revoked_by` / `revoked_reason` because the callers
110
+ that return it already filter to active permits.
111
+ - `ActorSummaryJson`
112
+ - `AdminAccountJson` extends `SessionAccountJson` with `updated_at` / `updated_by`
113
+ - `PendingOfferSummaryJson` — narrower than `PermitOfferJson`; omits
114
+ `message` and `decline_reason` so cross-admin visibility of the listing
115
+ does not expose grantor-authored text beyond what the audit log
116
+ discloses. `from_username` is resolved server-side so admins can see
117
+ whose pending offer is blocking a "+ role" button.
118
+ - `AdminAccountEntryJson` — composes `{account, actor, permits, pending_offers}`
119
+ - Converters: `to_session_account(account)`, `to_admin_account(account)`,
120
+ `is_permit_active(p, now?)`.
121
+ - Input types: `CreateAccountInput`, `GrantPermitInput` (with optional
122
+ `scope_id`, `source_offer_id`).
123
+
124
+ ### Role system (`role_schema.ts`)
125
+
126
+ - `RoleName`: lowercase letters + underscores, no leading/trailing
127
+ underscore.
128
+ - `ROLE_KEEPER = 'keeper'` (requires daemon token, not `web_grantable`).
129
+ - `ROLE_ADMIN = 'admin'` (web-grantable).
130
+ - `BUILTIN_ROLES`, `BuiltinRole` (Zod enum).
131
+ - `RoleOptions`: `requires_daemon_token`, `web_grantable` (defaults `false`
132
+ and `true`).
133
+ - `BUILTIN_ROLE_OPTIONS` — fixed, not overridable by consumers.
134
+ - `create_role_schema(app_roles)` — call once at startup; returns `{Role, role_options}`.
135
+ Collisions with builtin names throw at construction. Used by middleware
136
+ to check `requires_daemon_token` and by admin UI to filter `web_grantable`.
137
+
138
+ ### Raw DDL (`ddl.ts`)
139
+
140
+ Separated from runtime types to isolate DDL concerns. Consumed by
141
+ `migrations.ts`:
142
+
143
+ - `ACCOUNT_SCHEMA` (plus `ACCOUNT_EMAIL_INDEX`, `ACCOUNT_USERNAME_CI_INDEX`
144
+ — both case-insensitive partial uniques)
145
+ - `ACTOR_SCHEMA`, `ACTOR_INDEX`
146
+ - `PERMIT_SCHEMA`, `PERMIT_INDEXES` — v0 has `permit_actor_role_active_unique`
147
+ which is replaced in v1 with the scope-aware `permit_actor_role_scope_active_unique`
148
+ - `AUTH_SESSION_SCHEMA`, `AUTH_SESSION_INDEXES`
149
+ - `API_TOKEN_SCHEMA`, `API_TOKEN_INDEX`
150
+ - `BOOTSTRAP_LOCK_SCHEMA`, `BOOTSTRAP_LOCK_SEED` — seeded as `bootstrapped`
151
+ iff accounts already exist (fresh install: false; restoring into a
152
+ bootstrapped DB: true).
153
+ - `INVITE_SCHEMA`, `INVITE_INDEXES` — three partial uniques covering
154
+ email-unclaimed, username-unclaimed, plus a `claimed_at` index.
155
+ - `APP_SETTINGS_SCHEMA`, `APP_SETTINGS_SEED` — single-row via
156
+ `CHECK (id = 1)` constraint; seed is `ON CONFLICT DO NOTHING`.
157
+
158
+ ### Audit log (`audit_log_schema.ts`)
159
+
160
+ - `AUDIT_EVENT_TYPES` — 21 events covering auth + permit + offer + invite +
161
+ settings mutations. Offer lifecycle: `permit_offer_create` / `_accept` /
162
+ `_decline` / `_retract` / `_expire` / `_supersede`.
163
+ - `AuditEventType` (Zod enum), `AuditOutcome` (`'success' | 'failure'`).
164
+ - `AUDIT_METADATA_SCHEMAS` — per-type `z.looseObject`. Notable shapes:
165
+ - `permit_grant` metadata carries `scope_id`, optional `permit_id` (failed
166
+ grants omit — `web_grantable` denial never produces a row), optional
167
+ `source_offer_id`.
168
+ - `permit_revoke` metadata carries `scope_id`, optional `reason`.
169
+ - `permit_offer_create` metadata carries optional `offer_id` (failed
170
+ creates omit).
171
+ - `permit_offer_supersede` metadata carries
172
+ `reason: 'sibling_accepted' | 'permit_revoked'` + `cause_id` (accepted
173
+ offer id or revoked permit id).
174
+ - `AuditLogEvent` (row), `AuditLogInput<T>` (narrow metadata), `AuditLogListOptions`
175
+ (supports `since_seq` for SSE reconnection gap fill).
176
+ - Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`,
177
+ `PermitHistoryEventJson`, `AdminSessionJson`.
178
+ - `get_audit_metadata(event)` type-narrows metadata after checking `event_type`.
179
+ - DDL: `AUDIT_LOG_SCHEMA` (includes monotonically-increasing `seq SERIAL`
180
+ column for cursor-based gap fill), `AUDIT_LOG_INDEXES`.
181
+
182
+ ### Permit offer (`permit_offer_schema.ts`)
183
+
184
+ The consentful-permits surface. Key constants:
185
+
186
+ - `PERMIT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
187
+ inside `COALESCE(scope_id, sentinel)` in partial unique indexes to collapse
188
+ NULL scopes into a comparable value. Without this, Postgres's NULL-in-
189
+ unique-index quirk would allow duplicate global pending offers.
190
+ - `PERMIT_OFFER_MESSAGE_LENGTH_MAX = 500`.
191
+ - `PERMIT_OFFER_DEFAULT_TTL_MS` = 30 days (GitHub org-invite parity).
192
+
193
+ DDL:
194
+
195
+ - `PERMIT_OFFER_SCHEMA` carries four nullable terminal timestamps:
196
+ `accepted_at`, `declined_at`, `retracted_at`, **`superseded_at`** (fourth
197
+ terminal — obsoleted by sibling accept or revoke of the resulting permit).
198
+ Three CHECK constraints:
199
+ - `permit_offer_single_terminal` — at most one terminal timestamp set.
200
+ - `permit_offer_permit_iff_accepted` — `(accepted_at IS NOT NULL) = (resulting_permit_id IS NOT NULL)`.
201
+ - `permit_offer_reason_iff_declined` — `decline_reason` only on declined rows.
202
+ - `PERMIT_OFFER_PENDING_UNIQUE_INDEX` — partial unique on
203
+ `(to_account_id, role, COALESCE(scope_id, sentinel), from_actor_id)`
204
+ where all four terminal timestamps are null. Including `from_actor_id`
205
+ lets multiple grantors coexist (teacher A and B can both offer the same
206
+ student role). A same-grantor re-offer upserts the pending row. The
207
+ `ON CONFLICT` target in `query_permit_offer_create` must match this
208
+ expression literally.
209
+ - `PERMIT_OFFER_INBOX_INDEX` — `(to_account_id, expires_at)` partial on
210
+ pending rows, soonest-expiry first.
211
+
212
+ Types:
213
+
214
+ - `PermitOffer` (row), `SupersededOffer` (row + `from_account_id` joined
215
+ via `actor` — carried so callers fan out `permit_offer_supersede`
216
+ notifications without a second round trip).
217
+ - `CreatePermitOfferInput` (`expires_at` is required — query layer applies
218
+ no default).
219
+ - `PermitOfferJson` (with `.meta({description})` on every field) paired
220
+ with `to_permit_offer_json(offer)`.
221
+
222
+ ### WS notifications (`permit_offer_notifications.ts`)
223
+
224
+ Six `RemoteNotificationActionSpec`s fan notifications to affected sockets:
225
+
226
+ | Method | Fires to | Payload |
227
+ | ------------------------ | ---------------------------------- | --------------------------------------------------------------------- |
228
+ | `permit_offer_received` | Recipient | `{offer: PermitOfferJson}` |
229
+ | `permit_offer_retracted` | Recipient | `{offer: PermitOfferJson}` |
230
+ | `permit_offer_accepted` | Grantor | `{offer: PermitOfferJson}` |
231
+ | `permit_offer_declined` | Grantor | `{offer: PermitOfferJson}` (decline reason on `offer.decline_reason`) |
232
+ | `permit_offer_supersede` | Grantor (sibling / revoked-permit) | `{offer, reason: 'sibling_accepted' \| 'permit_revoked', cause_id}` |
233
+ | `permit_revoke` | Revokee | `{permit_id, role, scope_id, reason?}` |
234
+
235
+ Method constants: `PERMIT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
236
+ `_RETRACTED_`, `_ACCEPTED_`, `_DECLINED_`, `_SUPERSEDE_`,
237
+ `PERMIT_REVOKE_NOTIFICATION_METHOD`. Zod params schemas with inferred type
238
+ exports: `PermitOfferReceivedParams`, `_RetractedParams`, `_AcceptedParams`,
239
+ `_DeclinedParams`, `_SupersedeParams`, `PermitRevokeParams`. Notification
240
+ builders: `build_permit_offer_received_notification(params)` etc.
241
+
242
+ `PERMIT_OFFER_NOTIFICATION_SPECS: Array<EventSpec>` — pass to
243
+ `create_app_server`'s `event_specs` so the attack surface reflects them
244
+ and DEV-mode `create_validated_broadcaster` catches payload drift.
245
+
246
+ `NotificationSender` is the narrow structural capability:
247
+ `send_to_account(account_id, message): number`. `BackendWebsocketTransport`
248
+ structurally satisfies it (its signature accepts the broader
249
+ `JsonrpcMessageFromServerToClient`, contravariantly compatible). Target
250
+ account travels via the send argument, not the payload — `revoked_by` is
251
+ deliberately not in the `permit_revoke` payload (the revokee doesn't need
252
+ to learn the admin's identity).
253
+
254
+ ## Queries
255
+
256
+ All take `deps: QueryDeps = {db}` as their first arg (except
257
+ `query_validate_api_token` which uses `ApiTokenQueryDeps` — adds `log`).
258
+
259
+ ### `account_queries.ts`
260
+
261
+ CRUD + listing:
262
+
263
+ - `query_create_account`, `query_create_actor`, `query_create_account_with_actor`.
264
+ - `query_account_by_id` / `_username` / `_email` — case-insensitive via
265
+ `LOWER()` (relies on the `idx_account_email` / `idx_account_username_ci`
266
+ indexes).
267
+ - `query_account_by_username_or_email(deps, input)` — if `@` in input, tries
268
+ email first; else username first. Single login field accepting either.
269
+ - `query_update_account_password`, `query_delete_account` (cascades to
270
+ actors, permits, sessions, tokens).
271
+ - `query_account_has_any` — used by bootstrap for belt-and-suspenders check.
272
+ - `query_actor_by_account`, `query_actor_by_id`.
273
+ - `query_admin_account_list` — composes accounts + actors + active permits +
274
+ pending inbound offers with **four flat queries** instead of N+1. Pending
275
+ offers exclude `message` on purpose (cross-admin visibility). Returns
276
+ `Array<AdminAccountEntryJson>`, sorted by `created_at`.
277
+
278
+ ### `permit_queries.ts`
279
+
280
+ - `query_grant_permit` — idempotent; `ON CONFLICT` target and fallback
281
+ `SELECT` both use `COALESCE(scope_id, sentinel)`. The fallback `SELECT`
282
+ uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict
283
+ case).
284
+ - `query_permit_find_active_role_for_actor(deps, permit_id, actor_id)` —
285
+ actor-scoped read, so IDOR protection is consistent with revoke. Returns
286
+ `{role}` or `null`.
287
+ - **`query_revoke_permit(deps, permit_id, actor_id, revoked_by, reason?)`** —
288
+ actor-scoped IDOR guard (returns `null` if the permit belongs to a
289
+ different actor). Supersedes pending offers for the revoked permit's
290
+ `(to_account, role, scope)` in the **same transaction** via a CTE that
291
+ joins `actor` to surface each sibling's `from_account_id`. Returns
292
+ `RevokePermitResult = {id, role, scope_id, superseded_offers}`. Closes the
293
+ "accept a pre-revoke offer to bypass the revoke" path — the stale offer
294
+ becomes terminal at revoke time.
295
+ - `query_permit_find_active_for_actor`, `query_permit_list_for_actor`.
296
+ - `query_permit_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
297
+ handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
298
+ callers keep semantics).
299
+ - `query_permit_find_account_id_for_role(deps, role)` — joins
300
+ permit → actor → account, returns first match. Used by daemon token
301
+ middleware to resolve the keeper account.
302
+ - `query_permit_revoke_role(deps, actor_id, role, ...)` — revokes every
303
+ active permit for `(actor, role)` across all scopes and supersedes all
304
+ matching pending offers. Returns `RevokeRoleResult = {revoked, superseded_offers}`.
305
+
306
+ ### `permit_offer_queries.ts`
307
+
308
+ Error classes (all extend `Error` with stable `.name` — never use
309
+ `instanceof` against plain messages):
310
+
311
+ - `PermitOfferSelfTargetError` — grantor offered themselves. Enforced via
312
+ cross-row JOIN in `query_permit_offer_create` (rather than CHECK) to avoid
313
+ denormalized columns.
314
+ - `PermitOfferAlreadyTerminalError` — offer exists for the caller but is
315
+ accepted / declined / retracted / superseded.
316
+ - `PermitOfferExpiredError` — pending but past `expires_at` (distinct from
317
+ terminal; different user-facing story: "ask the grantor to re-send").
318
+ - `PermitOfferNotFoundError` — not found or belongs to a different recipient
319
+ (standard 404-over-403 IDOR mask; callers never reveal which).
320
+
321
+ Queries:
322
+
323
+ - `query_permit_offer_create` — INSERT with upsert-on-pending keyed by
324
+ `(to_account, role, scope, from_actor)`. Same-grantor re-offer refreshes
325
+ `message` + `expires_at` only. A terminal-state row with the same tuple
326
+ does not block a fresh INSERT.
327
+ - `query_permit_offer_decline(deps, id, to_account_id, reason)` — IDOR
328
+ guarded by `to_account_id`. `resolve_terminal_or_missing` helper
329
+ distinguishes "not found / different recipient" from "already terminal".
330
+ - `query_permit_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
331
+ grantor actor.
332
+ - `query_permit_offer_list(deps, to_account_id)` — pending + non-expired +
333
+ non-superseded, soonest expiry first.
334
+ - `query_permit_offer_history_for_account(deps, account_id, limit?, offset?)` —
335
+ both directions (recipient or grantor), includes terminal rows, newest
336
+ first.
337
+ - `query_permit_offer_find_pending`.
338
+ - `query_permit_offer_sweep_expired` — returns pending offers past
339
+ `expires_at`; the caller emits `permit_offer_expire` audit events
340
+ per-row (no tombstone — caller is responsible for idempotency).
341
+ - **`query_accept_offer(deps, input)`** — atomic, must run inside a
342
+ transaction. Row-locks with `SELECT ... FOR UPDATE` (concurrent callers
343
+ block until commit / rollback, then branch idempotently). Inserts the
344
+ permit with normal idempotency (`ON CONFLICT DO NOTHING`), stamps
345
+ `accepted_at` + `resulting_permit_id` in one UPDATE (satisfying the
346
+ `permit_offer_permit_iff_accepted` CHECK), supersedes sibling pending
347
+ offers for `(to_account, role, scope)` via CTE joined to `actor` for
348
+ grantor `account_id`, and emits `permit_offer_accept` + `permit_grant`
349
+ - one `permit_offer_supersede` per sibling. On race, returns the
350
+ pre-existing permit with `created: false` and empty `superseded_offers`
351
+ / `audit_events`. Error map: `PermitOfferNotFoundError`,
352
+ `PermitOfferAlreadyTerminalError`, `PermitOfferExpiredError`. Sibling
353
+ supersede is what forecloses the "accept a pre-revoke sibling later to
354
+ get the role back" path.
355
+
356
+ ### `session_queries.ts`
357
+
358
+ Server-side sessions, keyed by blake3 hash of the session token:
359
+
360
+ - `AUTH_SESSION_LIFETIME_MS` (30 days), `AUTH_SESSION_EXTEND_THRESHOLD_MS` (1 day).
361
+ - `hash_session_token`, `generate_session_token`.
362
+ - `query_create_session(deps, token_hash, account_id, expires_at)`.
363
+ - `query_session_get_valid` — implicit `expires_at > NOW()` filter.
364
+ - `query_session_touch` — updates `last_seen_at`; extends `expires_at` only
365
+ when less than `AUTH_SESSION_EXTEND_THRESHOLD_MS` remains (avoids a write
366
+ on every request).
367
+ - **`query_session_revoke_by_hash`** — unscoped DELETE. Only safe from the
368
+ authenticated session cookie path (logout). For user-facing revocation by
369
+ ID, use `query_session_revoke_for_account`.
370
+ - `query_session_revoke_for_account(deps, hash, account_id)` — IDOR guarded.
371
+ - `query_session_revoke_all_for_account` — returns count.
372
+ - `query_session_list_for_account`, `query_session_list_all_active` (admin).
373
+ - `query_session_enforce_limit(deps, account_id, max_sessions)` — keeps
374
+ newest N, evicts the rest. **Must run in a transaction** with the INSERT
375
+ that created the new session. All callers satisfy this: `POST /login`
376
+ via `transaction: true`; `account_token_create` RPC via the dispatcher's
377
+ `side_effects: true` transaction path; `/bootstrap` / `/signup` via
378
+ explicit `db.transaction` wrappers.
379
+ - `query_session_cleanup_expired`.
380
+ - `session_touch_fire_and_forget(deps, hash, pending_effects?, log)` —
381
+ errors logged, never thrown.
382
+
383
+ ### `api_token_queries.ts`
384
+
385
+ - `ApiTokenQueryDeps = QueryDeps & {log}`.
386
+ - `query_create_api_token` — caller provides `id`, `token_hash` (already
387
+ computed via `api_token.ts`).
388
+ - `query_validate_api_token(deps, raw_token, ip, pending_effects?)` — hashes,
389
+ looks up, checks expiry, fires a fire-and-forget UPDATE for `last_used_at`
390
+ / `last_used_ip` (errors logged via `deps.log`).
391
+ - `query_revoke_all_api_tokens_for_account` (returns count),
392
+ `query_revoke_api_token_for_account` (IDOR guarded).
393
+ - `query_api_token_list_for_account` — columns enumerated explicitly to
394
+ exclude `token_hash`. Must be kept in sync when `api_token` gains columns.
395
+ - `query_api_token_enforce_limit` — same transaction-safety requirement as
396
+ the session variant.
397
+
398
+ ### `invite_queries.ts`
399
+
400
+ - `query_create_invite` (requires at least one of `email` / `username` —
401
+ enforced by `CHECK constraint invite_has_identifier`).
402
+ - `query_invite_find_unclaimed_by_email`, `_by_username`.
403
+ - `query_invite_find_unclaimed_match(deps, email, username)` — three scoping
404
+ modes: email-only invite needs signup-email match; username-only invite
405
+ needs signup-username match; both-field invite requires both to match.
406
+ - `query_invite_claim` — sets `claimed_by` + `claimed_at` only if still
407
+ unclaimed. Return is a boolean for race-detection.
408
+ - `query_invite_list_all`, `query_invite_list_all_with_usernames` (joins to
409
+ `actor` for `created_by_username` and `account` for `claimed_by_username`).
410
+ - `query_invite_delete_unclaimed` — IDOR not a concern (admin-only surface),
411
+ but rejects already-claimed invites.
412
+
413
+ ### `app_settings_queries.ts`
414
+
415
+ - `query_app_settings_load`, `query_app_settings_load_with_username`,
416
+ `query_app_settings_update(deps, open_signup, actor_id)`.
417
+ - All three throw `'app_settings row not found — migration may not have
418
+ run'` if the seed somehow missed (defensive — migrations always seed).
419
+
420
+ ### `audit_log_queries.ts`
421
+
422
+ - `AUDIT_LOG_DEFAULT_LIMIT = 50`.
423
+ - `query_audit_log<T>(deps, input)` — DEV-only validates metadata against
424
+ `AUDIT_METADATA_SCHEMAS[event_type]` (warns on mismatch, never throws).
425
+ Returns the inserted row via `RETURNING *` (so callers get `id`, `seq`,
426
+ `created_at`).
427
+ - `query_audit_log_list(deps, options?)` — supports `event_type`,
428
+ `event_type_in`, `account_id` (matches either `account_id` OR
429
+ `target_account_id`), `outcome`, `since_seq`, `limit`, `offset`.
430
+ - `query_audit_log_list_with_usernames` — joins twice to `account`.
431
+ - `query_audit_log_list_for_account`, `query_audit_log_list_permit_history`
432
+ (filters to `permit_grant` / `permit_revoke`).
433
+ - `query_audit_log_cleanup_before`.
434
+ - **`audit_log_fire_and_forget(route, input, log, on_event)`** — writes to
435
+ `route.background_db` (pool-level), **not** the handler's transaction,
436
+ so audit entries **persist even when the request transaction rolls back**.
437
+ Write failures and `on_event` callback failures are logged separately so
438
+ the error message indicates the failing phase. Pushes onto
439
+ `route.pending_effects` for test flushing.
440
+
441
+ ### `migrations.ts`
442
+
443
+ - `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `AUTH_MIGRATION_NS` (pre-composed).
444
+ - `AUTH_MIGRATIONS`:
445
+ - **v0 `full_auth_schema`** — every table + index + seed for the v1
446
+ identity system (account, actor, permit, auth_session, api_token,
447
+ audit_log, bootstrap_lock, invite, app_settings). All
448
+ `IF NOT EXISTS` — idempotent replay.
449
+ - **v1 `permit_offer_and_scoped_permits`** — adds `permit_offer` table
450
+ plus its two partial indexes; adds `permit.scope_id` /
451
+ `permit.source_offer_id` / `permit.revoked_reason`; drops
452
+ `permit_actor_role_active_unique` and installs scope-aware
453
+ `permit_actor_role_scope_active_unique` using the
454
+ `PERMIT_OFFER_SCOPE_SENTINEL_UUID`.
455
+ - Forward-only (no down). Named migrations are preferred so the name
456
+ surfaces in error messages.
457
+
458
+ ## Middleware
459
+
460
+ Side of the chain ordering (concept-level — see the root `../../../CLAUDE.md`
461
+ §Middleware Ordering for the canonical assembly order):
462
+
463
+ **Session parsing is separate from auth enforcement.** The session /
464
+ request-context middleware populates `{account, actor, permits}` from a
465
+ cookie but does not 401; `require_auth` / `require_role` / `require_keeper`
466
+ enforce. This lets `/login` and `/bootstrap` participate in cookie refresh
467
+ without being blocked.
468
+
469
+ ### `request_context.ts`
470
+
471
+ - `RequestContext = {account, actor, permits}`.
472
+ - `REQUEST_CONTEXT_KEY` — Hono context variable name.
473
+ - **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on
474
+ successful session lookup; `null` for unauthenticated or non-session
475
+ credentials. Exposed so SSE endpoints can scope per-session resource
476
+ identity (the audit-log SSE uses this to close only the revoked session's
477
+ stream on `session_revoke`).
478
+ - `get_request_context(c)`, `require_request_context(c)` (throws on misuse
479
+ — misconfigured middleware surfaces immediately), `has_role(ctx, role, now?)`.
480
+ - `build_request_context(deps, account_id)` — shared helper used by
481
+ session, bearer, and daemon token middleware; does
482
+ `account → actor → permits` and returns `null` if either lookup misses.
483
+ - `refresh_permits(ctx, deps)` — reloads permits without mutating the
484
+ original (concurrent-safe). Useful for long-lived WebSocket connections.
485
+ - `create_request_context_middleware(deps, log, session_context_key?)` —
486
+ reads session token from context, hashes, validates, loads context, sets
487
+ `CREDENTIAL_TYPE_KEY = 'session'`, fires `session_touch_fire_and_forget`.
488
+ - `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) on no context.
489
+ - `require_role(role)` — 401 on no context, 403 (`ERROR_INSUFFICIENT_PERMISSIONS`
490
+ - `required_role`) on missing role.
491
+
492
+ ### `bearer_auth.ts`
493
+
494
+ - `create_bearer_auth_middleware(deps, ip_rate_limiter, log)`.
495
+ - **Soft-fails** for invalid / expired / empty tokens — calls `next()`
496
+ without setting context. Lets downstream auth enforcement return a
497
+ consistent error and avoids leaking token-specific diagnostics. Only
498
+ 429 is a hard-fail.
499
+ - **Rejects bearer tokens when `Origin` or `Referer` is present** (both,
500
+ not just `Origin` — some browser requests omit `Origin`). Checked via
501
+ `!== undefined` so empty-string headers still count as browser context.
502
+ Discards rather than 403s so public actions remain reachable.
503
+ - Case-insensitive scheme matching per RFC 7235 §2.1.
504
+ - Rate limiter: `record` before async DB work to close the TOCTOU window;
505
+ `reset` on valid token.
506
+
507
+ ### `require_keeper.ts`
508
+
509
+ Two-part type guard:
510
+
511
+ 1. `credential_type` must be `'daemon_token'` (not session, not API token).
512
+ A session cookie from the bootstrap account still fails this check.
513
+ 2. Active `keeper` permit.
514
+
515
+ Returns 401 on no context, 403 (`ERROR_KEEPER_REQUIRES_DAEMON_TOKEN` or
516
+ `ERROR_INSUFFICIENT_PERMISSIONS`) otherwise.
517
+
518
+ ### `session_middleware.ts` + `session_lifecycle.ts`
519
+
520
+ `session_middleware.ts`:
521
+
522
+ - `get_session_cookie`, `set_session_cookie`, `clear_session_cookie`.
523
+ - `create_session_middleware(keyring, options)` — always sets the
524
+ identity on context (null when invalid/missing) for type-safe reads.
525
+ Acts on `process_session_cookie`'s `action` (`'clear'` / `'refresh'` /
526
+ `'none'`).
527
+
528
+ `session_lifecycle.ts` — shared by login and bootstrap:
529
+
530
+ - `create_session_and_set_cookie({keyring, deps, c, account_id, session_options, max_sessions?})` —
531
+ generates token, hashes, persists `auth_session`, optionally enforces
532
+ per-account cap, signs the cookie.
533
+
534
+ ### `daemon_token_middleware.ts`
535
+
536
+ - `DEFAULT_ROTATION_INTERVAL_MS = 30_000`.
537
+ - `get_daemon_token_path(runtime, name)` → `~/.{name}/run/daemon_token`
538
+ or `null` if `$HOME` unset.
539
+ - `write_daemon_token(runtime, path, token)` — atomic (temp + rename);
540
+ `chmod 0600` if available.
541
+ - `resolve_keeper_account_id(deps)` — wraps `query_permit_find_account_id_for_role(ROLE_KEEPER)`.
542
+ - `start_daemon_token_rotation(runtime, deps, options, log)` — writes initial
543
+ token, resolves keeper, sets up interval. Returns `{state, stop}`. The
544
+ interval guard `writing` skips the next rotation if the prior write is
545
+ still in flight. `stop` clears the interval and removes the token file
546
+ (errors swallowed — already removed or never written).
547
+ - `create_daemon_token_middleware(state, deps)` — checks `X-Daemon-Token`:
548
+ - No header → pass through.
549
+ - Present + Zod-invalid → 401 `ERROR_INVALID_DAEMON_TOKEN`.
550
+ - Present + invalid value → 401 (fail-closed, no downgrade).
551
+ - Present + valid + no `keeper_account_id` → 503 `ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED`.
552
+ - Present + valid + keeper account missing → 500 `ERROR_KEEPER_ACCOUNT_NOT_FOUND`.
553
+ - Present + valid + ok → builds context from keeper account (overrides
554
+ any existing session / bearer context), sets `credential_type: 'daemon_token'`.
555
+
556
+ ### `middleware.ts`
557
+
558
+ - `create_auth_middleware_specs(deps, options)` — assembles the stack:
559
+ `[origin, session, request_context, bearer_auth]` plus an optional
560
+ `daemon_token` layer when `daemon_token_state` is passed. Returns
561
+ `Array<MiddlewareSpec>`. Dynamic imports keep heavy deps out of
562
+ consumers that only use types. `bearer_auth.errors: {429: RateLimitError}`
563
+ — bearer middleware only hard-fails on rate limit; `daemon_token.errors`
564
+ documents 401 / 500 / 503.
565
+
566
+ ## Routes
567
+
568
+ ### `account_routes.ts`
569
+
570
+ Session-based auth route specs. Factory: `create_account_route_specs(deps, options)`.
571
+
572
+ - `POST /login` — `UsernameProvided` + `PasswordProvided`. Two rate limiters:
573
+ per-IP and per-account (keyed by **canonical `account.id` after lookup**
574
+ — keying by submitted username would double the bucket when an attacker
575
+ alternates between username and email). **Login 401s are floored to
576
+ `DEFAULT_LOGIN_FAIL_FLOOR_MS` (250ms) + uniform jitter
577
+ `DEFAULT_LOGIN_FAIL_JITTER_MS` (±25ms)** via
578
+ `Promise.all(work, setTimeout)` — observed time is `max(work, delay)` so
579
+ found-wrong-password and not-found paths converge. 429 stays fast by
580
+ design. `verify_dummy` equalizes Argon2id timing on not-found.
581
+ - `POST /logout` — revokes session by hash, clears cookie.
582
+ - **`POST /password`** — `current_password: PasswordProvided` +
583
+ `new_password: Password`. Per-IP + per-account rate limited.
584
+ **Revokes all sessions + all API tokens** (force re-auth everywhere);
585
+ clears cookie.
586
+ - **`GET /verify`** — empty-body session-validity probe for nginx
587
+ `auth_request` subrequests. Status-code-only contract: 200 on valid
588
+ cookie, 401 otherwise. The auth middleware does the enforcement; the
589
+ handler is a one-line shim. Programmatic callers should use the
590
+ `account_verify` RPC action — that surface carries the typed
591
+ `SessionAccountJson` payload.
592
+ - `create_account_status_route_spec(options?)` — `GET /api/account/status`
593
+ returns `{account, actor, permits}` on 200 or 401 with optional
594
+ `bootstrap_available` flag. `actor` is the caller's own
595
+ `ActorSummaryJson` so clients don't need to derive `actor_id` from
596
+ the permit list. Lets the frontend fetch both session state
597
+ and bootstrap availability in one request (eliminates a separate `/health`
598
+ round trip).
599
+
600
+ Post-2026-04-23 RPC migration: session listing/revoke + revoke-all
601
+ and API token CRUD live in `account_actions.ts` (see
602
+ `account_session_list` / `_revoke` / `_revoke_all`,
603
+ `account_token_create` / `_list` / `_revoke` below). Each keeps its
604
+ guards (IDOR via `query_session_revoke_for_account` /
605
+ `query_revoke_api_token_for_account`; `Blake3Hash` on session ids;
606
+ `ApiTokenId` regex on token ids; `max_tokens` enforcement via
607
+ `query_api_token_enforce_limit`).
608
+
609
+ Constants:
610
+
611
+ - `DEFAULT_MAX_SESSIONS = 5`, `DEFAULT_MAX_TOKENS = 10`.
612
+ - `DEFAULT_LOGIN_FAIL_FLOOR_MS = 250`, `DEFAULT_LOGIN_FAIL_JITTER_MS = 25`.
613
+ - `AuthSessionRouteOptions` — shared base (`session_options`,
614
+ `ip_rate_limiter`). Extended by `AccountRouteOptions` and
615
+ `SignupRouteOptions`.
616
+
617
+ ### `bootstrap_routes.ts`
618
+
619
+ - `BootstrapStatus = {available, token_path}` — runtime state (mutable ref).
620
+ - `check_bootstrap_status(deps, {token_path})` — returns `available: true`
621
+ iff the token path is configured, the file exists on disk, and
622
+ `bootstrap_lock.bootstrapped = false`.
623
+ - `create_bootstrap_route_specs(deps, options)` — `POST /bootstrap`. Short-
624
+ circuits on `!bootstrap_status.available`. `transaction: false` —
625
+ `bootstrap_account` manages its own. On success: flips
626
+ `bootstrap_status.available = false`, creates session, runs `on_bootstrap`
627
+ callback (for app-specific work like generating an API token), emits
628
+ audit event. **If token file deletion fails, throws** so the operator
629
+ gets a loud signal (all success side effects have already run).
630
+ - Rate limiter: per-IP only.
631
+ - Error shapes: 401 `ERROR_INVALID_TOKEN`, 403 `ERROR_ALREADY_BOOTSTRAPPED`,
632
+ 404 `ERROR_TOKEN_FILE_MISSING | ERROR_BOOTSTRAP_NOT_CONFIGURED`.
633
+
634
+ ### `signup_routes.ts`
635
+
636
+ - `SignupRouteOptions extends AuthSessionRouteOptions` with
637
+ `signup_account_rate_limiter` and a mutable `app_settings: AppSettings` ref.
638
+ - `POST /signup` — `transaction: false` (manages its own). When
639
+ `app_settings.open_signup` is false, requires a matching unclaimed invite.
640
+ On `open_signup: true` path, no invite check.
641
+ - Transaction body: `query_create_account_with_actor` → `query_invite_claim`
642
+ (if invite present; throws `SignupConflictError` on race — another claim
643
+ won) → `create_session_and_set_cookie`. Catches
644
+ `is_pg_unique_violation(e)` → 409 `ERROR_SIGNUP_CONFLICT` (username or
645
+ email already exists).
646
+ - Error shapes: 403 `ERROR_NO_MATCHING_INVITE`, 409 `ERROR_SIGNUP_CONFLICT`.
647
+
648
+ ### `route_guards.ts`
649
+
650
+ `fuz_auth_guard_resolver: AuthGuardResolver` — maps `RouteAuth` discriminants
651
+ (`'none'` | `'authenticated'` | `'role'` | `'keeper'`) to middleware arrays.
652
+ Injected into `apply_route_specs` so the generic HTTP framework stays
653
+ auth-agnostic (see `../http/CLAUDE.md` §Validation pipeline for where it plugs in).
654
+
655
+ ### `audit_log_routes.ts` (post-RPC-migration state)
656
+
657
+ The 2026-04-22 RPC migration moved audit-log list + permit-history reads
658
+ (plus admin session listing) to `admin_actions.ts`. The sole remaining
659
+ REST concern is the optional SSE stream:
660
+
661
+ - **`GET /audit-log/stream`** — optional, wired only when
662
+ `AuditLogRouteOptions.stream` is passed. Streams aren't an RPC concern.
663
+ Uses `AUTH_SESSION_TOKEN_HASH_KEY` for SSE `scope` identity (so
664
+ `session_revoke` can close only that session's stream); `groups: [account_id]`
665
+ for coarse close on `permit_revoke` / `session_revoke_all` / `password_change`.
666
+
667
+ `create_audit_log_route_specs(options?)` — returns an empty array when
668
+ `options.stream` is not set; `required_role` defaults to `'admin'`.
669
+
670
+ ## RPC actions (SAES)
671
+
672
+ Three action surfaces that mount on a consumer's JSON-RPC endpoint via
673
+ `create_rpc_endpoint` (see `../actions/CLAUDE.md` §Single JSON-RPC 2.0 endpoint).
674
+ Each surface is split across two files:
675
+
676
+ - `*_action_specs.ts` — Input/Output Zod schemas (paired with `z.infer` type
677
+ exports), module-scope specs declared via `satisfies RequestResponseActionSpec`
678
+ (no per-method `*_METHOD` string constants — read `.method` off the spec),
679
+ and `all_*_action_specs: Array<RequestResponseActionSpec>` codegen-ready
680
+ registry. Plus any reason-string constants exported to the wire contract
681
+ (e.g. `ERROR_OFFER_*` for permit offers).
682
+ - `*_actions.ts` — `create_*_actions(deps, options) => Array<RpcAction>` factory
683
+ containing handler closures, the `*ActionDeps` / `*ActionOptions` interfaces,
684
+ and any handler-only helpers. Imports the specs from its sibling.
685
+
686
+ Client-side code that only needs the typed surface (codegen, attack-surface
687
+ reporting, form-state error matching) imports from `*_action_specs.ts` and
688
+ skips the handler module's transitive query-layer deps.
689
+
690
+ ### `admin_action_specs.ts` + `admin_actions.ts` — eleven admin-only RPC actions
691
+
692
+ Authorization is **spec-level** (`auth: {role: 'admin'}`) so the dispatcher
693
+ enforces admin before the handler runs. `permit_revoke` in
694
+ `permit_offer_actions.ts` uses the same spec-level gate even though its
695
+ sibling methods are authenticated-but-not-admin — the dispatcher checks
696
+ auth per-spec, so mixed-auth endpoints compose cleanly.
697
+
698
+ | Spec | Side effects | Input | Output |
699
+ | -------------------------------------- | ------------ | --------------------------------------------------------- | ----------------------------- |
700
+ | `admin_account_list_action_spec` | false | `z.null()` | `{accounts, grantable_roles}` |
701
+ | `admin_session_list_action_spec` | false | `z.null()` | `{sessions}` |
702
+ | `admin_session_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
703
+ | `admin_token_revoke_all_action_spec` | true | `{account_id}` | `{ok, count}` |
704
+ | `audit_log_list_action_spec` | false | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
705
+ | `audit_log_permit_history_action_spec` | false | `{limit?, offset?}` | `{events}` |
706
+ | `invite_create_action_spec` | true | `{email?, username?}` | `{ok, invite}` |
707
+ | `invite_list_action_spec` | false | `z.null()` | `{invites}` |
708
+ | `invite_delete_action_spec` | true | `{invite_id}` | `{ok}` |
709
+ | `app_settings_get_action_spec` | false | `z.null()` | `{settings}` |
710
+ | `app_settings_update_action_spec` | true | `{open_signup}` | `{ok, settings}` |
711
+
712
+ `AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp (mirrors the former REST
713
+ route).
714
+
715
+ Error reasons returned via `error.data.reason`:
716
+
717
+ | Method | Error |
718
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
719
+ | `admin_session_revoke_all` | `ERROR_ACCOUNT_NOT_FOUND` (404 via `jsonrpc_errors.not_found`) |
720
+ | `admin_token_revoke_all` | `ERROR_ACCOUNT_NOT_FOUND` |
721
+ | `invite_create` | `ERROR_INVITE_MISSING_IDENTIFIER` (invalid_params), `ERROR_INVITE_ACCOUNT_EXISTS_USERNAME`, `ERROR_INVITE_ACCOUNT_EXISTS_EMAIL`, `ERROR_INVITE_DUPLICATE` (conflict) |
722
+ | `invite_delete` | `ERROR_INVITE_NOT_FOUND` (not_found) |
723
+
724
+ Audit events fired by handlers (all pass `ip: ctx.client_ip` for
725
+ transport-uniform forensics — matches the REST convention and the
726
+ self-service `account_actions.ts` surface):
727
+
728
+ - `session_revoke_all` / `token_revoke_all` via `audit_log_fire_and_forget`
729
+ (mirrors the former REST behavior). Both also emit an
730
+ `outcome: 'failure'` row on the `ERROR_ACCOUNT_NOT_FOUND` 404 path for
731
+ forensic visibility — `target_account_id` is null (FK to `account`
732
+ rejects references to missing ids), and the probed id is preserved
733
+ under `metadata.attempted_account_id`. Metadata schema widening in
734
+ `audit_log_schema.ts` allows `reason`, `attempted_account_id`, and
735
+ makes `count` optional for the failure shape.
736
+ - `invite_create` / `invite_delete`.
737
+ - `app_settings_update` — metadata `{setting: 'open_signup', old_value, new_value}`.
738
+
739
+ Closure state:
740
+
741
+ - `grantable_roles` is derived once from `options.roles?.role_options ?? BUILTIN_ROLE_OPTIONS`
742
+ (the `web_grantable` subset) and closed over by the `admin_account_list` handler.
743
+ - `options.app_settings` — when provided, captured by the
744
+ `app_settings_get` / `app_settings_update` handlers. Update handler
745
+ **mutates the ref** (`open_signup`, `updated_at`, `updated_by`) so
746
+ `signup_routes.ts` reads the new value **without a DB round trip**.
747
+ When absent, those two specs are still present in `all_admin_action_specs`
748
+ (surface-wise) but the handlers are not wired — RPC dispatch returns
749
+ `method_not_found`.
750
+
751
+ `all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
752
+ registry of all eleven specs (always includes the two app-settings specs).
753
+
754
+ Deps: `AdminActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event'>`.
755
+
756
+ ### `permit_offer_action_specs.ts` + `permit_offer_actions.ts` — seven RPC actions
757
+
758
+ Six offer-lifecycle methods plus `permit_revoke`. Authorization is a mix:
759
+
760
+ - `permit_offer_create` — `auth: 'authenticated'`. The **`web_grantable`
761
+ gate runs first**, then the `PermitOfferCreateAuthorize` callback
762
+ (default: caller holds the offered role globally). Consumers can only
763
+ tighten, never loosen past `web_grantable`.
764
+ - `permit_offer_accept` / `_decline` / `_retract` — `authenticated`; IDOR
765
+ guards in the `query_*` layer.
766
+ - `permit_offer_list` / `_history` — `side_effects: false` so GET-addressable;
767
+ **input-dependent elevation** — `'authenticated'` at the spec level so
768
+ any caller reaches their own inbox, then the handler requires admin
769
+ when `{account_id}` refers to another account. The spec can't express
770
+ this because auth runs before input parsing.
771
+ `permit_offer_history` accepts `limit` (1–500, default 100) + `offset`.
772
+ - **`permit_revoke`** — spec-level `auth: {role: 'admin'}`; the RPC
773
+ dispatcher rejects non-admin callers before the handler runs. Keys on
774
+ **`actor_id`, not `account_id`** — permits are actor-scoped and deriving
775
+ actor from account collapses under multi-actor accounts.
776
+
777
+ | Spec | Input | Output |
778
+ | ---------------------------------- | -------------------------------------------- | ------------------------------------------ |
779
+ | `permit_offer_create_action_spec` | `{to_account_id, role, scope_id?, message?}` | `{offer}` |
780
+ | `permit_offer_accept_action_spec` | `{offer_id}` | `{permit_id, offer, superseded_offer_ids}` |
781
+ | `permit_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` |
782
+ | `permit_offer_retract_action_spec` | `{offer_id}` | `{ok}` |
783
+ | `permit_offer_list_action_spec` | `{account_id?}` | `{offers}` |
784
+ | `permit_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` |
785
+ | `permit_revoke_action_spec` | `{actor_id, permit_id, reason?}` | `{ok, revoked}` |
786
+
787
+ Error reason constants (exported as `as const` literals):
788
+
789
+ - `ERROR_OFFER_SELF_TARGET` (`'offer_self_target'`)
790
+ - `ERROR_OFFER_TERMINAL` (`'offer_terminal'`)
791
+ - `ERROR_OFFER_EXPIRED` (`'offer_expired'`)
792
+ - `ERROR_OFFER_NOT_FOUND` (`'offer_not_found'` — 404-over-403 IDOR mask)
793
+ - `ERROR_OFFER_ROLE_NOT_GRANTABLE` (`'offer_role_not_grantable'`)
794
+ - `ERROR_OFFER_NOT_AUTHORIZED` (`'offer_not_authorized'`)
795
+
796
+ Plus re-uses from `../http/error_schemas.ts`: `ERROR_PERMIT_NOT_FOUND`,
797
+ `ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`,
798
+ `ERROR_ACCOUNT_NOT_FOUND`.
799
+
800
+ Failure-outcome audit events emitted (success and failure rows both carry
801
+ `ip: ctx.client_ip` — uniform with the admin and self-service surfaces):
802
+
803
+ - `permit_offer_create` failure — `web_grantable` denial, `authorize`
804
+ denial, self-target rejection (all three denial paths emit the same
805
+ audit row with `target_account_id`).
806
+ - `permit_revoke` failure — `web_grantable` denial after IDOR / role
807
+ lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit,
808
+ matching the middleware auth-guard precedent.
809
+
810
+ WS notifications (post-commit via `emit_after_commit` from
811
+ `../http/pending_effects.js` — swallows exceptions so one failed send
812
+ can't starve others; see `../http/CLAUDE.md` §Pending Effects):
813
+
814
+ - Create → `permit_offer_received` to recipient.
815
+ - Retract → `permit_offer_retracted` to recipient.
816
+ - Accept → `permit_offer_accepted` to grantor + one
817
+ `permit_offer_supersede` per superseded sibling to that sibling's grantor.
818
+ - Decline → `permit_offer_declined` to grantor.
819
+ - Revoke → `permit_revoke` to revokee + one `permit_offer_supersede` per
820
+ superseded sibling.
821
+
822
+ Deps: `PermitOfferActionDeps extends Pick<RouteFactoryDeps, 'log' | 'on_audit_event'> & {notification_sender?: NotificationSender | null}`.
823
+ Notification sender is optional — when absent, WS fan-out is silently
824
+ skipped (DB-only side effects still happen).
825
+
826
+ Options:
827
+
828
+ - `roles?: RoleSchemaResult` — drives `web_grantable` lookup (defaults to
829
+ `BUILTIN_ROLE_OPTIONS`).
830
+ - `default_ttl_ms?: number` — applied to new offers (defaults to
831
+ `PERMIT_OFFER_DEFAULT_TTL_MS`).
832
+ - `authorize?: PermitOfferCreateAuthorize` — custom policy for
833
+ `permit_offer_create`. Signature:
834
+ `(auth, input: {to_account_id, role, scope_id}, deps: Pick<RouteFactoryDeps, 'log'>, ctx: ActionContext) => boolean | Promise<boolean>`.
835
+
836
+ `all_permit_offer_action_specs: Array<RequestResponseActionSpec>` —
837
+ codegen-ready registry.
838
+
839
+ ### `admin_rpc_actions.ts` — combined admin + permit-offer factory
840
+
841
+ `create_admin_rpc_actions(deps, options)` spreads
842
+ `create_admin_actions` and `create_permit_offer_actions` into a single
843
+ `Array<RpcAction>`, so consumers that mount the stock fuz_app admin
844
+ surface don't hand-wire the two factories. `roles` is shared between
845
+ both factories; `app_settings` flows to admin only; `default_ttl_ms`
846
+ and `authorize` flow to permit-offer only; `notification_sender` is
847
+ wired through to permit-offer (admin ignores it).
848
+
849
+ `AdminRpcActionsOptions` composes `AdminActionOptions` +
850
+ `PermitOfferActionOptions`. `AdminRpcActionsDeps` is the same shape as
851
+ `PermitOfferActionDeps` — `log`, `on_audit_event`, optional
852
+ `notification_sender`.
853
+
854
+ Pair this with `create_app_server`'s `rpc_endpoints` factory form
855
+ (`(ctx) => Array<RpcEndpointSpec>`) so the combined action list gets
856
+ `ctx.deps` + `ctx.app_settings` — `create_app_server` auto-mounts the
857
+ endpoint via `create_rpc_endpoint`, so consumers don't need to mount it
858
+ again in `create_route_specs`. See `../../../docs/usage.md` §Server
859
+ Assembly.
860
+
861
+ ### `account_action_specs.ts` + `account_actions.ts` — seven self-service RPC actions
862
+
863
+ Counterpart to `account_routes.ts`. Cookie-lifecycle flows (`login`,
864
+ `logout`, `password`, `signup`, `bootstrap`) stay on REST, as does
865
+ `GET /verify` (empty-body nginx `auth_request` probe). Everything else
866
+ that was `/api/account/*` is on the RPC endpoint.
867
+
868
+ `account_verify` is intentionally on both surfaces: the REST shim is a
869
+ status-only probe, the RPC action returns `SessionAccountJson` for
870
+ programmatic callers.
871
+
872
+ Authorization is **spec-level** (`auth: 'authenticated'`). Revoke operations
873
+ are account-scoped via `query_session_revoke_for_account` /
874
+ `query_revoke_api_token_for_account` — passing another account's session
875
+ or token id returns `revoked: false` rather than revealing whether the id
876
+ exists.
877
+
878
+ | Spec | Side effects | Input | Output |
879
+ | ---------------------------------------- | ------------ | -------------- | ----------------------- |
880
+ | `account_verify_action_spec` | false | `z.null()` | `SessionAccountJson` |
881
+ | `account_session_list_action_spec` | false | `z.null()` | `{sessions}` |
882
+ | `account_session_revoke_action_spec` | true | `{session_id}` | `{ok, revoked}` |
883
+ | `account_session_revoke_all_action_spec` | true | `z.null()` | `{ok, count}` |
884
+ | `account_token_create_action_spec` | true | `{name?}` | `{ok, token, id, name}` |
885
+ | `account_token_list_action_spec` | false | `z.null()` | `{tokens}` |
886
+ | `account_token_revoke_action_spec` | true | `{token_id}` | `{ok, revoked}` |
887
+
888
+ `session_id` validates as `Blake3Hash`; `token_id` validates as
889
+ `ApiTokenId` (`tok_[A-Za-z0-9_-]{12}`).
890
+
891
+ Audit events emitted (via `audit_log_fire_and_forget` with `ip: ctx.client_ip`):
892
+ `session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. The
893
+ IP is the resolved trusted-proxy value from `ActionContext.client_ip`,
894
+ matching the REST handler convention.
895
+
896
+ Deps: `AccountActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event'>`.
897
+ Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS`
898
+ from `account_routes.ts`; `null` disables the cap.
899
+
900
+ `all_account_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
901
+ registry of all seven specs.
902
+
903
+ ## Cleanup
904
+
905
+ `cleanup.ts` — periodic auth maintenance:
906
+
907
+ - `AuthCleanupDeps = QueryDeps & {log, on_audit_event?}`.
908
+ - `cleanup_expired_permit_offers(deps)` — wraps `query_permit_offer_sweep_expired`,
909
+ emits one `permit_offer_expire` audit row per expired offer. Per-row
910
+ `on_audit_event` exceptions are logged and swallowed; one failed callback
911
+ does not starve siblings. Audit-write failures are also logged and skipped
912
+ (not re-thrown) so sibling sweeps still complete.
913
+ - `run_auth_cleanup(deps)` — one-shot consumer entry point: expired
914
+ sessions + expired offers. Returns `{expired_sessions, expired_offers}`.
915
+ **Re-throws sweep errors** so the caller's scheduler can log / alert.
916
+ Call from `setInterval` / cron / similar.
917
+
918
+ Idempotency: the audit log has no tombstone on `permit_offer_expire`, so
919
+ concurrent sweep runs double-audit. Deploy a single scheduled invocation
920
+ per instance — matches `query_session_cleanup_expired`'s expected pattern.
921
+ Expired offer rows are **preserved** (not deleted) — they carry audit value
922
+ for the history view, and accepted rows are the provenance for the
923
+ resulting permit.
924
+
925
+ ## Deps
926
+
927
+ `deps.ts` defines:
928
+
929
+ - **`AppDeps`** — the stateless capabilities bundle. Seven members:
930
+ - `stat`, `read_text_file`, `delete_file` — filesystem.
931
+ - `keyring: Keyring` — HMAC-SHA256 signing.
932
+ - `password: PasswordHashDeps` — use `argon2_password_deps` in production.
933
+ - `db: Db` — pool-level instance (middleware uses this; route handlers
934
+ get a transaction-scoped `Db` via `RouteContext`).
935
+ - `log: Logger`.
936
+ - `on_audit_event: (event) => void` — fires after every successful audit
937
+ INSERT. Wire to SSE broadcast for realtime audit streams. Defaults to
938
+ noop when unwired. Flows automatically through every factory that
939
+ receives `deps` / `RouteFactoryDeps`.
940
+ - **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
941
+ handlers receive DB access via `RouteContext`, so factories don't capture
942
+ a pool-level `Db`.
943
+
944
+ See root `../../../CLAUDE.md` §AppDeps Vocabulary for the
945
+ capability / options / runtime-state split across the whole project.