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