@fuzdev/fuz_app 0.139.0 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/actions/CLAUDE.md +62 -17
  2. package/dist/actions/action_rpc.d.ts +5 -1
  3. package/dist/actions/action_rpc.d.ts.map +1 -1
  4. package/dist/actions/action_rpc.js +9 -1
  5. package/dist/actions/connection_closer.d.ts +25 -2
  6. package/dist/actions/connection_closer.d.ts.map +1 -1
  7. package/dist/actions/connection_closer.js +8 -1
  8. package/dist/actions/register_action_ws.d.ts +42 -6
  9. package/dist/actions/register_action_ws.d.ts.map +1 -1
  10. package/dist/actions/register_action_ws.js +87 -19
  11. package/dist/actions/register_ws_endpoint.d.ts +15 -6
  12. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  13. package/dist/actions/register_ws_endpoint.js +5 -2
  14. package/dist/actions/socket.svelte.d.ts +19 -4
  15. package/dist/actions/socket.svelte.d.ts.map +1 -1
  16. package/dist/actions/socket.svelte.js +31 -4
  17. package/dist/actions/transports.d.ts +30 -1
  18. package/dist/actions/transports.d.ts.map +1 -1
  19. package/dist/actions/transports.js +30 -1
  20. package/dist/actions/transports_ws_auth_guard.d.ts +13 -6
  21. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  22. package/dist/actions/transports_ws_auth_guard.js +24 -10
  23. package/dist/actions/transports_ws_backend.d.ts +51 -5
  24. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  25. package/dist/actions/transports_ws_backend.js +62 -9
  26. package/dist/actions/ws_endpoint_spec.d.ts +11 -10
  27. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  28. package/dist/auth/CLAUDE.md +11 -9
  29. package/dist/auth/account_route_schema.d.ts +1 -0
  30. package/dist/auth/account_route_schema.d.ts.map +1 -1
  31. package/dist/auth/account_route_schema.js +4 -0
  32. package/dist/auth/api_token.d.ts +20 -0
  33. package/dist/auth/api_token.d.ts.map +1 -1
  34. package/dist/auth/api_token.js +21 -1
  35. package/dist/auth/api_token_queries.d.ts +22 -4
  36. package/dist/auth/api_token_queries.d.ts.map +1 -1
  37. package/dist/auth/api_token_queries.js +11 -6
  38. package/dist/auth/audit_log_routes.d.ts +10 -0
  39. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  40. package/dist/auth/audit_log_routes.js +20 -0
  41. package/dist/auth/audit_log_schema.d.ts +30 -5
  42. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  43. package/dist/auth/audit_log_schema.js +30 -0
  44. package/dist/auth/bearer_auth.d.ts +4 -2
  45. package/dist/auth/bearer_auth.d.ts.map +1 -1
  46. package/dist/auth/bearer_auth.js +19 -8
  47. package/dist/auth/bootstrap_route_schema.d.ts +1 -0
  48. package/dist/auth/bootstrap_route_schema.d.ts.map +1 -1
  49. package/dist/auth/bootstrap_route_schema.js +1 -0
  50. package/dist/auth/cleanup.d.ts +9 -9
  51. package/dist/auth/cleanup.js +9 -9
  52. package/dist/auth/credential_deadline.d.ts +95 -0
  53. package/dist/auth/credential_deadline.d.ts.map +1 -0
  54. package/dist/auth/credential_deadline.js +139 -0
  55. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  56. package/dist/auth/daemon_token_middleware.js +4 -1
  57. package/dist/auth/middleware.d.ts +90 -12
  58. package/dist/auth/middleware.d.ts.map +1 -1
  59. package/dist/auth/middleware.js +115 -16
  60. package/dist/auth/request_context.d.ts +4 -2
  61. package/dist/auth/request_context.d.ts.map +1 -1
  62. package/dist/auth/request_context.js +14 -3
  63. package/dist/auth/resolved_auth.d.ts +13 -2
  64. package/dist/auth/resolved_auth.d.ts.map +1 -1
  65. package/dist/auth/resolved_auth.js +5 -4
  66. package/dist/auth/session_cookie.d.ts +4 -0
  67. package/dist/auth/session_cookie.d.ts.map +1 -1
  68. package/dist/auth/session_cookie.js +3 -2
  69. package/dist/auth/session_middleware.d.ts +1 -1
  70. package/dist/auth/session_middleware.d.ts.map +1 -1
  71. package/dist/auth/session_middleware.js +4 -1
  72. package/dist/auth/session_queries.d.ts +19 -2
  73. package/dist/auth/session_queries.d.ts.map +1 -1
  74. package/dist/auth/session_queries.js +8 -2
  75. package/dist/auth/signup_route_schema.d.ts.map +1 -1
  76. package/dist/auth/signup_route_schema.js +1 -0
  77. package/dist/hono_context.d.ts +23 -0
  78. package/dist/hono_context.d.ts.map +1 -1
  79. package/dist/hono_context.js +19 -0
  80. package/dist/http/CLAUDE.md +41 -15
  81. package/dist/http/middleware_spec.d.ts +16 -0
  82. package/dist/http/middleware_spec.d.ts.map +1 -1
  83. package/dist/http/origin.d.ts +113 -18
  84. package/dist/http/origin.d.ts.map +1 -1
  85. package/dist/http/origin.js +247 -127
  86. package/dist/http/route_spec.d.ts +21 -0
  87. package/dist/http/route_spec.d.ts.map +1 -1
  88. package/dist/http/schema_helpers.d.ts +22 -0
  89. package/dist/http/schema_helpers.d.ts.map +1 -1
  90. package/dist/http/schema_helpers.js +24 -0
  91. package/dist/http/surface.d.ts +6 -1
  92. package/dist/http/surface.d.ts.map +1 -1
  93. package/dist/http/surface.js +12 -5
  94. package/dist/realtime/sse_auth_guard.d.ts +22 -6
  95. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  96. package/dist/realtime/sse_auth_guard.js +31 -12
  97. package/dist/realtime/subscriber_registry.d.ts +10 -1
  98. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  99. package/dist/realtime/subscriber_registry.js +10 -1
  100. package/dist/server/app_server.d.ts +20 -7
  101. package/dist/server/app_server.d.ts.map +1 -1
  102. package/dist/server/app_server.js +8 -3
  103. package/dist/server/env.d.ts +2 -1
  104. package/dist/server/env.d.ts.map +1 -1
  105. package/dist/server/env.js +7 -17
  106. package/dist/server/serve_fact_route.d.ts.map +1 -1
  107. package/dist/server/serve_fact_route.js +1 -0
  108. package/dist/testing/CLAUDE.md +154 -22
  109. package/dist/testing/adversarial_headers.d.ts.map +1 -1
  110. package/dist/testing/adversarial_headers.js +13 -6
  111. package/dist/testing/connection_closer_helpers.d.ts +6 -1
  112. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  113. package/dist/testing/connection_closer_helpers.js +4 -0
  114. package/dist/testing/cross_backend/body_size.d.ts.map +1 -1
  115. package/dist/testing/cross_backend/body_size.js +14 -65
  116. package/dist/testing/cross_backend/capabilities.d.ts +26 -2
  117. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  118. package/dist/testing/cross_backend/capabilities.js +9 -3
  119. package/dist/testing/cross_backend/conformance_case.d.ts +11 -3
  120. package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
  121. package/dist/testing/cross_backend/conformance_case.js +6 -0
  122. package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
  123. package/dist/testing/cross_backend/conformance_table.js +20 -19
  124. package/dist/testing/cross_backend/credential_deadline.d.ts +25 -0
  125. package/dist/testing/cross_backend/credential_deadline.d.ts.map +1 -0
  126. package/dist/testing/cross_backend/credential_deadline.js +185 -0
  127. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/default_backend_configs.js +13 -2
  129. package/dist/testing/cross_backend/full_spine_mount.d.ts +3 -0
  130. package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
  131. package/dist/testing/cross_backend/full_spine_mount.js +7 -1
  132. package/dist/testing/cross_backend/narrowed_api_token.d.ts +23 -0
  133. package/dist/testing/cross_backend/narrowed_api_token.d.ts.map +1 -0
  134. package/dist/testing/cross_backend/narrowed_api_token.js +40 -0
  135. package/dist/testing/cross_backend/origin.d.ts.map +1 -1
  136. package/dist/testing/cross_backend/origin.js +6 -26
  137. package/dist/testing/cross_backend/origin_conformance.d.ts +152 -0
  138. package/dist/testing/cross_backend/origin_conformance.d.ts.map +1 -0
  139. package/dist/testing/cross_backend/origin_conformance.js +136 -0
  140. package/dist/testing/cross_backend/origin_conformance.json +1679 -0
  141. package/dist/testing/cross_backend/origin_scope.d.ts +16 -0
  142. package/dist/testing/cross_backend/origin_scope.d.ts.map +1 -0
  143. package/dist/testing/cross_backend/origin_scope.js +140 -0
  144. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts +35 -0
  145. package/dist/testing/cross_backend/role_grant_cross_helpers.d.ts.map +1 -0
  146. package/dist/testing/cross_backend/role_grant_cross_helpers.js +64 -0
  147. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts +8 -0
  148. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  149. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +44 -33
  150. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  151. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +6 -4
  152. package/dist/testing/cross_backend/spine_surface_constants.d.ts +19 -0
  153. package/dist/testing/cross_backend/spine_surface_constants.d.ts.map +1 -1
  154. package/dist/testing/cross_backend/spine_surface_constants.js +22 -0
  155. package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
  156. package/dist/testing/cross_backend/testing_backdoor.js +20 -7
  157. package/dist/testing/cross_backend/testing_reset_actions.d.ts +180 -5
  158. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  159. package/dist/testing/cross_backend/testing_reset_actions.js +154 -5
  160. package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
  161. package/dist/testing/cross_backend/token_scope_surface.js +25 -21
  162. package/dist/testing/cross_backend/transport_composition.d.ts +60 -0
  163. package/dist/testing/cross_backend/transport_composition.d.ts.map +1 -0
  164. package/dist/testing/cross_backend/transport_composition.js +364 -0
  165. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  166. package/dist/testing/cross_backend/ts_spine_backend_config.js +21 -6
  167. package/dist/testing/cross_backend/ws_role_gated.d.ts +38 -0
  168. package/dist/testing/cross_backend/ws_role_gated.d.ts.map +1 -0
  169. package/dist/testing/cross_backend/ws_role_gated.js +142 -0
  170. package/dist/testing/entities.d.ts +23 -0
  171. package/dist/testing/entities.d.ts.map +1 -1
  172. package/dist/testing/entities.js +37 -0
  173. package/dist/testing/integration.d.ts.map +1 -1
  174. package/dist/testing/integration.js +3 -1
  175. package/dist/testing/middleware.d.ts +10 -0
  176. package/dist/testing/middleware.d.ts.map +1 -1
  177. package/dist/testing/middleware.js +31 -7
  178. package/dist/testing/stubs.d.ts +16 -3
  179. package/dist/testing/stubs.d.ts.map +1 -1
  180. package/dist/testing/stubs.js +20 -7
  181. package/dist/testing/surface_invariants.js +2 -2
  182. package/package.json +12 -12
@@ -28,11 +28,16 @@
28
28
  * two cannot drift into closing a revoked token's socket while leaving its
29
29
  * stream open.
30
30
  *
31
- * `role` is the one scope this guard deliberately does not act on:
32
- * `perform_action` re-authorizes every message, so a socket whose account just
33
- * lost the gating role is refused at its next message, and closing it would
34
- * only change when the caller learns. A stream has no later check, so the SSE
35
- * guard closes there.
31
+ * `role` closes the target account's sockets whose endpoint gated the upgrade
32
+ * on the revoked role (`close_sockets_for_role`), with
33
+ * `WS_CLOSE_PERMISSION_REVOKED`, when the revoked grant was global
34
+ * (`to_global_role_revocation` — a role gate admits on global grants only).
35
+ * `perform_action` re-authorizes every *request* against its own action, but
36
+ * the role gate is the only check on what the socket *receives*: broadcasts
37
+ * and notifications reach every admitted connection of the endpoint with no
38
+ * per-recipient filter, so a socket left open past the revocation would keep
39
+ * receiving what the gate exists to withhold. The role is the only revocation
40
+ * no handler closes for, so this guard is its one closer.
36
41
  *
37
42
  * For standard WS endpoints mounted via `AppServerOptions.ws_endpoints`,
38
43
  * `create_app_server` registers the guard automatically per
@@ -45,7 +50,7 @@
45
50
  * @module
46
51
  */
47
52
  import { UnreachableError } from '@fuzdev/fuz_util/error.ts';
48
- import { to_revocation_scope } from '../auth/audit_log_schema.js';
53
+ import { to_global_role_revocation, to_revocation_scope } from '../auth/audit_log_schema.js';
49
54
  /**
50
55
  * Create an audit event handler that closes connections on a successful
51
56
  * revocation row, dispatching on the event's `RevocationScope`:
@@ -55,7 +60,9 @@ import { to_revocation_scope } from '../auth/audit_log_schema.js';
55
60
  * - `account` → `close_sockets_for_account(target_account_id ?? account_id)`
56
61
  * — `logout`, `password_change`, `session_revoke_all`, `token_revoke_all`,
57
62
  * `account_delete`, `account_purge`
58
- * - `role` → nothing, deliberately (module doc)
63
+ * - `role` → `close_sockets_for_role(target_account_id ?? account_id,
64
+ * metadata.role)` when the revoked grant was global; a scoped revoke closes
65
+ * nothing (module doc)
59
66
  * - `none` → nothing — every other event, and any event type fuz_app does not
60
67
  * define
61
68
  *
@@ -81,10 +88,17 @@ export const create_ws_auth_guard = (closer, log) => {
81
88
  switch (scope) {
82
89
  case 'none':
83
90
  return;
84
- // A decision, not a fallthrough: per-message dispatch re-authorizes,
85
- // so the next message is refused. The SSE guard closes on this scope.
86
- case 'role':
91
+ case 'role': {
92
+ // global grants only — a role gate never admitted on a scoped one
93
+ const revoked = to_global_role_revocation(event);
94
+ if (!revoked)
95
+ return;
96
+ const closed = closer.close_sockets_for_role(revoked.account_id, revoked.role);
97
+ if (closed > 0) {
98
+ log.info(`WS auth guard: closed ${closed} socket(s) gated on ${revoked.role} for account ${revoked.account_id} (${event.event_type})`);
99
+ }
87
100
  return;
101
+ }
88
102
  case 'session': {
89
103
  const session_id = event.metadata?.session_id;
90
104
  if (typeof session_id !== 'string' || session_id.length === 0)
@@ -124,7 +124,10 @@ import { type PeerRequestOptions, type PeerRequestOutcome } from './peer_request
124
124
  * One record per connection. `token_hash` is set for cookie-session
125
125
  * connections, `api_token_id` for bearer (`api_token`) connections, and
126
126
  * both are null for daemon-token connections (reachable only via
127
- * `BackendWebsocketTransport.close_sockets_for_account`).
127
+ * `BackendWebsocketTransport.close_sockets_for_account`). `required_roles`
128
+ * is what the connection's endpoint gated the upgrade on, kept per
129
+ * registration because endpoints with different gates can share one
130
+ * transport.
128
131
  */
129
132
  export interface ConnectionIdentity {
130
133
  /** Blake3 session token hash, or null for non-session credentials. */
@@ -133,6 +136,11 @@ export interface ConnectionIdentity {
133
136
  account_id: Uuid;
134
137
  /** `api_token.id` for bearer-authenticated connections, else null. */
135
138
  api_token_id: string | null;
139
+ /**
140
+ * The roles the endpoint required at upgrade (any-of), or empty for an
141
+ * ungated endpoint — what `close_sockets_for_role` matches on.
142
+ */
143
+ required_roles: ReadonlyArray<string>;
136
144
  }
137
145
  /**
138
146
  * Structural capability for transports that can broadcast with a
@@ -227,8 +235,9 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
227
235
  *
228
236
  * The entry is closeable at once: a `close_sockets_for_*` matching
229
237
  * `token_hash` / `account_id` / `api_token_id` removes it, aborts
230
- * `abort_controller`, and closes `ws` with `WS_CLOSE_SESSION_REVOKED`; the
231
- * later `admit` then refuses. Until admitted it evicts nothing, is not
238
+ * `abort_controller`, and closes `ws` with `WS_CLOSE_SESSION_REVOKED`
239
+ * (`close_sockets_for_role`, matching `required_roles`, with
240
+ * `WS_CLOSE_PERMISSION_REVOKED`); the later `admit` then refuses. Until admitted it evicts nothing, is not
232
241
  * counted toward the per-account cap, and receives no message.
233
242
  *
234
243
  * The caller owns the entry until it is admitted: a refusal closes it with
@@ -246,11 +255,27 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
246
255
  * @param api_token_id - `api_token.id` for bearer connections, else `null`
247
256
  * @param abort_controller - the socket-scoped controller to abort when the
248
257
  * transport closes this connection (revocation or cap eviction)
258
+ * @param required_roles - the roles the endpoint gated the upgrade on, for
259
+ * `close_sockets_for_role`; empty for an ungated endpoint
249
260
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
250
261
  * @mutates this - inserts an un-admitted entry into `#connections`, unless
251
262
  * the transport is closing
252
263
  */
253
- register_pending(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null, abort_controller?: AbortController | null): Uuid;
264
+ register_pending(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null, abort_controller?: AbortController | null, required_roles?: ReadonlyArray<string>): Uuid;
265
+ /**
266
+ * The close code the transport closed a still-pending registration with —
267
+ * `WS_CLOSE_SESSION_REVOKED` for a credential revocation,
268
+ * `WS_CLOSE_PERMISSION_REVOKED` for a role close, `WS_CLOSE_GOING_AWAY`
269
+ * for the shutdown — read once: the record is dropped by this call (and by
270
+ * `remove_connection`). `null` when the transport did not close it while
271
+ * pending. For a caller whose `admit` was refused, to say why; the twin of
272
+ * the reason the Rust `ConnectionRegistry::admit` refuses with.
273
+ *
274
+ * @param connection_id - the id `register_pending` returned
275
+ * @returns the close code, or `null`
276
+ * @mutates this - drops the recorded code
277
+ */
278
+ take_pending_close_code(connection_id: Uuid): number | null;
254
279
  /**
255
280
  * Admit a pending connection — phase two (module doc).
256
281
  *
@@ -294,12 +319,14 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
294
319
  * connection is always admitted — unless the transport is closing: then it
295
320
  * is born closed, as in `register_pending`, and evicts nothing.
296
321
  *
322
+ * @param required_roles - the roles the endpoint gated the connection on,
323
+ * for `close_sockets_for_role`; empty for an ungated one
297
324
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
298
325
  * @mutates this - inserts an admitted entry into `#connections`; past the
299
326
  * cap, first removes the account's oldest admitted connections and closes
300
327
  * their `WSContext`
301
328
  */
302
- add_connection(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null, abort_controller?: AbortController | null): Uuid;
329
+ add_connection(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null, abort_controller?: AbortController | null, required_roles?: ReadonlyArray<string>): Uuid;
303
330
  /**
304
331
  * Remove a connection and its auth tracking data, pending or admitted, by
305
332
  * the id `register_pending` / `add_connection` returned. Idempotent — safe
@@ -376,6 +403,25 @@ export declare class BackendWebsocketTransport implements FilterableBroadcastTra
376
403
  * `WS_CLOSE_SESSION_REVOKED`
377
404
  */
378
405
  close_sockets_for_token(api_token_id: string): number;
406
+ /**
407
+ * Close the sockets of `account_id` whose endpoint required `role`,
408
+ * including a registration still pending admission — the close for a
409
+ * revoked global grant of a role the upgrade gate admitted on. Each closes
410
+ * with `WS_CLOSE_PERMISSION_REVOKED`, not a revocation's code: the
411
+ * credential is still good.
412
+ *
413
+ * **Over-closes an any-of gate.** An endpoint gated on several roles admits
414
+ * an account holding any one of them, but this closes on the loss of any
415
+ * listed role, without checking whether the account still holds another.
416
+ * The client can reconnect when it still qualifies. The account's sockets
417
+ * on endpoints that did not list `role`, and on ungated ones, stay open.
418
+ *
419
+ * @returns the number of sockets closed
420
+ * @mutates this - removes matching connections, aborts their registered
421
+ * controllers, and closes their underlying `WSContext` with
422
+ * `WS_CLOSE_PERMISSION_REVOKED`
423
+ */
424
+ close_sockets_for_role(account_id: Uuid, role: string): number;
379
425
  /**
380
426
  * Close every connection, pending or admitted, with `WS_CLOSE_GOING_AWAY` —
381
427
  * the shutdown close `AppServer.close` runs. Not a revocation: the client
@@ -1 +1 @@
1
- {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiHG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEzC,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AACjD,OAAO,EAAe,KAAK,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAEjE,OAAO,KAAK,EAEX,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,oBAAoB,EACpB,eAAe,EACf,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAQ5B,OAAO,EASN,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAEN,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,MAAM,mBAAmB,CAAC;AAU3B;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IAClC,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,4CAA4C;IAC5C,UAAU,EAAE,IAAI,CAAC;IACjB,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,4BAA6B,SAAQ,SAAS;IAC9D,kBAAkB,EAAE,CACnB,OAAO,EAAE,gCAAgC,EACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,KAChD,MAAM,CAAC;CACZ;AAED,qDAAqD;AACrD,eAAO,MAAM,iCAAiC,GAC7C,WAAW,SAAS,KAClB,SAAS,IAAI,4BAEqE,CAAC;AAEtF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mCAAmC,KAAK,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,+BAA+B,QAAkB,CAAC;AAE/D,+CAA+C;AAC/C,MAAM,WAAW,gCAAgC;IAChD;;;;;;;;;;;;;;OAcG;IACH,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C;;;;;;;;;;OAUG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAuBD,qBAAa,yBAA0B,YAAW,4BAA4B;;IAC7E,QAAQ,CAAC,cAAc,EAAG,uBAAuB,CAAU;IA8B3D;;;OAGG;gBACS,OAAO,CAAC,EAAE,gCAAgC;IAuBtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,gBAAgB,CACf,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,IAAI,EAChB,YAAY,GAAE,MAAM,GAAG,IAAW,EAClC,gBAAgB,GAAE,eAAe,GAAG,IAAW,GAC7C,IAAI;IAKP;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,aAAa,EAAE,IAAI,GAAG,OAAO;IAanC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8BG;IACH,cAAc,CACb,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,IAAI,EAChB,YAAY,GAAE,MAAM,GAAG,IAAW,EAClC,gBAAgB,GAAE,eAAe,GAAG,IAAW,GAC7C,IAAI;IAgCP;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,iBAAiB,CAAC,aAAa,EAAE,IAAI,GAAG,IAAI;IAW5C;;;;;;;;;;;;;;;;;OAiBG;IACH,gBAAgB,CAAC,aAAa,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO;IAqD5E;;;;;;;;OAQG;IACH,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAIrD;;;;;;;;OAQG;IACH,yBAAyB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM;IAInD;;;;;;;;;;;;OAYG;IACH,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAIrD;;;;;;;;;;;;;;;;;OAiBG;IACH,iBAAiB,IAAI,MAAM;IAQ3B;;;;;OAKG;IACH,UAAU,IAAI,OAAO;IA2If,IAAI,CACT,OAAO,EAAE,cAAc,EACvB,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,sBAAsB,CAAC;IAC5B,IAAI,CACT,OAAO,EAAE,mBAAmB,EAC5B,OAAO,CAAC,EAAE,oBAAoB,GAC5B,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA2CvC;;;;;;;;;;;;;OAaG;IACH,kBAAkB,CACjB,OAAO,EAAE,gCAAgC,EACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,GAClD,MAAM;IAYT;;;;;;;;;;;;;OAaG;IACH,eAAe,CAAC,UAAU,EAAE,IAAI,EAAE,OAAO,EAAE,gCAAgC,GAAG,MAAM;IAIpF;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,aAAa,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAMpD;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,kBAAkB,CACjB,aAAa,EAAE,IAAI,EACnB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,oBAAoB,GAAG,SAAS,EACxC,OAAO,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,kBAAkB,CAAC;IA2B9B;;;;;;;;;;OAUG;IACH,qBAAqB,CACpB,aAAa,EAAE,IAAI,EACnB,QAAQ,EAAE,eAAe,GAAG,oBAAoB,GAC9C,OAAO;IAIV;;;;;;;;;OASG;IACH,aAAa,CAAC,aAAa,EAAE,IAAI,GAAG,OAAO;IAI3C,QAAQ,IAAI,OAAO;IAInB;;;;;;;;OAQG;IACH,oBAAoB,IAAI,MAAM;IAQ9B;;;;;OAKG;IACH,4BAA4B,IAAI,MAAM;CAOtC"}
1
+ {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"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"}
@@ -117,7 +117,7 @@ import { Logger } from '@fuzdev/fuz_util/log.ts';
117
117
  import { create_uuid } from '../primitive_schemas.js';
118
118
  import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
119
119
  import { create_jsonrpc_error_response, create_jsonrpc_request, to_jsonrpc_message_id, is_jsonrpc_request } from '../http/jsonrpc_helpers.js';
120
- import { WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON, WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON, utf8_length } from './transports.js';
120
+ import { WS_CLOSE_CONNECTION_LIMIT, WS_CLOSE_GOING_AWAY, WS_CLOSE_GOING_AWAY_REASON, WS_CLOSE_OUTBOUND_OVERFLOW, WS_CLOSE_OUTBOUND_OVERFLOW_REASON, WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_PERMISSION_REVOKED_REASON, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON, utf8_length } from './transports.js';
121
121
  import { PendingPeerRequests } from './peer_request.js';
122
122
  import { WsOutboundWriter } from './ws_outbound_writer.js';
123
123
  /** Type guard for `FilterableBroadcastTransport`. */
@@ -163,6 +163,10 @@ export class BackendWebsocketTransport {
163
163
  // close arrives (`remove_connection`) or the close deadline passes — so the
164
164
  // deadline timer is cleared when the socket goes.
165
165
  #closing_writers = new Map();
166
+ // The close code each registration the transport closed while it was still
167
+ // pending got, until `take_pending_close_code` or `remove_connection` drops
168
+ // it — so a refused admission can say why it was refused.
169
+ #pending_close_codes = new Map();
166
170
  // set once by `close_all_sockets`, never cleared — every later
167
171
  // registration is born closed (module doc, "Shutdown")
168
172
  #closing = false;
@@ -197,8 +201,9 @@ export class BackendWebsocketTransport {
197
201
  *
198
202
  * The entry is closeable at once: a `close_sockets_for_*` matching
199
203
  * `token_hash` / `account_id` / `api_token_id` removes it, aborts
200
- * `abort_controller`, and closes `ws` with `WS_CLOSE_SESSION_REVOKED`; the
201
- * later `admit` then refuses. Until admitted it evicts nothing, is not
204
+ * `abort_controller`, and closes `ws` with `WS_CLOSE_SESSION_REVOKED`
205
+ * (`close_sockets_for_role`, matching `required_roles`, with
206
+ * `WS_CLOSE_PERMISSION_REVOKED`); the later `admit` then refuses. Until admitted it evicts nothing, is not
202
207
  * counted toward the per-account cap, and receives no message.
203
208
  *
204
209
  * The caller owns the entry until it is admitted: a refusal closes it with
@@ -216,14 +221,36 @@ export class BackendWebsocketTransport {
216
221
  * @param api_token_id - `api_token.id` for bearer connections, else `null`
217
222
  * @param abort_controller - the socket-scoped controller to abort when the
218
223
  * transport closes this connection (revocation or cap eviction)
224
+ * @param required_roles - the roles the endpoint gated the upgrade on, for
225
+ * `close_sockets_for_role`; empty for an ungated endpoint
219
226
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
220
227
  * @mutates this - inserts an un-admitted entry into `#connections`, unless
221
228
  * the transport is closing
222
229
  */
223
- register_pending(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
230
+ register_pending(ws, token_hash, account_id, api_token_id = null, abort_controller = null, required_roles = []) {
224
231
  if (this.#closing)
225
232
  return this.#close_born_closed(ws, abort_controller);
226
- return this.#insert(ws, token_hash, account_id, api_token_id, abort_controller, false);
233
+ return this.#insert(ws, { token_hash, account_id, api_token_id, required_roles }, abort_controller, false);
234
+ }
235
+ /**
236
+ * The close code the transport closed a still-pending registration with —
237
+ * `WS_CLOSE_SESSION_REVOKED` for a credential revocation,
238
+ * `WS_CLOSE_PERMISSION_REVOKED` for a role close, `WS_CLOSE_GOING_AWAY`
239
+ * for the shutdown — read once: the record is dropped by this call (and by
240
+ * `remove_connection`). `null` when the transport did not close it while
241
+ * pending. For a caller whose `admit` was refused, to say why; the twin of
242
+ * the reason the Rust `ConnectionRegistry::admit` refuses with.
243
+ *
244
+ * @param connection_id - the id `register_pending` returned
245
+ * @returns the close code, or `null`
246
+ * @mutates this - drops the recorded code
247
+ */
248
+ take_pending_close_code(connection_id) {
249
+ const code = this.#pending_close_codes.get(connection_id);
250
+ if (code === undefined)
251
+ return null;
252
+ this.#pending_close_codes.delete(connection_id);
253
+ return code;
227
254
  }
228
255
  /**
229
256
  * Admit a pending connection — phase two (module doc).
@@ -281,24 +308,26 @@ export class BackendWebsocketTransport {
281
308
  * connection is always admitted — unless the transport is closing: then it
282
309
  * is born closed, as in `register_pending`, and evicts nothing.
283
310
  *
311
+ * @param required_roles - the roles the endpoint gated the connection on,
312
+ * for `close_sockets_for_role`; empty for an ungated one
284
313
  * @returns the freshly assigned `connection_id` (branded `Uuid`)
285
314
  * @mutates this - inserts an admitted entry into `#connections`; past the
286
315
  * cap, first removes the account's oldest admitted connections and closes
287
316
  * their `WSContext`
288
317
  */
289
- add_connection(ws, token_hash, account_id, api_token_id = null, abort_controller = null) {
318
+ add_connection(ws, token_hash, account_id, api_token_id = null, abort_controller = null, required_roles = []) {
290
319
  if (this.#closing)
291
320
  return this.#close_born_closed(ws, abort_controller);
292
321
  if (this.#max_connections_per_account !== null) {
293
322
  this.#evict_oldest_for_account(account_id, this.#max_connections_per_account);
294
323
  }
295
- return this.#insert(ws, token_hash, account_id, api_token_id, abort_controller, true);
324
+ return this.#insert(ws, { token_hash, account_id, api_token_id, required_roles }, abort_controller, true);
296
325
  }
297
- #insert(ws, token_hash, account_id, api_token_id, abort_controller, admitted) {
326
+ #insert(ws, identity, abort_controller, admitted) {
298
327
  const connection_id = create_uuid();
299
328
  this.#connections.set(connection_id, {
300
329
  ws,
301
- identity: { token_hash, account_id, api_token_id },
330
+ identity,
302
331
  admitted,
303
332
  abort_controller,
304
333
  writer: new WsOutboundWriter({
@@ -333,6 +362,7 @@ export class BackendWebsocketTransport {
333
362
  * requests as `connection_gone`
334
363
  */
335
364
  remove_connection(connection_id) {
365
+ this.#pending_close_codes.delete(connection_id);
336
366
  this.#connections.get(connection_id)?.writer.dispose();
337
367
  // a connection the transport closed: its socket is gone now
338
368
  const closing = this.#closing_writers.get(connection_id);
@@ -451,6 +481,27 @@ export class BackendWebsocketTransport {
451
481
  close_sockets_for_token(api_token_id) {
452
482
  return this.#close_where((id) => id.api_token_id === api_token_id);
453
483
  }
484
+ /**
485
+ * Close the sockets of `account_id` whose endpoint required `role`,
486
+ * including a registration still pending admission — the close for a
487
+ * revoked global grant of a role the upgrade gate admitted on. Each closes
488
+ * with `WS_CLOSE_PERMISSION_REVOKED`, not a revocation's code: the
489
+ * credential is still good.
490
+ *
491
+ * **Over-closes an any-of gate.** An endpoint gated on several roles admits
492
+ * an account holding any one of them, but this closes on the loss of any
493
+ * listed role, without checking whether the account still holds another.
494
+ * The client can reconnect when it still qualifies. The account's sockets
495
+ * on endpoints that did not list `role`, and on ungated ones, stay open.
496
+ *
497
+ * @returns the number of sockets closed
498
+ * @mutates this - removes matching connections, aborts their registered
499
+ * controllers, and closes their underlying `WSContext` with
500
+ * `WS_CLOSE_PERMISSION_REVOKED`
501
+ */
502
+ close_sockets_for_role(account_id, role) {
503
+ return this.#close_where((id) => id.account_id === account_id && id.required_roles.includes(role), WS_CLOSE_PERMISSION_REVOKED, WS_CLOSE_PERMISSION_REVOKED_REASON);
504
+ }
454
505
  /**
455
506
  * Close every connection, pending or admitted, with `WS_CLOSE_GOING_AWAY` —
456
507
  * the shutdown close `AppServer.close` runs. Not a revocation: the client
@@ -511,6 +562,8 @@ export class BackendWebsocketTransport {
511
562
  }
512
563
  #end_connection(connection_id, entry, code, reason, backlog = 'flush') {
513
564
  this.#cleanup_connection(connection_id);
565
+ if (!entry.admitted)
566
+ this.#pending_close_codes.set(connection_id, code);
514
567
  // abort before the close so a handler observing `ctx.signal` bails out
515
568
  // without waiting for the close handshake
516
569
  entry.abort_controller?.abort();
@@ -46,10 +46,12 @@ export interface WsEndpointSpec {
46
46
  */
47
47
  actions: ReadonlyArray<Action>;
48
48
  /**
49
- * Roles permitted to upgrade — any-of disjunction. Omit (or pass `[]`)
50
- * to skip the upgrade-time role gate; per-action `auth` on each spec
51
- * still applies at dispatch time via `perform_action`. Pass
52
- * `[ROLE_ADMIN]` for an admin-only WS endpoint.
49
+ * Roles permitted to upgrade — any-of disjunction, global grants only.
50
+ * Omit (or pass `[]`) to skip the upgrade-time role gate; per-action
51
+ * `auth` on each spec still applies at dispatch time via
52
+ * `perform_action`. Pass `[ROLE_ADMIN]` for an admin-only WS endpoint.
53
+ * Held for the socket's life — see `register_ws_endpoint`'s
54
+ * `required_roles`.
53
55
  */
54
56
  required_roles?: ReadonlyArray<RoleName>;
55
57
  /**
@@ -133,18 +135,17 @@ export interface WsEndpointSpec {
133
135
  * Set `false` when a consumer needs to compose their own callback
134
136
  * from scratch — or to opt out of the auto-wiring entirely.
135
137
  *
136
- * NOTE: does NOT close sockets on `role_grant_revoke` — that omission
137
- * is deliberate (per-connection role tracking is out of scope). A user
138
- * whose admin role is revoked keeps their socket open; the next message
139
- * gets `forbidden` from the per-message authorization phase. Consumers
140
- * wanting role-revoke disconnection use `extra_audit_handlers`.
138
+ * The guard is also the one closer of a role revocation: a revoked
139
+ * global grant of a role in `required_roles` closes the account's
140
+ * sockets on this endpoint with `WS_CLOSE_PERMISSION_REVOKED`. With the
141
+ * guard off for a transport, nothing closes them.
141
142
  */
142
143
  auth_guard?: boolean;
143
144
  /**
144
145
  * Extra audit-event handlers registered via `deps.audit.add_listener`
145
146
  * AFTER the standard `auth_guard` wiring (when enabled). By the time
146
147
  * these run, the standard guard may have already closed sockets. Use
147
- * for role-revoke disconnection, custom analytics, etc.
148
+ * for custom analytics, a consumer's own revocation events, etc.
148
149
  *
149
150
  * Never deduped — consumer-owned; pass the same handler twice and it
150
151
  * fires twice.
@@ -1 +1 @@
1
- {"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD;AAED,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,cAAc,GAAG;IAAE,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC;AAEjG;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,cAAc,aAAa,CAAC,cAAc,CAAC,GAAG,SAAS,EACvD,iBAAiB,aAAa,CAAC,MAAM,CAAC,KACpC,KAAK,CAAC,sBAAsB,CAAC,GAAG,SAI/B,CAAC"}
1
+ {"version":3,"file":"ws_endpoint_spec.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/ws_endpoint_spec.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AACvD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAC5E,OAAO,KAAK,EACX,sBAAsB,EACtB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,+BAA+B,CAAC;AAEvE;;;;;;;;GAQG;AACH,MAAM,WAAW,cAAc;IAC9B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACxC;;;;;OAKG;IACH,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC/B;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;IACzC;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,sBAAsB,CAAC;IAC7C,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,kBAAkB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAmCG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;;;;;;OAQG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,iBAAiB,CAAC,CAAC;CACxD;AAED,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,cAAc,GAAG;IAAE,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC;AAEjG;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAChC,cAAc,aAAa,CAAC,cAAc,CAAC,GAAG,SAAS,EACvD,iBAAiB,aAAa,CAAC,MAAM,CAAC,KACpC,KAAK,CAAC,sBAAsB,CAAC,GAAG,SAI/B,CAAC"}
@@ -35,7 +35,7 @@ over the pool so rows persist when request transactions roll back. See root
35
35
  - `auth/session_cookie.ts` — `SessionOptions<T>`, `parse_session`, `process_session_cookie` (`'none' | 'clear'` — no refresh action; the 30-day lifetime is an absolute cap signed once at mint), `create_session_config`, `fuz_session_config`, `SESSION_AGE_MAX`.
36
36
  - `auth/password.ts` — `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300).
37
37
  - `auth/password_argon2.ts` — `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps`.
38
- - `auth/api_token.ts` — `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token`.
38
+ - `auth/api_token.ts` — `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token`, and the shape precheck `is_api_token_shaped` (`API_TOKEN_REGEX`: the prefix plus exactly `API_TOKEN_BODY_LENGTH` = 43 base64url chars).
39
39
  - `auth/daemon_token.ts` — `DaemonToken`, `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState`.
40
40
  - `auth/bootstrap_account.ts` — `bootstrap_account` (one-shot, `bootstrap_lock`-protected).
41
41
 
@@ -74,7 +74,7 @@ Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
74
74
 
75
75
  ### Queries
76
76
 
77
- All take `deps: QueryDeps = {db}` first; `query_validate_api_token` adds `log`.
77
+ All take `deps: QueryDeps = {db}` first; `query_validate_api_token` adds `log` and returns a `ValidatedApiToken` — the row with `scope` parsed, refusing (returning `undefined` for) a row whose stored scope is unreadable.
78
78
 
79
79
  - `auth/account_queries.ts` — account CRUD, actor resolution, password update with verify-write race guard, paged `query_admin_account_list`.
80
80
  - `auth/actor_lookup_queries.ts` — batched `actor` ⨝ `account` for the labels arc.
@@ -219,11 +219,12 @@ Everything else listed under §RPC action surfaces.
219
219
 
220
220
  ### Middleware
221
221
 
222
- - `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token` (the content-type refusal isn't a middleware: `apply_route_specs` checks every `POST` route, see `http/simple_request.ts`). `AUTH_MIDDLEWARE_PATH` (`'/api/*'`) is where `create_app_server` mounts the stack; two assembly asserts lean on it: `assert_endpoint_in_auth_scope` (an `rpc_endpoints` / `ws_endpoints` path, or the bootstrap route under `bootstrap.route_prefix`, must sit inside it) and `assert_middleware_stack_mounted(stack_specs, middleware_specs)`, run on a `transform_middleware` result against the pre-transform stack (`[proxy, ...auth]`) — each stack spec, matched by its `handler` closure (not its spoofable `name`), mounted exactly once at its original path (`'*'` for the trusted proxy, `AUTH_MIDDLEWARE_PATH` for auth), in its original order; other specs may sit anywhere, including before the proxy and between the auth layers. The proxy is in the stack because dropping it turns every `get_client_ip` into `'unknown'`, one shared bucket for every IP-keyed limiter. The transform receives shallow copies in a fresh array, so an in-place mutation can't rewrite the originals the check reads. The check pins where those layers sit, not what added layers do — a layer you add runs with full context access. Both run in `create_test_app_surface_spec` too.
222
+ - `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token` (the content-type refusal isn't a middleware: `apply_route_specs` checks every `POST` route, see `http/simple_request.ts`). The origin check mounts on every path (`ORIGIN_MIDDLEWARE_PATH`, `'*'`), less its `origin_carve_outs` — each a `MiddlewareCarveOut` (`{path, reason}`, copied and frozen at assembly by `freeze_origin_carve_outs`, refused by `assert_origin_carve_outs_valid` without a reason or when it overlaps the auth scope — `middleware_paths_overlap` against `AUTH_MIDDLEWARE_PATH`, which holds every credential-reading and credential-minting route — and recorded on the origin spec so the surface leaves its `403` off the carved routes); `AUTH_MIDDLEWARE_PATH` (`'/api/*'`) is where `create_app_server` mounts the rest of the stack; assembly asserts lean on it: `assert_endpoint_in_auth_scope` (an `rpc_endpoints` / `ws_endpoints` path, or the bootstrap route under `bootstrap.route_prefix`, must sit inside it — so no carve-out can reach one), `assert_credential_routes_in_auth_scope(route_specs)` (every registered route that reads a credential — `auth.account` not `'none'`, or marked `reads_credential`, as account status is — or is marked `mints_credential` — login, signup, bootstrap — must sit inside it too, so a consumer prefixing the relative account or signup specs outside `/api/` is refused), and `assert_middleware_stack_mounted(stack_specs, middleware_specs)`, run on a `transform_middleware` result against the pre-transform stack (`[proxy, ...auth]`) — each stack spec, matched by its `handler` closure (not its spoofable `name`), mounted exactly once at its original path (`'*'` for the trusted proxy and the origin check, `AUTH_MIDDLEWARE_PATH` for the rest of auth), in its original order; other specs may sit anywhere, including before the proxy and between the auth layers. The proxy is in the stack because dropping it turns every `get_client_ip` into `'unknown'`, one shared bucket for every IP-keyed limiter. The transform receives shallow copies in a fresh array, so an in-place mutation can't rewrite the originals the check reads. The check pins where those layers sit, not what added layers do — a layer you add runs with full context access. Both run in `create_test_app_surface_spec` too.
223
223
  - `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role` — the dispatcher's role gate and every builtin-role check read the **global** grant via `has_scoped_role(_, role, null)` / `has_any_scoped_role(_, roles, null)`; scope-blind `has_role` is never the gate for a builtin role), guards (`require_auth`, `require_role`, `require_credential_types`, `require_token_scope`), `token_scope_surface_denial` (the direct-call form, for surfaces that aren't route specs), `refresh_role_grants`.
224
224
  - `auth/session_middleware.ts` — `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap; returns the token hashes of the sessions the cap evicted, which the caller closes after its commit — only login passes a cap).
225
- - `auth/resolved_auth.ts` — `ResolvedAuth` (the resolved credential as one value: `account_id`, `credential_type`, `token_hash`, `api_token_id`), `get_resolved_auth(c)` (gathers it from the context keys the auth middleware set), and `revalidate_resolved_auth(deps, resolved)`, the no-touch re-read a long-lived connection runs once it is registered: session row / token row (by id) still live and still on the account, account not soft-deleted; a daemon token re-reads its account only. Reuses the middleware's own queries and predicates. Called by the WS upgrade (`actions/register_action_ws.ts`) and the audit SSE route between registering a connection pending and admitting it. Twin of the Rust `revalidate_resolved_auth`.
226
- - `auth/bearer_auth.ts` — soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context).
225
+ - `auth/resolved_auth.ts` — `ResolvedAuth` (the resolved credential as one value: `account_id`, `credential_type`, `token_hash`, `api_token_id`, `credential_deadline`), `get_resolved_auth(c)` (gathers it from the context keys the auth middleware set), and `revalidate_resolved_auth(deps, resolved)`, the no-touch re-read a long-lived connection runs once it is registered: session row / token row (by id) still live and still on the account, account not soft-deleted; a daemon token re-reads its account only. Reuses the middleware's own queries and predicates. Called by the WS upgrade (`actions/register_action_ws.ts`) and the audit SSE route between registering a connection pending and admitting it. Twin of the Rust `revalidate_resolved_auth`.
226
+ - `auth/credential_deadline.ts` — when a resolved credential stops authenticating, which a WebSocket or audit stream opened on it closes at (expiry writes nothing a revocation listener hears): `credential_deadline_after` (the earliest remaining lifetime, anchored to `performance.now()`), `EXPIRES_IN_MS_SQL` (a row's `expires_at - NOW()`, read on the database clock in the statement that checks it, so the session and token validation queries return `expires_in_ms`), and `schedule_credential_deadline` (a chain of timers of at most `CREDENTIAL_DEADLINE_TIMER_CAP_MS`, an hour — a 30-day session is past `setTimeout`'s ceiling — each wake-up also checking a wall-clock copy of the deadline, so a host suspend, which stops the monotonic clock, delays the close by at most one timer after the resume). The middleware sets `CREDENTIAL_DEADLINE_KEY`: session — the earlier of the cookie's signed expiry (`SESSION_COOKIE_EXPIRES_AT_KEY`, set by the session middleware) and the row's; bearer — the row's, none for an eternal token; daemon token — none. Twin of the Rust `ResolvedAuth::credential_deadline`.
227
+ - `auth/bearer_auth.ts` — soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context), and soft-fails a value that is not `is_api_token_shaped` before any query. The scope it sets is the one the query parsed — there is no fallback.
227
228
  - `auth/daemon_token_middleware.ts` — `create_daemon_token_middleware(state, deps, log)` — the credential **consumer** only (soft-fail validation, keeper account resolution). The producer (`write_daemon_token` / `start_daemon_token_rotation`) lives in `testing/daemon_token_rotation.ts` behind the dev-env guard — no production assembly mints daemon tokens, mirroring the Rust spine's `fuz_testing`-confined producer. Soft-fails — discards the credential (pass-through, no own 401/503) on **every** non-success path: browser context (`Origin`/`Referer` present), malformed/invalid token, and valid-token-but-no-keeper all `next()` through to the dispatcher's `credential_type_required` (403) gate, mirroring the bearer guard and the Rust spine's `resolve.rs`. Daemon tokens are loopback-only, so browser context never arises in practice — the discard is defense-in-depth.
228
229
 
229
230
  See root ../../../CLAUDE.md §Middleware Ordering for canonical assembly
@@ -913,10 +914,11 @@ session, and once the sweep deletes an expired row `account_session_revoke`
913
914
  has nothing left to close for — so `cleanup_expired_sessions` closes each
914
915
  swept session's connections itself, by session id
915
916
  (`connection_closer.close_sockets_for_session`), which is why
916
- `query_session_cleanup_expired` returns the deleted ids. With the cleanup
917
- scheduled, a connection outlives its **session's** expiry by at most one
918
- interval. An expired API token's row is not swept and its connection is not
919
- closed (`account_token_revoke` still finds the row).
917
+ `query_session_cleanup_expired` returns the deleted ids. The close repeats
918
+ one already made: every connection closes at its credential's deadline
919
+ (`auth/credential_deadline.ts`) in the process holding it, sweep or no sweep.
920
+ An expired API token's row is not swept; its connections close at their
921
+ deadline (`account_token_revoke` still finds the row).
920
922
 
921
923
  - The delete runs in its own transaction and the closes follow the commit —
922
924
  the rule every revocation close keeps (`actions/CLAUDE.md` §Connection
@@ -111,6 +111,7 @@ export declare const account_status_route_shape: {
111
111
  account: "none";
112
112
  actor: "none";
113
113
  };
114
+ reads_credential: true;
114
115
  description: string;
115
116
  input: z.ZodNull;
116
117
  output: z.ZodObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
1
+ {"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAaD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAqD1B,CAAC"}
@@ -85,6 +85,9 @@ export const account_status_route_shape = {
85
85
  method: 'GET',
86
86
  path: '/status',
87
87
  auth: { account: 'none', actor: 'none' },
88
+ // no gate — the handler answers an anonymous caller with its own 401 — but
89
+ // it reads the account the auth middleware resolved
90
+ reads_credential: true,
88
91
  description: 'Current account info (unauthenticated: 401 with bootstrap status)',
89
92
  input: AccountStatusInput,
90
93
  output: AccountStatusOutput,
@@ -115,6 +118,7 @@ export const create_account_route_shapes = (options) => [
115
118
  method: 'POST',
116
119
  path: '/login',
117
120
  auth: { account: 'none', actor: 'none' },
121
+ mints_credential: true,
118
122
  description: 'Exchange credentials for session',
119
123
  input: LoginInput,
120
124
  output: LoginOutput,
@@ -11,6 +11,26 @@
11
11
  import { z } from 'zod';
12
12
  /** Prefix for all fuz API tokens (enables secret scanning). */
13
13
  export declare const API_TOKEN_PREFIX = "secret_fuz_token_";
14
+ /** Random bytes encoded into the raw token body (256 bits). */
15
+ export declare const API_TOKEN_ENTROPY_BYTES = 32;
16
+ /** Length of the unpadded base64url body that encodes `API_TOKEN_ENTROPY_BYTES`. */
17
+ export declare const API_TOKEN_BODY_LENGTH: number;
18
+ /**
19
+ * Regex for a raw API token: `API_TOKEN_PREFIX` plus exactly
20
+ * `API_TOKEN_BODY_LENGTH` base64url characters. Matches the format produced
21
+ * by `generate_api_token`.
22
+ */
23
+ export declare const API_TOKEN_REGEX: RegExp;
24
+ /**
25
+ * Whether `raw` has the shape of a raw API token — the bearer path's precheck,
26
+ * so a value that cannot be a token costs no database work. A `true` says
27
+ * nothing about whether the token exists. Twin of the Rust spine's
28
+ * `is_api_token_shaped`.
29
+ *
30
+ * @param raw - the value from the `Authorization: Bearer` header
31
+ * @returns `true` when `raw` matches `API_TOKEN_REGEX`
32
+ */
33
+ export declare const is_api_token_shaped: (raw: string) => boolean;
14
34
  /**
15
35
  * Regex for the public API token id (e.g. `tok_abC0_d-3xyzA`). Twelve
16
36
  * base64url characters after the `tok_` prefix. Matches the format produced
@@ -1 +1 @@
1
- {"version":3,"file":"api_token.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/api_token.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,sBAAsB,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,QAA4B,CAAC;AAE5D,8CAA8C;AAC9C,eAAO,MAAM,UAAU,aAAuC,CAAC;AAC/D,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,MAA4B,CAAC;AAE5E;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,QAAO;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAMpF,CAAC"}
1
+ {"version":3,"file":"api_token.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/api_token.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,+DAA+D;AAC/D,eAAO,MAAM,gBAAgB,sBAAsB,CAAC;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C,oFAAoF;AACpF,eAAO,MAAM,qBAAqB,QAA+C,CAAC;AAElF;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAE3B,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,KAAK,MAAM,KAAG,OAAoC,CAAC;AAEvF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,QAA4B,CAAC;AAE5D,8CAA8C;AAC9C,eAAO,MAAM,UAAU,aAAuC,CAAC;AAC/D,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,cAAc,GAAI,OAAO,MAAM,KAAG,MAA4B,CAAC;AAE5E;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,QAAO;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAMpF,CAAC"}
@@ -13,6 +13,26 @@ import { hash_blake3 } from '@fuzdev/fuz_util/hash_blake3.ts';
13
13
  import { generate_random_base64url } from '../crypto.js';
14
14
  /** Prefix for all fuz API tokens (enables secret scanning). */
15
15
  export const API_TOKEN_PREFIX = 'secret_fuz_token_';
16
+ /** Random bytes encoded into the raw token body (256 bits). */
17
+ export const API_TOKEN_ENTROPY_BYTES = 32;
18
+ /** Length of the unpadded base64url body that encodes `API_TOKEN_ENTROPY_BYTES`. */
19
+ export const API_TOKEN_BODY_LENGTH = Math.ceil((API_TOKEN_ENTROPY_BYTES * 4) / 3);
20
+ /**
21
+ * Regex for a raw API token: `API_TOKEN_PREFIX` plus exactly
22
+ * `API_TOKEN_BODY_LENGTH` base64url characters. Matches the format produced
23
+ * by `generate_api_token`.
24
+ */
25
+ export const API_TOKEN_REGEX = new RegExp(`^${API_TOKEN_PREFIX}[A-Za-z0-9_-]{${API_TOKEN_BODY_LENGTH}}$`);
26
+ /**
27
+ * Whether `raw` has the shape of a raw API token — the bearer path's precheck,
28
+ * so a value that cannot be a token costs no database work. A `true` says
29
+ * nothing about whether the token exists. Twin of the Rust spine's
30
+ * `is_api_token_shaped`.
31
+ *
32
+ * @param raw - the value from the `Authorization: Bearer` header
33
+ * @returns `true` when `raw` matches `API_TOKEN_REGEX`
34
+ */
35
+ export const is_api_token_shaped = (raw) => API_TOKEN_REGEX.test(raw);
16
36
  /**
17
37
  * Regex for the public API token id (e.g. `tok_abC0_d-3xyzA`). Twelve
18
38
  * base64url characters after the `tok_` prefix. Matches the format produced
@@ -37,7 +57,7 @@ export const hash_api_token = (token) => hash_blake3(token);
37
57
  * @returns the raw token, a public id, and the blake3 hash for storage
38
58
  */
39
59
  export const generate_api_token = () => {
40
- const raw = generate_random_base64url();
60
+ const raw = generate_random_base64url(API_TOKEN_ENTROPY_BYTES);
41
61
  const token = `${API_TOKEN_PREFIX}${raw}`;
42
62
  const token_hash = hash_api_token(token);
43
63
  const id = `tok_${raw.slice(0, 12)}`;
@@ -7,6 +7,22 @@ import type { Logger } from '@fuzdev/fuz_util/log.ts';
7
7
  import type { QueryDeps } from '../db/query_deps.js';
8
8
  import type { ApiToken } from './account_schema.js';
9
9
  import { type TokenScope } from './token_scope.js';
10
+ /**
11
+ * A live `api_token` row as `query_validate_api_token` returns it: the stored
12
+ * `scope` document already parsed, so a caller holds the narrowing the
13
+ * credential was minted with and never decides what an unreadable one means.
14
+ * The Rust twin's `ApiTokenRow` carries the parsed scope the same way.
15
+ */
16
+ export type ValidatedApiToken = Omit<ApiToken, 'scope'> & {
17
+ scope: TokenScope;
18
+ /**
19
+ * Milliseconds until the token expires — `expires_at - NOW()`, measured on
20
+ * the database clock in the statement that found it live
21
+ * (`EXPIRES_IN_MS_SQL`); `null` for an eternal token. The Rust twin's
22
+ * `ApiTokenRow.expires_in`.
23
+ */
24
+ expires_in_ms: number | null;
25
+ };
10
26
  /** Extended deps for `query_validate_api_token` which needs a logger. */
11
27
  export interface ApiTokenQueryDeps extends QueryDeps {
12
28
  log: Logger;
@@ -32,21 +48,23 @@ export declare const API_TOKEN_COLUMNS: readonly ["id", "account_id", "name", "t
32
48
  */
33
49
  export declare const query_create_api_token: (deps: QueryDeps, id: string, account_id: string, name: string, token_hash: string, scope: TokenScope, expires_at?: Date | null) => Promise<ApiToken>;
34
50
  /**
35
- * Validate a raw API token and return the token record.
51
+ * Validate a raw API token and return the token record with its scope parsed.
36
52
  *
37
53
  * Hashes the token with blake3, looks up the hash, and checks
38
- * expiration. Updates `last_used_at` and `last_used_ip` on success
54
+ * expiration, reading how long the token has left on the database clock in
55
+ * the same statement. A row whose stored scope is unreadable is refused like an
56
+ * unknown hash. Updates `last_used_at` and `last_used_ip` on success
39
57
  * (fire-and-forget — errors logged, never thrown).
40
58
  *
41
59
  * @param deps - query dependencies with logger
42
60
  * @param raw_token - the raw API token from the Authorization header
43
61
  * @param ip - the client IP address (for audit)
44
62
  * @param pending_effects - optional array to register the usage-tracking effect for later awaiting
45
- * @returns the token record if valid, or `undefined`
63
+ * @returns the token record with its parsed scope if valid, or `undefined`
46
64
  * @mutates `api_token` row - fire-and-forget UPDATE of `last_used_at` / `last_used_ip` on a valid token
47
65
  * @mutates `pending_effects` - pushes the in-flight tracking promise when provided
48
66
  */
49
- export declare const query_validate_api_token: (deps: ApiTokenQueryDeps, raw_token: string, ip: string | undefined, pending_effects: Array<Promise<void>> | undefined) => Promise<ApiToken | undefined>;
67
+ export declare const query_validate_api_token: (deps: ApiTokenQueryDeps, raw_token: string, ip: string | undefined, pending_effects: Array<Promise<void>> | undefined) => Promise<ValidatedApiToken | undefined>;
50
68
  /**
51
69
  * Look up the owning account of a live API token — one that still exists and
52
70
  * has not expired — by its public id.