@fuzdev/fuz_app 0.127.0 → 0.128.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 (44) hide show
  1. package/dist/actions/CLAUDE.md +35 -18
  2. package/dist/actions/connection_closer.d.ts +16 -4
  3. package/dist/actions/connection_closer.d.ts.map +1 -1
  4. package/dist/actions/connection_closer.js +20 -4
  5. package/dist/actions/register_action_ws.d.ts +10 -2
  6. package/dist/actions/register_action_ws.d.ts.map +1 -1
  7. package/dist/actions/register_action_ws.js +6 -3
  8. package/dist/actions/register_ws_endpoint.d.ts +3 -1
  9. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  10. package/dist/actions/register_ws_endpoint.js +3 -1
  11. package/dist/auth/CLAUDE.md +13 -9
  12. package/dist/auth/audit_emitter.d.ts +59 -12
  13. package/dist/auth/audit_emitter.d.ts.map +1 -1
  14. package/dist/auth/audit_emitter.js +53 -15
  15. package/dist/auth/middleware.d.ts +30 -0
  16. package/dist/auth/middleware.d.ts.map +1 -1
  17. package/dist/auth/middleware.js +59 -0
  18. package/dist/realtime/sse_auth_guard.d.ts +3 -1
  19. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  20. package/dist/server/app_server.d.ts +39 -12
  21. package/dist/server/app_server.d.ts.map +1 -1
  22. package/dist/server/app_server.js +92 -39
  23. package/dist/testing/CLAUDE.md +36 -23
  24. package/dist/testing/app_server.d.ts +1 -1
  25. package/dist/testing/app_server.d.ts.map +1 -1
  26. package/dist/testing/attack_surface.d.ts +1 -1
  27. package/dist/testing/attack_surface.d.ts.map +1 -1
  28. package/dist/testing/audit_drift_guard.d.ts +4 -2
  29. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  30. package/dist/testing/audit_drift_guard.js +14 -4
  31. package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -1
  32. package/dist/testing/cross_backend/account_lifecycle.js +1 -2
  33. package/dist/testing/cross_backend/capabilities.d.ts +3 -1
  34. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  35. package/dist/testing/cross_backend/testing_reset_actions.d.ts +27 -18
  36. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  37. package/dist/testing/cross_backend/testing_reset_actions.js +30 -19
  38. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  39. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  40. package/dist/testing/cross_backend/ws_round_trip.js +40 -4
  41. package/dist/testing/stubs.d.ts +23 -6
  42. package/dist/testing/stubs.d.ts.map +1 -1
  43. package/dist/testing/stubs.js +39 -14
  44. package/package.json +1 -1
@@ -565,11 +565,17 @@ fail-open. Failure outcomes (`revoked: false`, a 404) queue no close — they
565
565
  carry caller-supplied ids.
566
566
 
567
567
  **One closer for every transport.** `create_realtime_closer()` returns a
568
- `RealtimeCloser` — a `ConnectionCloser` plus `add(closer)` (idempotent by
569
- reference) — whose every close fans out to each member and returns the sum.
568
+ `RealtimeCloser` — a `ConnectionCloser` plus `add(closer)` and `member_count()`
569
+ — whose every close fans out to each member and returns the sum. Membership is
570
+ by reference: a closer added twice is one member, reached once. `add` returns a
571
+ remover for that addition (idempotent); additions are counted, so the closer
572
+ stays a member until every addition is removed — one owner releasing a shared
573
+ transport leaves it reachable for the others.
570
574
  `create_app_backend` puts one on `AppDeps.connection_closer`; `create_app_server`
571
- adds each mounted WS transport and the audit stream registry, and its
572
- `AppServer.close` runs `close_all_sockets` across them all. Twin of
575
+ adds each mounted WS transport and the audit stream registry itself (passing
576
+ `connection_closer: null` to the mount functions so it holds the removers), and
577
+ removes them when its assembly fails or `AppServer.close` runs — after
578
+ `close_all_sockets` across them all. Twin of
573
579
  `RealtimeRevoker`. A member that throws does not stop the fan-out — every
574
580
  member is called, then the first error is thrown for the caller to log;
575
581
  `BackendWebsocketTransport` and `SubscriberRegistry.close_by_identity` treat a
@@ -586,7 +592,8 @@ the connections of each expired session it deletes — `auth/CLAUDE.md`
586
592
  §Cleanup), and the
587
593
  `connection_closer: RealtimeCloser | null` option of `register_action_ws` /
588
594
  `register_ws_endpoint` / `create_audit_log_sse`, which add their transport to it
589
- (`null` is the explicit opt-out).
595
+ (`null` is the explicit opt-out, or for a caller that adds the transport itself
596
+ to hold the remover, as `create_app_server` does).
590
597
 
591
598
  ## WS auth guard (`actions/transports_ws_auth_guard.ts`)
592
599
 
@@ -676,10 +683,10 @@ enforced.
676
683
 
677
684
  Mounted transport reachable at `app_server.ws_endpoints[path]`
678
685
  (`Readonly<Record<string, BackendWebsocketTransport>>`). Duplicate paths
679
- across `WsEndpointSpec`s throw at mount time. Cross-surface collisions
680
- (same `GET <path>` on both `RouteSpec` and `WsEndpointSpec`) throw with
681
- exact-string match. Pattern overlap (e.g. `GET /api/:resource` vs
682
- `/api/ws`) is not detected — Hono's specific-before-wildcard routing keeps
686
+ across `WsEndpointSpec`s throw at assembly, before any endpoint mounts.
687
+ Cross-surface collisions (same `GET <path>` on both `RouteSpec` and
688
+ `WsEndpointSpec`) throw with exact-string match. Pattern overlap (e.g.
689
+ `GET /api/:resource` vs `/api/ws`) is not detected — Hono's specific-before-wildcard routing keeps
683
690
  those working but avoid the overlap.
684
691
 
685
692
  `auth_guard: true` does NOT close sockets on `role_grant_revoke`
@@ -746,8 +753,13 @@ revocation closes only after its commit — see
746
753
 
747
754
  `connection_closer: RealtimeCloser | null` is a required option: the endpoint's
748
755
  transport is added to it, so the revocation handlers' post-commit closes reach
749
- the sockets opened here. `null` is for a transport no revocation has to reach
750
- (a harness driving the dispatcher directly).
756
+ the sockets opened here. The add is the mount's last step, so a mount that
757
+ throws (a registry that fails to compile) adds nothing; the addition is
758
+ permanent, its remover not returned. `null` is for a transport no revocation
759
+ has to reach (a harness driving the dispatcher directly), or one the caller
760
+ adds itself to hold the remover — `create_app_server` passes `null` and adds
761
+ the transport after the mount returns, releasing it when assembly fails or
762
+ the server closes.
751
763
 
752
764
  Because `onOpen` awaits, an adapter can deliver `onMessage` before admission
753
765
  (`@hono/node-ws` doesn't await `onOpen` at all). Those frames are **queued, not
@@ -800,13 +812,18 @@ Per-message side-effect queues: `pending_effects` (eager) drains via
800
812
  `flush_pending_effects`; `post_commit_effects` (deferred — pushed by
801
813
  handlers via `emit_after_commit`) drains via `flush_post_commit_effects`.
802
814
  Both flush in the same `try/finally` that releases the request controller,
803
- so fire-and-forget audit / notification effects pushed by the handler
804
- complete (or reject visibly) before the next message dispatches. The
805
- deferred thunks are invoked first and the eager writes awaited after, so a
806
- revocation's close never waits on a slow audit INSERT — held behind one, the
807
- socket would stay open on a credential already committed gone and dispatch its
808
- next frame. The
809
- deferred queue is **discarded on rollback** before it reaches that flush (a
815
+ after the reply is sent, and every rejection is logged. Nothing orders that
816
+ flush against other frames: frames on an admitted socket are not serialized
817
+ (`handle_frame` calls aren't chained, and an adapter need not await
818
+ `onMessage` — `@hono/node-ws` doesn't), so a message's effects can still be
819
+ in flight when the caller reads its reply and when the next message
820
+ dispatches. A test that reads the audit log after a WS reply awaits
821
+ `_testing_drain_effects` first, over an emitter built with
822
+ `track_inflight: true` (`src/lib/testing/CLAUDE.md`). The deferred thunks are
823
+ invoked first and the eager writes awaited after, so a revocation's close never
824
+ waits on a slow audit INSERT — held behind one, the socket would stay open on a
825
+ credential already committed gone and dispatch its next frame. The deferred
826
+ queue is **discarded on rollback** before it reaches that flush (a
810
827
  rolled-back message fires no post-commit effect). See `http/CLAUDE.md`
811
828
  §Pending Effects.
812
829
 
@@ -139,20 +139,32 @@ export declare const noop_connection_closer: ConnectionCloser;
139
139
  */
140
140
  export interface RealtimeCloser extends ConnectionCloser {
141
141
  /**
142
- * Add a transport's closer. Idempotent by reference, so two endpoints
143
- * sharing one transport add it once.
142
+ * Add a transport's closer, returning a remover for this addition.
143
+ *
144
+ * Membership is by reference: a closer added twice — two endpoints
145
+ * sharing one transport — is one member, and every close reaches it
146
+ * once. Each `add` is counted, and its remover releases only that count:
147
+ * the closer stays a member until every addition is removed, so one
148
+ * owner releasing a shared transport leaves it reachable for the others.
149
+ * Calling a remover again is a no-op.
144
150
  *
145
151
  * Members must be the same instances connections are registered on — a
146
152
  * closer over a fresh registry closes nothing.
153
+ *
154
+ * @param closer - the transport's closer
155
+ * @returns a remover for this addition, idempotent
147
156
  */
148
- add: (closer: ConnectionCloser) => void;
157
+ add: (closer: ConnectionCloser) => () => void;
158
+ /** Count of distinct members — introspection for tests and diagnostics. */
159
+ member_count: () => number;
149
160
  }
150
161
  /**
151
162
  * Create an empty `RealtimeCloser`.
152
163
  *
153
164
  * `create_app_backend` creates the one on `AppDeps.connection_closer`, and
154
165
  * `create_app_server` adds each WebSocket transport it mounts and its audit
155
- * stream registry. A consumer that mounts a transport by hand adds it through
166
+ * stream registry, removing them when its assembly fails or the server
167
+ * closes. A consumer that mounts a transport by hand adds it through
156
168
  * the `connection_closer` option of `register_ws_endpoint` /
157
169
  * `create_audit_log_sse`, or with `add`.
158
170
  */
@@ -1 +1 @@
1
- {"version":3,"file":"connection_closer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/connection_closer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAAqB,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAE5F;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAChC,qGAAqG;IACrG,yBAAyB,EAAE,CAAC,kBAAkB,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,4EAA4E;IAC5E,uBAAuB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1D;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,MAAM,CAAC;IACxD;;;;;OAKG;IACH,iBAAiB,EAAE,MAAM,MAAM,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,IAAI,CAAA;CAAE,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAC7B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,MAWF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB,GAClC,KAAK,sBAAsB,EAC3B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,IAIF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,gBAKnC,CAAC;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAe,SAAQ,gBAAgB;IACvD;;;;;;OAMG;IACH,GAAG,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,CAAC;CACxC;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,QAAO,cAiCzC,CAAC"}
1
+ {"version":3,"file":"connection_closer.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/connection_closer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAAqB,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAE5F;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,gBAAgB;IAChC,qGAAqG;IACrG,yBAAyB,EAAE,CAAC,kBAAkB,EAAE,MAAM,KAAK,MAAM,CAAC;IAClE,4EAA4E;IAC5E,uBAAuB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1D;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,UAAU,EAAE,IAAI,KAAK,MAAM,CAAC;IACxD;;;;;OAKG;IACH,iBAAiB,EAAE,MAAM,MAAM,CAAC;CAChC;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,kBAAkB,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,UAAU,EAAE,IAAI,CAAA;CAAE,CAAC;AAEzC;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,GAC7B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,MAWF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB,GAClC,KAAK,sBAAsB,EAC3B,QAAQ,gBAAgB,EACxB,QAAQ,qBAAqB,KAC3B,IAIF,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAE,gBAKnC,CAAC;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,cAAe,SAAQ,gBAAgB;IACvD;;;;;;;;;;;;;;;OAeG;IACH,GAAG,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;IAC9C,2EAA2E;IAC3E,YAAY,EAAE,MAAM,MAAM,CAAC;CAC3B;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,QAAO,cA4CzC,CAAC"}
@@ -100,17 +100,19 @@ export const noop_connection_closer = Object.freeze({
100
100
  *
101
101
  * `create_app_backend` creates the one on `AppDeps.connection_closer`, and
102
102
  * `create_app_server` adds each WebSocket transport it mounts and its audit
103
- * stream registry. A consumer that mounts a transport by hand adds it through
103
+ * stream registry, removing them when its assembly fails or the server
104
+ * closes. A consumer that mounts a transport by hand adds it through
104
105
  * the `connection_closer` option of `register_ws_endpoint` /
105
106
  * `create_audit_log_sse`, or with `add`.
106
107
  */
107
108
  export const create_realtime_closer = () => {
108
- const members = new Set();
109
+ // member → how many additions hold it; iteration order is first addition
110
+ const members = new Map();
109
111
  const fan_out = (close) => {
110
112
  let count = 0;
111
113
  let failed = false;
112
114
  let first_error;
113
- for (const member of members) {
115
+ for (const member of members.keys()) {
114
116
  try {
115
117
  count += close(member);
116
118
  }
@@ -129,8 +131,22 @@ export const create_realtime_closer = () => {
129
131
  };
130
132
  return Object.freeze({
131
133
  add: (closer) => {
132
- members.add(closer);
134
+ members.set(closer, (members.get(closer) ?? 0) + 1);
135
+ let removed = false;
136
+ return () => {
137
+ if (removed)
138
+ return;
139
+ removed = true;
140
+ const count = members.get(closer);
141
+ if (count === undefined)
142
+ return;
143
+ if (count <= 1)
144
+ members.delete(closer);
145
+ else
146
+ members.set(closer, count - 1);
147
+ };
133
148
  },
149
+ member_count: () => members.size,
134
150
  close_sockets_for_session: (session_token_hash) => fan_out((member) => member.close_sockets_for_session(session_token_hash)),
135
151
  close_sockets_for_token: (api_token_id) => fan_out((member) => member.close_sockets_for_token(api_token_id)),
136
152
  close_sockets_for_account: (account_id) => fan_out((member) => member.close_sockets_for_account(account_id)),
@@ -244,6 +244,12 @@ export interface RegisterActionWsOptions {
244
244
  * reaches. Pass `null` only when no revocation has to reach this transport
245
245
  * (a harness driving the dispatcher directly), or when the transport was
246
246
  * added by hand.
247
+ *
248
+ * The transport is added once the mount succeeds, so a mount that throws
249
+ * adds nothing. The addition is permanent — its remover is not returned.
250
+ * A caller that must release the membership (as `create_app_server` does
251
+ * on close) passes `null` and adds the transport itself, holding the
252
+ * remover `RealtimeCloser.add` returns.
247
253
  */
248
254
  connection_closer: RealtimeCloser | null;
249
255
  /**
@@ -355,9 +361,11 @@ export interface RegisterActionWsResult {
355
361
  * @mutates options.app - registers a `GET path` route via `upgradeWebSocket`
356
362
  * @mutates options.transport - per socket, registers, admits, and removes a
357
363
  * connection via `register_pending` / `admit` / `remove_connection`
358
- * @mutates options.connection_closer - adds the endpoint's transport
364
+ * @mutates options.connection_closer - adds the endpoint's transport, once the
365
+ * mount has succeeded
359
366
  * @throws Error when `max_connections_per_account` is passed alongside
360
- * `transport`, or is neither `null` nor a positive integer
367
+ * `transport`, or is neither `null` nor a positive integer, or when
368
+ * `actions` fails to compile (`compile_action_registry`)
361
369
  */
362
370
  export declare const register_action_ws: (options: RegisterActionWsOptions) => RegisterActionWsResult;
363
371
  //# sourceMappingURL=register_action_ws.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
@@ -208,9 +208,11 @@ const is_peer_response = (json) => {
208
208
  * @mutates options.app - registers a `GET path` route via `upgradeWebSocket`
209
209
  * @mutates options.transport - per socket, registers, admits, and removes a
210
210
  * connection via `register_pending` / `admit` / `remove_connection`
211
- * @mutates options.connection_closer - adds the endpoint's transport
211
+ * @mutates options.connection_closer - adds the endpoint's transport, once the
212
+ * mount has succeeded
212
213
  * @throws Error when `max_connections_per_account` is passed alongside
213
- * `transport`, or is neither `null` nor a positive integer
214
+ * `transport`, or is neither `null` nor a positive integer, or when
215
+ * `actions` fails to compile (`compile_action_registry`)
214
216
  */
215
217
  export const register_action_ws = (options) => {
216
218
  if (options.transport && options.max_connections_per_account !== undefined) {
@@ -224,7 +226,6 @@ export const register_action_ws = (options) => {
224
226
  max_connections_per_account: options.max_connections_per_account,
225
227
  log
226
228
  });
227
- options.connection_closer?.add(transport);
228
229
  // Build the dispatcher's per-method lookup. Only request_response
229
230
  // specs with a handler reach `action_map` — perform_action is the
230
231
  // only site that calls handlers, and it requires an `RpcAction`.
@@ -800,6 +801,8 @@ export const register_action_ws = (options) => {
800
801
  }
801
802
  };
802
803
  }));
804
+ // last, after everything that can throw, so a failed mount leaves no member
805
+ options.connection_closer?.add(transport);
803
806
  return { transport };
804
807
  };
805
808
  /**
@@ -62,7 +62,9 @@ export interface RegisterWsEndpointOptions extends RegisterActionWsOptions {
62
62
  * @mutates options.app - applies origin/auth/scope/authorization/role middleware
63
63
  * via `app.use`, then registers the `GET path` route via the inner
64
64
  * `register_action_ws`
65
- * @mutates options.connection_closer - adds the endpoint's transport
65
+ * @mutates options.connection_closer - adds the endpoint's transport, once the
66
+ * mount has succeeded, for good — pass `null` and add it yourself to hold the
67
+ * remover
66
68
  */
67
69
  export declare const register_ws_endpoint: (options: RegisterWsEndpointOptions) => RegisterActionWsResult;
68
70
  //# sourceMappingURL=register_ws_endpoint.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAoBF,CAAC"}
1
+ {"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAoBF,CAAC"}
@@ -86,7 +86,9 @@ const create_ws_authorization_middleware = (db) => {
86
86
  * @mutates options.app - applies origin/auth/scope/authorization/role middleware
87
87
  * via `app.use`, then registers the `GET path` route via the inner
88
88
  * `register_action_ws`
89
- * @mutates options.connection_closer - adds the endpoint's transport
89
+ * @mutates options.connection_closer - adds the endpoint's transport, once the
90
+ * mount has succeeded, for good — pass `null` and add it yourself to hold the
91
+ * remover
90
92
  */
91
93
  export const register_ws_endpoint = (options) => {
92
94
  const { app, path, allowed_origins, db, required_roles, log = new Logger('[ws]'), ...rest } = options;
@@ -121,20 +121,24 @@ upstream in `auth/signup_routes.ts` via `query_invite_find_unclaimed_match_for_u
121
121
  `auth/audit_emitter.ts` defines the `AuditEmitter` capability that lives on
122
122
  `AppDeps.audit`. Built once at backend assembly via the consumer's
123
123
  `audit_factory` callback over `create_audit_emitter`; closes over the pool +
124
- its registered listeners + optional `AuditLogConfig`. Six methods:
124
+ its registered listeners + optional `AuditLogConfig`. Its methods:
125
125
 
126
126
  - `emit(ctx, input)` — fire-and-forget pool write, pushes to `ctx.pending_effects`; a success row's listener fan-out goes on `ctx.post_commit_effects`
127
127
  - `emit_role_grant_target(ctx, auth, input)` — lifts `actor_id` / `account_id` / `ip` boilerplate for role-grant-shape events
128
128
  - `emit_pool(input)` — awaitable pool write for code paths without a request context (ad-hoc maintenance scripts; not for success audits paired with a mutation — those write in-tx and `notify` post-commit). Writes, then notifies, whatever the outcome
129
129
  - `notify(event)` — fan out an already-written row to listeners (used by in-tx audit batches like `query_accept_offer.audit_events`)
130
- - `add_listener(listener)` — append-only listener registration (twin of Rust `fuz_auth` `AuditEmitter::add_listener`)
130
+ - `add_listener(listener)` — listener registration, returning a remover for that registration (idempotent; the same function registered twice is two registrations, each with its own remover). Twin of Rust `fuz_auth` `AuditEmitter::add_listener` in firing order and snapshot-at-notify
131
131
  - `listener_count()` — registered-listener count, for tests / diagnostics
132
-
133
- Listeners are closure-private and append-only. `create_app_server` registers
134
- the audit-log SSE listener and per-endpoint WS auth guards via
135
- `add_listener` so SSE + WS fan-out compose on top of the consumer's
136
- `on_audit_event` callback without shallow-copying `AppDeps`. `notify` iterates a
137
- snapshot so a listener registered mid-fan-out fires only on the next event
132
+ - `drain_inflight()` — test-binary barrier: waits until no `emit` write is in flight, writes started while waiting included. Only an emitter built with `track_inflight: true` tracks; otherwise it resolves at once (production never tracks). Backs `_testing_drain_effects` (twin of Rust `AuditEmitter::drain_inflight` / `new_with_inflight_tracking`)
133
+
134
+ Listeners are closure-private; they change only through `add_listener` and
135
+ its removers. `create_app_server` registers the audit-log SSE listener,
136
+ per-endpoint WS auth guards, and any `extra_audit_handlers` via `add_listener`
137
+ so SSE + WS fan-out compose on top of the consumer's `on_audit_event` callback
138
+ without shallow-copying `AppDeps`, and removes them when its assembly fails or
139
+ `AppServer.close` runs — the emitter outlives any one server on its backend.
140
+ `notify` iterates a snapshot so a listener registered mid-fan-out fires only on
141
+ the next event, and one removed mid-fan-out still hears the in-flight event
138
142
  (converges with the Rust twin's cloned vec).
139
143
 
140
144
  **When listeners hear about a row.** `emit` always writes at once, on the pool
@@ -211,7 +215,7 @@ Everything else listed under §RPC action surfaces.
211
215
 
212
216
  ### Middleware
213
217
 
214
- - `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token`.
218
+ - `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token`. `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 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.
215
219
  - `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`.
216
220
  - `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).
217
221
  - `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`.
@@ -29,6 +29,8 @@
29
29
  * the query layer, or the offer-expiry rows `auth/cleanup.ts` writes in
30
30
  * its sweep transaction). Runs every registered listener; per-listener
31
31
  * throws are isolated.
32
+ * - `drain_inflight()` — test-binary barrier: await every in-flight `emit`
33
+ * write, on an emitter built with `track_inflight: true`.
32
34
  *
33
35
  * ## When listeners hear about a row
34
36
  *
@@ -56,9 +58,10 @@
56
58
  * registers additional listeners via `add_listener` after the backend is
57
59
  * built (the factory-managed audit-log SSE, per-endpoint WS auth guards, any
58
60
  * `extra_audit_handlers` on a `WsEndpointSpec`) before the first request
59
- * runs. Consumers can also register listeners directly on the emitter they
60
- * return from `audit_factory` for setups that don't pass through
61
- * `create_app_server`.
61
+ * runs, and removes each through the remover `add_listener` returns when its
62
+ * assembly fails or the server closes. Consumers can also register
63
+ * listeners directly on the emitter they return from `audit_factory` for
64
+ * setups that don't pass through `create_app_server`.
62
65
  *
63
66
  * @module
64
67
  */
@@ -168,22 +171,55 @@ export interface AuditEmitter {
168
171
  */
169
172
  notify(event: AuditLogEvent): void;
170
173
  /**
171
- * Register an audit-event listener. Append-only — listeners fire in
172
- * registration order for every row `emit` / `emit_pool` wrote and on
173
- * every `notify`.
174
+ * Register an audit-event listener. Listeners fire in registration order
175
+ * for every row `emit` / `emit_pool` wrote and on every `notify`.
176
+ *
177
+ * Returns a remover that unregisters this registration. Each call is its
178
+ * own registration: the same function registered twice fires twice, and
179
+ * each remover takes out only the registration it was returned for,
180
+ * leaving the others in place and in order. Calling a remover again is a
181
+ * no-op. A `notify` already in progress still runs a listener removed
182
+ * during its fan-out — it iterates a snapshot — and the next one does not.
174
183
  *
175
184
  * `create_app_server` registers the factory-managed audit-log SSE
176
185
  * listener and per-endpoint WS auth guards here so
177
186
  * SSE + WS fan-out compose on top of the consumer's `on_audit_event`
178
- * callback without shallow-copying `AppDeps`. Consumers can also
179
- * register listeners directly for setups that don't run through
180
- * `create_app_server`.
187
+ * callback without shallow-copying `AppDeps`, and removes them when its
188
+ * assembly fails or the server closes. Consumers can also register
189
+ * listeners directly for setups that don't run through
190
+ * `create_app_server`. The `on_audit_event` passed at construction has
191
+ * no remover — it lives as long as the emitter.
192
+ *
193
+ * Twin of the Rust `fuz_auth` `AuditEmitter::add_listener` in what
194
+ * registration means — firing order, snapshot-at-notify.
181
195
  *
182
- * Twin of the Rust `fuz_auth` `AuditEmitter::add_listener`.
196
+ * @param listener - called with each announced audit row
197
+ * @returns a remover for this registration, idempotent
198
+ */
199
+ add_listener(listener: (event: AuditLogEvent) => void): () => void;
200
+ /**
201
+ * Count of registered listeners, the construction-time `on_audit_event`
202
+ * included — introspection for tests and diagnostics.
183
203
  */
184
- add_listener(listener: (event: AuditLogEvent) => void): void;
185
- /** Count of registered listeners — introspection for tests and diagnostics. */
186
204
  listener_count(): number;
205
+ /**
206
+ * Await every fire-and-forget `emit` write in flight — the deterministic
207
+ * barrier behind the `_testing_drain_effects` test action.
208
+ *
209
+ * Waits until no tracked write is outstanding, so a write that starts
210
+ * while the drain is waiting (an emit from a post-commit thunk) is awaited
211
+ * too. Once it resolves, a following `audit_log_list` read sees every row
212
+ * whose `emit` ran before the drain was called. A write that starts after
213
+ * the drain resolves is not covered. A success row's listener fan-out runs
214
+ * as a post-commit effect and is not tracked, so an SSE broadcast or an
215
+ * auth-guard close driven by one isn't guaranteed done when the drain
216
+ * resolves — the row itself is.
217
+ *
218
+ * Resolves at once unless the emitter was built with
219
+ * `track_inflight: true` — production never tracks, so there is nothing
220
+ * to wait for. Twin of the Rust `fuz_auth` `AuditEmitter::drain_inflight`.
221
+ */
222
+ drain_inflight(): Promise<void>;
187
223
  }
188
224
  /**
189
225
  * Signature of `AuditEmitter.emit` — captured by the inner closure so
@@ -230,6 +266,17 @@ export interface CreateAuditEmitterOptions {
230
266
  * Leave unset in production — it is a test instrumentation seam.
231
267
  */
232
268
  emit_decorator?: EmitDecorator;
269
+ /**
270
+ * Track every in-flight `emit` write so `drain_inflight` can await it.
271
+ * Defaults to `false`, which makes `drain_inflight` resolve at once.
272
+ *
273
+ * Test binaries only. A transport can reply before a request's writes
274
+ * settle — a WebSocket message is answered before its effects flush — so
275
+ * a test reading the audit log after a reply needs this barrier. In
276
+ * production the tracking is pure overhead and nothing drains it. Twin of
277
+ * the Rust `fuz_auth` `AuditEmitter::new_with_inflight_tracking`.
278
+ */
279
+ track_inflight?: boolean;
233
280
  }
234
281
  /**
235
282
  * Build a bound `AuditEmitter`. Typical caller is the consumer's
@@ -1 +1 @@
1
- {"version":3,"file":"audit_emitter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_emitter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"audit_emitter.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/audit_emitter.ts"],"names":[],"mappings":"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"}
@@ -29,6 +29,8 @@
29
29
  * the query layer, or the offer-expiry rows `auth/cleanup.ts` writes in
30
30
  * its sweep transaction). Runs every registered listener; per-listener
31
31
  * throws are isolated.
32
+ * - `drain_inflight()` — test-binary barrier: await every in-flight `emit`
33
+ * write, on an emitter built with `track_inflight: true`.
32
34
  *
33
35
  * ## When listeners hear about a row
34
36
  *
@@ -56,9 +58,10 @@
56
58
  * registers additional listeners via `add_listener` after the backend is
57
59
  * built (the factory-managed audit-log SSE, per-endpoint WS auth guards, any
58
60
  * `extra_audit_handlers` on a `WsEndpointSpec`) before the first request
59
- * runs. Consumers can also register listeners directly on the emitter they
60
- * return from `audit_factory` for setups that don't pass through
61
- * `create_app_server`.
61
+ * runs, and removes each through the remover `add_listener` returns when its
62
+ * assembly fails or the server closes. Consumers can also register
63
+ * listeners directly on the emitter they return from `audit_factory` for
64
+ * setups that don't pass through `create_app_server`.
62
65
  *
63
66
  * @module
64
67
  */
@@ -75,18 +78,21 @@ import { builtin_audit_log_config } from './audit_log_schema.js';
75
78
  * @returns the bound emitter; closes over the pool + config + listener chain
76
79
  */
77
80
  export const create_audit_emitter = (options) => {
78
- const { db, log, audit_log_config = builtin_audit_log_config, emit_decorator } = options;
81
+ const { db, log, audit_log_config = builtin_audit_log_config, emit_decorator, track_inflight = false } = options;
79
82
  // Closure-private listener list — no mutable array is exposed on the
80
83
  // returned (frozen) emitter; registration goes through `add_listener`.
84
+ // One entry object per registration, so a remover takes out its own
85
+ // registration even when the same function is registered twice.
81
86
  const listeners = [];
82
87
  if (options.on_audit_event)
83
- listeners.push(options.on_audit_event);
88
+ listeners.push({ listener: options.on_audit_event });
84
89
  const notify = (event) => {
85
90
  // Snapshot-at-notify: iterate a copy so a listener that registers
86
91
  // another listener mid-fan-out doesn't have the newcomer fire for the
87
- // in-flight event (it fires on the next `notify`). Converges with the
88
- // Rust twin, which clones the listener vec before iterating.
89
- for (const listener of [...listeners]) {
92
+ // in-flight event (it fires on the next `notify`), and one removed
93
+ // mid-fan-out still hears the in-flight event. Converges with the Rust
94
+ // twin, which clones the listener vec before iterating.
95
+ for (const { listener } of [...listeners]) {
90
96
  try {
91
97
  listener(event);
92
98
  }
@@ -111,16 +117,41 @@ export const create_audit_emitter = (options) => {
111
117
  if (event)
112
118
  notify(event);
113
119
  };
120
+ // In-flight `emit` writes, when tracked — each removed as it settles, so
121
+ // the set is empty exactly when nothing is outstanding. Neither tracked
122
+ // promise rejects (`write` and `notify` log their failures).
123
+ const inflight = track_inflight ? new Set() : null;
124
+ const track = (promise) => {
125
+ if (inflight) {
126
+ inflight.add(promise);
127
+ const remove = () => {
128
+ inflight.delete(promise);
129
+ };
130
+ void promise.then(remove, remove);
131
+ }
132
+ return promise;
133
+ };
134
+ const drain_inflight = async () => {
135
+ if (!inflight)
136
+ return;
137
+ // Wait for zero, not for a snapshot: a write started while waiting —
138
+ // an emit from a post-commit thunk — joins the set and is awaited on
139
+ // the next pass. The removal reactions were registered before these,
140
+ // so a settled write is gone from the set by the time a pass resumes.
141
+ while (inflight.size > 0) {
142
+ await Promise.allSettled(inflight);
143
+ }
144
+ };
114
145
  const base_emit = (ctx, input) => {
115
146
  if (input.outcome === 'failure') {
116
147
  // an attempt happened whether or not the transaction commits
117
- ctx.pending_effects.push(emit_pool(input));
148
+ ctx.pending_effects.push(track(emit_pool(input)));
118
149
  return;
119
150
  }
120
151
  // A success row is written now and announced after the commit — and
121
152
  // not at all on rollback, when the queue is discarded. See the module
122
153
  // doc, "When listeners hear about a row".
123
- const written = write(input);
154
+ const written = track(write(input));
124
155
  ctx.pending_effects.push(written.then(() => undefined));
125
156
  emit_after_commit(ctx, async () => {
126
157
  const event = await written;
@@ -146,7 +177,13 @@ export const create_audit_emitter = (options) => {
146
177
  });
147
178
  };
148
179
  const add_listener = (listener) => {
149
- listeners.push(listener);
180
+ const entry = { listener };
181
+ listeners.push(entry);
182
+ return () => {
183
+ const index = listeners.indexOf(entry);
184
+ if (index !== -1)
185
+ listeners.splice(index, 1);
186
+ };
150
187
  };
151
188
  const listener_count = () => listeners.length;
152
189
  // Freeze the slot layout so consumers cannot hot-patch `emit` /
@@ -157,15 +194,16 @@ export const create_audit_emitter = (options) => {
157
194
  // `this.emit`, so the patch silently bypassed role-grant-shape emits.
158
195
  // Tests that need instrumentation pass `emit_decorator` so the wrap
159
196
  // is captured by the closure before the freeze. The listener list stays
160
- // closure-private; registration is append-only via `add_listener`
161
- // (`create_app_server` registers its SSE + WS listeners post-assembly,
162
- // by design).
197
+ // closure-private; it changes only through `add_listener` and the remover
198
+ // it returns (`create_app_server` registers its SSE + WS listeners
199
+ // post-assembly, by design, and removes them on close).
163
200
  return Object.freeze({
164
201
  emit,
165
202
  emit_role_grant_target,
166
203
  emit_pool,
167
204
  notify,
168
205
  add_listener,
169
- listener_count
206
+ listener_count,
207
+ drain_inflight
170
208
  });
171
209
  };
@@ -38,6 +38,36 @@ export declare const AUTH_MIDDLEWARE_PATH = "/api/*";
38
38
  * @throws Error when `path` is outside `AUTH_MIDDLEWARE_PATH`
39
39
  */
40
40
  export declare const assert_endpoint_in_auth_scope: (option: "rpc_endpoints" | "ws_endpoints", path: string) => void;
41
+ /**
42
+ * Throw unless a middleware list still mounts every spec of the stack it was
43
+ * built from — the trusted-proxy spec and the auth middleware — exactly once,
44
+ * at its original path, in its original order.
45
+ *
46
+ * `create_app_server` and `create_test_app_surface_spec` run it on the list
47
+ * a `transform_middleware` option returns, against the stack they handed it
48
+ * (`[proxy, ...auth]`). The endpoint auth-scope check
49
+ * (`assert_endpoint_in_auth_scope`) assumes the auth stack is mounted at
50
+ * `AUTH_MIDDLEWARE_PATH`, and every IP-keyed rate limiter assumes the proxy
51
+ * spec ran first — without it `get_client_ip` answers `'unknown'` for every
52
+ * caller, collapsing each limiter into one shared bucket. So a transform that
53
+ * moves, drops, duplicates, reorders, wraps, or replaces a stack layer is
54
+ * refused. Other specs may sit anywhere, including before the proxy spec and
55
+ * between the auth layers. The check pins where the stack's layers sit, not
56
+ * what added layers do — a layer you add runs with full context access.
57
+ *
58
+ * Each stack spec is matched by its `handler` — the closure its factory built
59
+ * — not by `name`, which any spec can carry, so a same-named replacement or a
60
+ * wrapped handler counts as missing. A spread copy (`{...spec}`) keeps the
61
+ * handler and still matches. Its path is compared with the original's, so
62
+ * pass specs the transform could not reach: the assemblies hand the transform
63
+ * shallow copies in a fresh array and keep the originals here.
64
+ *
65
+ * @param stack_specs - the stack the transform was built from, in mount order
66
+ * @param middleware_specs - the middleware list the transform returned
67
+ * @throws Error when a stack spec is missing, mounted more than once, mounted
68
+ * at another path, or out of order
69
+ */
70
+ export declare const assert_middleware_stack_mounted: (stack_specs: ReadonlyArray<MiddlewareSpec>, middleware_specs: ReadonlyArray<MiddlewareSpec>) => void;
41
71
  /**
42
72
  * Per-factory configuration for the standard auth middleware stack.
43
73
  */