@fuzdev/fuz_app 0.117.1 → 0.119.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 (179) hide show
  1. package/dist/actions/CLAUDE.md +301 -73
  2. package/dist/actions/action_rpc.d.ts.map +1 -1
  3. package/dist/actions/action_rpc.js +16 -1
  4. package/dist/actions/connection_closer.d.ts +136 -51
  5. package/dist/actions/connection_closer.d.ts.map +1 -1
  6. package/dist/actions/connection_closer.js +133 -34
  7. package/dist/actions/register_action_ws.d.ts +176 -24
  8. package/dist/actions/register_action_ws.d.ts.map +1 -1
  9. package/dist/actions/register_action_ws.js +581 -241
  10. package/dist/actions/register_ws_endpoint.d.ts +1 -0
  11. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  12. package/dist/actions/register_ws_endpoint.js +1 -0
  13. package/dist/actions/socket.svelte.d.ts +43 -9
  14. package/dist/actions/socket.svelte.d.ts.map +1 -1
  15. package/dist/actions/socket.svelte.js +59 -8
  16. package/dist/actions/transports.d.ts +55 -1
  17. package/dist/actions/transports.d.ts.map +1 -1
  18. package/dist/actions/transports.js +55 -1
  19. package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
  20. package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
  21. package/dist/actions/transports_ws_auth_guard.js +87 -111
  22. package/dist/actions/transports_ws_backend.d.ts +212 -30
  23. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  24. package/dist/actions/transports_ws_backend.js +327 -70
  25. package/dist/actions/ws_endpoint_spec.d.ts +26 -13
  26. package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
  27. package/dist/auth/CLAUDE.md +125 -31
  28. package/dist/auth/account_actions.d.ts +12 -18
  29. package/dist/auth/account_actions.d.ts.map +1 -1
  30. package/dist/auth/account_actions.js +47 -33
  31. package/dist/auth/account_routes.d.ts +5 -11
  32. package/dist/auth/account_routes.d.ts.map +1 -1
  33. package/dist/auth/account_routes.js +40 -36
  34. package/dist/auth/account_schema.d.ts +27 -0
  35. package/dist/auth/account_schema.d.ts.map +1 -1
  36. package/dist/auth/account_schema.js +22 -0
  37. package/dist/auth/admin_actions.d.ts +8 -17
  38. package/dist/auth/admin_actions.d.ts.map +1 -1
  39. package/dist/auth/admin_actions.js +33 -21
  40. package/dist/auth/api_token_queries.d.ts +23 -2
  41. package/dist/auth/api_token_queries.d.ts.map +1 -1
  42. package/dist/auth/api_token_queries.js +39 -3
  43. package/dist/auth/audit_emitter.d.ts +72 -44
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +60 -16
  46. package/dist/auth/audit_log_route_schema.js +7 -7
  47. package/dist/auth/audit_log_routes.d.ts +31 -6
  48. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  49. package/dist/auth/audit_log_routes.js +85 -11
  50. package/dist/auth/audit_log_schema.d.ts +48 -0
  51. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  52. package/dist/auth/audit_log_schema.js +58 -0
  53. package/dist/auth/bootstrap_account.d.ts +3 -2
  54. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  55. package/dist/auth/bootstrap_account.js +3 -3
  56. package/dist/auth/cleanup.d.ts +219 -30
  57. package/dist/auth/cleanup.d.ts.map +1 -1
  58. package/dist/auth/cleanup.js +260 -45
  59. package/dist/auth/deps.d.ts +29 -1
  60. package/dist/auth/deps.d.ts.map +1 -1
  61. package/dist/auth/migrations.d.ts.map +1 -1
  62. package/dist/auth/migrations.js +27 -0
  63. package/dist/auth/resolved_auth.d.ts +105 -0
  64. package/dist/auth/resolved_auth.d.ts.map +1 -0
  65. package/dist/auth/resolved_auth.js +124 -0
  66. package/dist/auth/role_grant_offer_ddl.d.ts +4 -2
  67. package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
  68. package/dist/auth/role_grant_offer_ddl.js +10 -3
  69. package/dist/auth/role_grant_offer_queries.d.ts +16 -7
  70. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
  71. package/dist/auth/role_grant_offer_queries.js +33 -15
  72. package/dist/auth/role_grant_offer_schema.d.ts +9 -0
  73. package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
  74. package/dist/auth/session_middleware.d.ts +8 -1
  75. package/dist/auth/session_middleware.d.ts.map +1 -1
  76. package/dist/auth/session_middleware.js +9 -3
  77. package/dist/auth/session_queries.d.ts +31 -6
  78. package/dist/auth/session_queries.d.ts.map +1 -1
  79. package/dist/auth/session_queries.js +31 -6
  80. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  81. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  82. package/dist/auth/standard_rpc_actions.js +3 -3
  83. package/dist/db/CLAUDE.md +7 -1
  84. package/dist/db/migrate.d.ts +8 -13
  85. package/dist/db/migrate.d.ts.map +1 -1
  86. package/dist/db/migrate.js +9 -38
  87. package/dist/db/status.d.ts +58 -7
  88. package/dist/db/status.d.ts.map +1 -1
  89. package/dist/db/status.js +205 -97
  90. package/dist/http/CLAUDE.md +7 -3
  91. package/dist/http/db_routes.d.ts +6 -2
  92. package/dist/http/db_routes.d.ts.map +1 -1
  93. package/dist/http/db_routes.js +2 -2
  94. package/dist/periodic.d.ts +80 -0
  95. package/dist/periodic.d.ts.map +1 -0
  96. package/dist/periodic.js +122 -0
  97. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  98. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  99. package/dist/realtime/sse_auth_guard.js +103 -73
  100. package/dist/realtime/subscriber_registry.d.ts +119 -8
  101. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  102. package/dist/realtime/subscriber_registry.js +184 -25
  103. package/dist/runtime/deno.d.ts.map +1 -1
  104. package/dist/runtime/deno.js +9 -6
  105. package/dist/runtime/deps.d.ts +3 -2
  106. package/dist/runtime/deps.d.ts.map +1 -1
  107. package/dist/runtime/secure_file.d.ts +23 -9
  108. package/dist/runtime/secure_file.d.ts.map +1 -1
  109. package/dist/runtime/secure_file.js +29 -10
  110. package/dist/server/app_backend.d.ts +13 -2
  111. package/dist/server/app_backend.d.ts.map +1 -1
  112. package/dist/server/app_backend.js +5 -2
  113. package/dist/server/app_server.d.ts +55 -15
  114. package/dist/server/app_server.d.ts.map +1 -1
  115. package/dist/server/app_server.js +41 -13
  116. package/dist/testing/CLAUDE.md +140 -48
  117. package/dist/testing/app_server.d.ts +2 -2
  118. package/dist/testing/app_server.d.ts.map +1 -1
  119. package/dist/testing/app_server.js +11 -2
  120. package/dist/testing/audit_drift_guard.d.ts +3 -52
  121. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  122. package/dist/testing/audit_drift_guard.js +2 -46
  123. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  124. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  125. package/dist/testing/connection_closer_helpers.js +11 -17
  126. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  127. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  128. package/dist/testing/cross_backend/capabilities.js +27 -12
  129. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  130. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  131. package/dist/testing/cross_backend/expected_schema.json +1 -0
  132. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  133. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  134. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  135. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  136. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  137. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  138. package/dist/testing/cross_backend/session_cap.js +55 -19
  139. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  140. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  141. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  142. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  143. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  144. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  145. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  146. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  147. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  148. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  149. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  150. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  151. package/dist/testing/sse_round_trip.d.ts +6 -4
  152. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  153. package/dist/testing/stubs.d.ts +1 -1
  154. package/dist/testing/stubs.d.ts.map +1 -1
  155. package/dist/testing/stubs.js +6 -4
  156. package/dist/testing/transports/sse_transport.d.ts +1 -1
  157. package/dist/testing/transports/ws_client.d.ts +7 -0
  158. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  159. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  160. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  161. package/dist/testing/transports/ws_raw_client.js +156 -0
  162. package/dist/testing/transports/ws_transport.d.ts +28 -0
  163. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  164. package/dist/testing/transports/ws_transport.js +45 -1
  165. package/dist/testing/ws_round_trip.d.ts +8 -3
  166. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  167. package/dist/testing/ws_round_trip.js +19 -4
  168. package/dist/ui/AdminAccounts.svelte +4 -3
  169. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  170. package/dist/ui/AdminOverview.svelte +3 -1
  171. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  172. package/dist/ui/CLAUDE.md +22 -6
  173. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  174. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  175. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  176. package/dist/ui/auth_state.svelte.d.ts +25 -1
  177. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  178. package/dist/ui/auth_state.svelte.js +28 -1
  179. package/package.json +2 -2
@@ -17,6 +17,9 @@
17
17
  * carries a broader identity for coarse close — the broader identity
18
18
  * doesn't cap across sessions.
19
19
  *
20
+ * Registration can run in two phases (`subscribe_pending` then `admit`) so a
21
+ * revocation reaches a stream that is still opening — see the class doc.
22
+ *
20
23
  * @module
21
24
  */
22
25
  /**
@@ -27,6 +30,28 @@
27
30
  * matching subscribers. `close_by_identity` force-closes all subscribers
28
31
  * whose `scope` or `groups` contain the given key — use for auth revocation.
29
32
  *
33
+ * ## Two-phase registration
34
+ *
35
+ * A stream is authorized once, at open, so a revocation has to be able to
36
+ * reach it from the moment its credential is read. A route's gates run before
37
+ * the stream exists, and each revocation closes only the streams *registered*
38
+ * under what it revoked — registering after the gates leaves a window in which
39
+ * a revocation closes nothing and the stream then opens on a dead session, or
40
+ * under a role its actor no longer holds.
41
+ *
42
+ * So a route that authenticated a revocable credential registers in two
43
+ * phases: `subscribe_pending` registers the subscription **un-admitted** —
44
+ * closeable by every `close_by_identity`, and otherwise inert: it has no
45
+ * stream, so no broadcast reaches it, the cap neither counts nor evicts for
46
+ * it, and `count` does not see it (`pending_count` does). The route then
47
+ * re-reads the credential and the role, and `admit` attaches the stream and
48
+ * opens the feed — or refuses when a revocation closed the registration in
49
+ * between, evicting nothing. The cap's eviction happens at admission.
50
+ *
51
+ * `subscribe` stays as the one-step form for a stream with nothing to revoke
52
+ * behind it. The twin of the Rust `fuz_realtime` `SseRegistry`
53
+ * (`subscribe_pending` / `admit` / `subscribe`).
54
+ *
30
55
  * @example
31
56
  * ```ts
32
57
  * const registry = new SubscriberRegistry<SseNotification>();
@@ -56,19 +81,63 @@
56
81
  * // fine — close just the stream(s) tied to a specific session
57
82
  * registry.close_by_identity(session_hash);
58
83
  * ```
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * // two-phase, for a route behind a revocable credential
88
+ * const pending = registry.subscribe_pending({scope: session_hash, groups: [account_id]});
89
+ * if (!(await credential_is_still_live())) {
90
+ * pending.unsubscribe();
91
+ * return unauthorized();
92
+ * }
93
+ * const {response, stream} = create_sse_response(c, log);
94
+ * if (registry.admit(pending, stream)) stream.on_close(pending.unsubscribe);
95
+ * else stream.close(); // closed while pending — the body is the connect comment alone
96
+ * return response;
97
+ * ```
59
98
  */
60
99
  export class SubscriberRegistry {
100
+ // insertion order is registration order — the cap's "oldest"
61
101
  #subscribers = new Set();
102
+ // each pending handle's entry; keyed per registry, so a handle from
103
+ // another registry names nothing here
104
+ #pending = new WeakMap();
62
105
  #max_per_scope;
63
106
  constructor(options) {
64
107
  this.#max_per_scope = options?.max_per_scope ?? null;
65
108
  }
66
- /** Number of active subscribers. */
109
+ /**
110
+ * Number of open — **admitted** — subscribers. A registration still pending
111
+ * admission is counted by `pending_count` instead.
112
+ */
67
113
  get count() {
68
- return this.#subscribers.size;
114
+ let count = 0;
115
+ for (const subscriber of this.#subscribers) {
116
+ if (subscriber.stream !== null)
117
+ count++;
118
+ }
119
+ return count;
69
120
  }
70
121
  /**
71
- * Add a subscriber.
122
+ * Number of registrations awaiting admission — between `subscribe_pending`
123
+ * and `admit` (or their refusal). For telemetry and tests.
124
+ */
125
+ get pending_count() {
126
+ let count = 0;
127
+ for (const subscriber of this.#subscribers) {
128
+ if (subscriber.stream === null)
129
+ count++;
130
+ }
131
+ return count;
132
+ }
133
+ /**
134
+ * Add a subscriber, admitted at once — the one-step form of
135
+ * `subscribe_pending` + `admit`.
136
+ *
137
+ * **Not for a route that authenticated a revocable credential**: a
138
+ * revocation landing between that route's gates and this call closes
139
+ * nothing, and the stream would open on a dead credential (class doc,
140
+ * "Two-phase registration").
72
141
  *
73
142
  * @param stream - SSE stream to send data to
74
143
  * @param options - channel filter and identity slots (`scope` + `groups`)
@@ -76,28 +145,72 @@ export class SubscriberRegistry {
76
145
  * @mutates registry - adds the new subscriber; closes oldest matching subscribers when `max_per_scope` is exceeded
77
146
  */
78
147
  subscribe(stream, options) {
79
- const channels = options?.channels && options.channels.length > 0 ? new Set(options.channels) : null;
80
- const scope = options?.scope ?? null;
81
- const groups = options?.groups && options.groups.length > 0 ? new Set(options.groups) : null;
82
- // Per-scope cap — only `scope` is capped, `groups` are never capped.
83
- // Insertion order of the backing Set preserves FIFO eviction semantics.
84
- if (this.#max_per_scope != null && scope !== null) {
85
- this.#enforce_scope_limit(scope, this.#max_per_scope);
86
- }
87
- const subscriber = { stream, channels, scope, groups };
148
+ const subscriber = this.#create_entry(options);
149
+ this.#admit_entry(subscriber, stream);
88
150
  this.#subscribers.add(subscriber);
89
151
  return () => {
90
152
  this.#subscribers.delete(subscriber);
91
153
  };
92
154
  }
93
155
  /**
94
- * Broadcast data to all subscribers on a channel.
156
+ * Register a subscription **un-admitted** — phase one of the two-phase
157
+ * registration (class doc).
158
+ *
159
+ * The registration is closeable at once by every `close_by_identity`
160
+ * matching its `scope` or `groups`, and until `admit` it receives no
161
+ * broadcast, is not counted, and evicts nothing. A caller that then refuses
162
+ * the request calls the returned handle's `unsubscribe`.
163
+ *
164
+ * @param options - channel filter and identity slots (`scope` + `groups`)
165
+ * @returns the pending handle `admit` takes
166
+ * @mutates registry - adds the un-admitted registration
167
+ */
168
+ subscribe_pending(options) {
169
+ const subscriber = this.#create_entry(options);
170
+ this.#subscribers.add(subscriber);
171
+ const pending = {
172
+ unsubscribe: () => {
173
+ this.#subscribers.delete(subscriber);
174
+ }
175
+ };
176
+ this.#pending.set(pending, subscriber);
177
+ return pending;
178
+ }
179
+ /**
180
+ * Admit a pending subscription, attaching the stream it delivers to — phase
181
+ * two (class doc).
182
+ *
183
+ * If the registration is gone — a revocation closed it while it was
184
+ * pending, or it was unsubscribed — nothing is evicted and the subscription
185
+ * is refused. Otherwise the per-scope cap is enforced as in `subscribe`,
186
+ * counting only admitted subscribers, and the stream starts receiving
187
+ * `broadcast` data. Admitting twice is refused the second time.
188
+ *
189
+ * @param pending - the handle `subscribe_pending` returned
190
+ * @param stream - SSE stream to send data to
191
+ * @returns `false` when the subscription was not admitted — the caller ends the stream
192
+ * @mutates registry - attaches `stream`; closes oldest matching subscribers when `max_per_scope` is exceeded
193
+ */
194
+ admit(pending, stream) {
195
+ const subscriber = this.#pending.get(pending);
196
+ if (!subscriber || subscriber.stream !== null || !this.#subscribers.has(subscriber)) {
197
+ return false;
198
+ }
199
+ this.#admit_entry(subscriber, stream);
200
+ return true;
201
+ }
202
+ /**
203
+ * Broadcast data to all admitted subscribers on a channel.
95
204
  *
96
205
  * Subscribers with no channel filter receive all broadcasts.
97
- * Subscribers with a channel filter only receive matching broadcasts.
206
+ * Subscribers with a channel filter only receive matching broadcasts. A
207
+ * registration pending admission is not an open stream and is skipped —
208
+ * nothing is queued toward a subscriber not yet authorized.
98
209
  */
99
210
  broadcast(channel, data) {
100
211
  for (const subscriber of this.#subscribers) {
212
+ if (subscriber.stream === null)
213
+ continue;
101
214
  if (subscriber.channels === null || subscriber.channels.has(channel)) {
102
215
  subscriber.stream.send(data);
103
216
  }
@@ -110,9 +223,17 @@ export class SubscriberRegistry {
110
223
  * Use for auth revocation — when a user's permissions change, close their
111
224
  * SSE connections so they must reconnect and re-authenticate.
112
225
  *
226
+ * Matches every registration, pending or admitted — never filter on
227
+ * admission here: reaching a registration in flight is the point of the
228
+ * pending phase, and `admit` then refuses it.
229
+ *
230
+ * A stream whose `close` throws does not stop the loop: every match is
231
+ * removed and closed, and the first error is thrown afterward.
232
+ *
113
233
  * @param identity - the identity key to match (checked against `scope` and `groups`)
114
234
  * @returns the number of subscribers closed
115
235
  * @mutates registry - removes matching subscribers and closes their streams
236
+ * @throws the first error a stream's `close` threw, after every match was attempted
116
237
  */
117
238
  close_by_identity(identity) {
118
239
  // collect first, then close — avoids mutating the Set during iteration
@@ -123,28 +244,66 @@ export class SubscriberRegistry {
123
244
  to_close.push(subscriber);
124
245
  }
125
246
  }
247
+ let failed = false;
248
+ let first_error;
126
249
  for (const subscriber of to_close) {
127
- subscriber.stream.close();
250
+ // removed first, so a stream whose close throws is unregistered too
128
251
  this.#subscribers.delete(subscriber);
252
+ try {
253
+ subscriber.stream?.close();
254
+ }
255
+ catch (error) {
256
+ if (!failed) {
257
+ failed = true;
258
+ first_error = error;
259
+ }
260
+ }
129
261
  }
262
+ if (failed)
263
+ throw first_error;
130
264
  return to_close.length;
131
265
  }
266
+ #create_entry(options) {
267
+ return {
268
+ stream: null,
269
+ channels: options?.channels && options.channels.length > 0 ? new Set(options.channels) : null,
270
+ scope: options?.scope ?? null,
271
+ groups: options?.groups && options.groups.length > 0 ? new Set(options.groups) : null
272
+ };
273
+ }
274
+ /**
275
+ * Enforce the per-scope cap for one more admitted subscriber on the entry's
276
+ * scope, then attach its stream.
277
+ */
278
+ #admit_entry(subscriber, stream) {
279
+ // Per-scope cap — only `scope` is capped, `groups` are never capped. The
280
+ // entry itself has no stream yet, so the eviction neither counts nor
281
+ // removes it.
282
+ if (this.#max_per_scope != null && subscriber.scope !== null) {
283
+ this.#enforce_scope_limit(subscriber.scope, this.#max_per_scope);
284
+ }
285
+ subscriber.stream = stream;
286
+ }
287
+ /**
288
+ * Close `scope`'s oldest **admitted** subscribers until one more fits under
289
+ * `max`. Pending registrations are neither counted nor closed — a
290
+ * subscription that may yet be refused must not cost a live one its slot.
291
+ */
132
292
  #enforce_scope_limit(scope, max) {
133
- // count existing subscribers with this scope (in insertion order)
293
+ // admitted subscribers with this scope, in registration order (the
294
+ // backing Set's insertion order), paired with their streams
134
295
  const matching = [];
135
296
  for (const subscriber of this.#subscribers) {
136
- if (subscriber.scope === scope)
137
- matching.push(subscriber);
297
+ if (subscriber.scope === scope && subscriber.stream !== null) {
298
+ matching.push([subscriber, subscriber.stream]);
299
+ }
138
300
  }
139
301
  // close oldest first, stopping once we've freed up room for one more
140
- let overflow = matching.length - (max - 1);
141
- let i = 0;
142
- while (overflow > 0 && i < matching.length) {
143
- const victim = matching[i];
144
- victim.stream.close();
302
+ const overflow = matching.length - (max - 1);
303
+ for (let i = 0; i < overflow; i++) {
304
+ const [victim, stream] = matching[i];
305
+ stream.close();
145
306
  this.#subscribers.delete(victim);
146
- overflow--;
147
- i++;
148
307
  }
149
308
  }
150
309
  }
@@ -1 +1 @@
1
- {"version":3,"file":"deno.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deno.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAA6B,MAAM,WAAW,CAAC;AAoExE;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,WAuKhE,CAAC"}
1
+ {"version":3,"file":"deno.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deno.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAA6B,MAAM,WAAW,CAAC;AAyExE;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,WA0KhE,CAAC"}
@@ -8,7 +8,7 @@
8
8
  * @module
9
9
  */
10
10
  import { to_error_message } from '@fuzdev/fuz_util/error.ts';
11
- import { assert_secure_mode, assert_secure_size, read_secure_bounded } from './secure_file.js';
11
+ import { assert_secure_mode, assert_secure_regular_file, assert_secure_size, read_secure_bounded } from './secure_file.js';
12
12
  /**
13
13
  * Create a runtime backed by Deno APIs.
14
14
  *
@@ -46,17 +46,20 @@ export const create_deno_runtime = (args) => ({
46
46
  read_text_file: (path) => Deno.readTextFile(path),
47
47
  read_file: (path) => Deno.readFile(path),
48
48
  read_secure_file: async (path) => {
49
- // Deno exposes no O_NOFOLLOW, so the symlink check is a pre-open lstat
50
- // (a TOCTOU window the Node impl doesn't have — the mode check below
51
- // still runs on the open descriptor, so a post-open swap can't defeat
52
- // it). The checks themselves are the shared `runtime/secure_file.ts`
53
- // helpers, same as `load_secure_file_node`.
49
+ // Deno exposes no O_NOFOLLOW or O_NONBLOCK, so the symlink and
50
+ // regular-file checks are a pre-open lstat (a TOCTOU window the Node
51
+ // impl doesn't have — a FIFO swapped in after it would block the open;
52
+ // the checks below still run on the open descriptor, so a post-open swap
53
+ // can't defeat them). The checks themselves are the shared
54
+ // `runtime/secure_file.ts` helpers, same as `load_secure_file_node`.
54
55
  const l = await Deno.lstat(path);
55
56
  if (l.isSymlink)
56
57
  throw new Error(`secure file is a symlink: ${path}`);
58
+ assert_secure_regular_file(path, l.isFile);
57
59
  const file = await Deno.open(path, { read: true });
58
60
  try {
59
61
  const s = await file.stat();
62
+ assert_secure_regular_file(path, s.isFile);
60
63
  // `mode` is null where POSIX modes don't apply (Windows).
61
64
  if (s.mode !== null)
62
65
  assert_secure_mode(path, s.mode);
@@ -103,8 +103,9 @@ export interface WriteFileOptions {
103
103
  */
104
104
  export interface FsSecureReadDeps {
105
105
  /**
106
- * Read a small secret file with fail-loud checks: rejects symlinks, any
107
- * group/other-accessible mode (must be `0600`/`0400`; skipped where POSIX
106
+ * Read a small secret file with fail-loud checks: rejects symlinks,
107
+ * non-regular files (FIFO, device, directory — real runtimes only; the mock
108
+ * has no file types), any group/other-accessible mode (must be `0600`/`0400`; skipped where POSIX
108
109
  * modes don't apply), and files over a 4 KiB cap. Permission checks run on
109
110
  * the open descriptor where the platform allows, so the file can't be
110
111
  * swapped between check and read. Throws on any refusal — a refused secret
@@ -1 +1 @@
1
- {"version":3,"file":"deps.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deps.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"deps.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deps.ts"],"names":[],"mappings":"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"}
@@ -4,18 +4,20 @@
4
4
  *
5
5
  * The file this guards is typically the highest-value credential on the host
6
6
  * (the bootstrap token mints the keeper account), so the read fails loud
7
- * rather than degrading: a symlink, a group/other-accessible mode, or an
8
- * oversized file is refused, never returned. The deploy recipe places the
9
- * token at `0600`, so the check ratifies the shape zap produces; a
10
- * hand-placed `0644` file fails at boot instead of being read.
7
+ * rather than degrading: a symlink, a non-regular file (FIFO, device node,
8
+ * directory), a group/other-accessible mode, or an oversized file is refused,
9
+ * never returned. The deploy recipe places the token at `0600`, so the check
10
+ * ratifies the shape zap produces; a hand-placed `0644` file fails at boot
11
+ * instead of being read.
11
12
  *
12
13
  * The Node implementation lives here (`load_secure_file_node`, wired as
13
14
  * `create_node_runtime().read_secure_file`); the Deno runtime implements the
14
15
  * same contract over `Deno.open` in `runtime/deno.ts`, and the mock honors
15
- * the mode/size checks over its in-memory map. All three refuse through the
16
- * shared `assert_secure_mode` / `assert_secure_size` / `read_secure_bounded`
17
- * helpers so the checks — and their operator-facing messages — can't drift
18
- * between runtimes.
16
+ * the mode/size checks over its in-memory map (it has no file types). The real
17
+ * runtimes refuse through the shared `assert_secure_regular_file` /
18
+ * `assert_secure_mode` / `assert_secure_size` / `read_secure_bounded` helpers,
19
+ * and the mock through the mode and size ones, so the checks — and their
20
+ * operator-facing messages — can't drift between runtimes.
19
21
  *
20
22
  * @module
21
23
  */
@@ -25,6 +27,13 @@
25
27
  * `MAX_SECURE_FILE_SIZE`.
26
28
  */
27
29
  export declare const MAX_SECURE_FILE_SIZE = 4096;
30
+ /**
31
+ * Refuse anything but a regular file — a FIFO, device node, socket, or
32
+ * directory at the secret's path.
33
+ *
34
+ * @throws Error naming the path
35
+ */
36
+ export declare const assert_secure_regular_file: (path: string, is_file: boolean) => void;
28
37
  /**
29
38
  * Refuse any group/other-accessible mode (only `0600`/`0400` pass).
30
39
  *
@@ -57,6 +66,11 @@ export declare const read_secure_bounded: (path: string, read_chunk: (target: Ui
57
66
  * Read a secret file with fail-loud checks (Node implementation).
58
67
  *
59
68
  * - `O_NOFOLLOW` atomically rejects symlinks during open (no TOCTOU window)
69
+ * - `O_NONBLOCK` keeps the open from waiting on a FIFO that has no writer (it
70
+ * has no effect on a regular file); the regular-file check on the open
71
+ * descriptor then refuses it, a device node, or a directory
72
+ * - `O_NOCTTY` keeps a terminal device at the path from becoming the process's
73
+ * controlling terminal in the moment before that check refuses it
60
74
  * - the permission check runs on the open descriptor, not the path, so the
61
75
  * file can't be swapped between check and read — any group/other access
62
76
  * (not `0600`/`0400`) is refused
@@ -65,7 +79,7 @@ export declare const read_secure_bounded: (path: string, read_chunk: (target: Ui
65
79
  *
66
80
  * @param path - path to the secret file
67
81
  * @returns the file's bytes
68
- * @throws Error on a missing file, symlink, permissive mode, oversized file, or I/O failure
82
+ * @throws Error on a missing file, symlink, non-regular file, permissive mode, oversized file, or I/O failure
69
83
  */
70
84
  export declare const load_secure_file_node: (path: string) => Promise<Uint8Array>;
71
85
  //# sourceMappingURL=secure_file.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"secure_file.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/secure_file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAMH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAUzC;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,IAM/D,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,IAI/D,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAC/B,MAAM,MAAM,EACZ,YAAY,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,KACxD,OAAO,CAAC,UAAU,CAUpB,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,UAAU,CAuB5E,CAAC"}
1
+ {"version":3,"file":"secure_file.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/secure_file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAMH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAUzC;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,MAAM,MAAM,EAAE,SAAS,OAAO,KAAG,IAI3E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,IAM/D,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,IAI/D,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAC/B,MAAM,MAAM,EACZ,YAAY,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,KACxD,OAAO,CAAC,UAAU,CAUpB,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,qBAAqB,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,UAAU,CA2B5E,CAAC"}
@@ -4,18 +4,20 @@
4
4
  *
5
5
  * The file this guards is typically the highest-value credential on the host
6
6
  * (the bootstrap token mints the keeper account), so the read fails loud
7
- * rather than degrading: a symlink, a group/other-accessible mode, or an
8
- * oversized file is refused, never returned. The deploy recipe places the
9
- * token at `0600`, so the check ratifies the shape zap produces; a
10
- * hand-placed `0644` file fails at boot instead of being read.
7
+ * rather than degrading: a symlink, a non-regular file (FIFO, device node,
8
+ * directory), a group/other-accessible mode, or an oversized file is refused,
9
+ * never returned. The deploy recipe places the token at `0600`, so the check
10
+ * ratifies the shape zap produces; a hand-placed `0644` file fails at boot
11
+ * instead of being read.
11
12
  *
12
13
  * The Node implementation lives here (`load_secure_file_node`, wired as
13
14
  * `create_node_runtime().read_secure_file`); the Deno runtime implements the
14
15
  * same contract over `Deno.open` in `runtime/deno.ts`, and the mock honors
15
- * the mode/size checks over its in-memory map. All three refuse through the
16
- * shared `assert_secure_mode` / `assert_secure_size` / `read_secure_bounded`
17
- * helpers so the checks — and their operator-facing messages — can't drift
18
- * between runtimes.
16
+ * the mode/size checks over its in-memory map (it has no file types). The real
17
+ * runtimes refuse through the shared `assert_secure_regular_file` /
18
+ * `assert_secure_mode` / `assert_secure_size` / `read_secure_bounded` helpers,
19
+ * and the mock through the mode and size ones, so the checks — and their
20
+ * operator-facing messages — can't drift between runtimes.
19
21
  *
20
22
  * @module
21
23
  */
@@ -35,6 +37,17 @@ export const MAX_SECURE_FILE_SIZE = 4096;
35
37
  * The mock runtime checks unconditionally — its modes are simulated.
36
38
  */
37
39
  const modes_apply = process.platform !== 'win32';
40
+ /**
41
+ * Refuse anything but a regular file — a FIFO, device node, socket, or
42
+ * directory at the secret's path.
43
+ *
44
+ * @throws Error naming the path
45
+ */
46
+ export const assert_secure_regular_file = (path, is_file) => {
47
+ if (!is_file) {
48
+ throw new Error(`not a regular file: ${path}`);
49
+ }
50
+ };
38
51
  /**
39
52
  * Refuse any group/other-accessible mode (only `0600`/`0400` pass).
40
53
  *
@@ -86,6 +99,11 @@ export const read_secure_bounded = async (path, read_chunk) => {
86
99
  * Read a secret file with fail-loud checks (Node implementation).
87
100
  *
88
101
  * - `O_NOFOLLOW` atomically rejects symlinks during open (no TOCTOU window)
102
+ * - `O_NONBLOCK` keeps the open from waiting on a FIFO that has no writer (it
103
+ * has no effect on a regular file); the regular-file check on the open
104
+ * descriptor then refuses it, a device node, or a directory
105
+ * - `O_NOCTTY` keeps a terminal device at the path from becoming the process's
106
+ * controlling terminal in the moment before that check refuses it
89
107
  * - the permission check runs on the open descriptor, not the path, so the
90
108
  * file can't be swapped between check and read — any group/other access
91
109
  * (not `0600`/`0400`) is refused
@@ -94,12 +112,12 @@ export const read_secure_bounded = async (path, read_chunk) => {
94
112
  *
95
113
  * @param path - path to the secret file
96
114
  * @returns the file's bytes
97
- * @throws Error on a missing file, symlink, permissive mode, oversized file, or I/O failure
115
+ * @throws Error on a missing file, symlink, non-regular file, permissive mode, oversized file, or I/O failure
98
116
  */
99
117
  export const load_secure_file_node = async (path) => {
100
118
  let handle;
101
119
  try {
102
- handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
120
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY);
103
121
  }
104
122
  catch (err) {
105
123
  // ELOOP means the path was a symlink (O_NOFOLLOW refused it).
@@ -110,6 +128,7 @@ export const load_secure_file_node = async (path) => {
110
128
  }
111
129
  try {
112
130
  const s = await handle.stat();
131
+ assert_secure_regular_file(path, s.isFile());
113
132
  if (modes_apply)
114
133
  assert_secure_mode(path, s.mode);
115
134
  assert_secure_size(path, s.size);
@@ -13,6 +13,7 @@
13
13
  import { Logger } from '@fuzdev/fuz_util/log.ts';
14
14
  import type { AppDeps } from '../auth/deps.js';
15
15
  import { type AuditEmitter } from '../auth/audit_emitter.js';
16
+ import { type RealtimeCloser } from '../actions/connection_closer.js';
16
17
  import type { DbType, Db } from '../db/db.js';
17
18
  import type { Keyring } from '../auth/keyring.js';
18
19
  import type { PasswordHashDeps } from '../auth/password.js';
@@ -85,7 +86,7 @@ export interface CreateAppBackendOptions {
85
86
  /**
86
87
  * Hardened secret-file read for the bootstrap token — pass the runtime's
87
88
  * `read_secure_file` (see `FsSecureReadDeps`). Rejects symlinks,
88
- * group/other-accessible modes, and oversized files.
89
+ * non-regular files, group/other-accessible modes, and oversized files.
89
90
  */
90
91
  read_secure_file: (path: string) => Promise<Uint8Array>;
91
92
  /** Delete a file. */
@@ -118,6 +119,15 @@ export interface CreateAppBackendOptions {
118
119
  * `audit.add_listener(...)`.
119
120
  */
120
121
  audit_factory: AuditFactory;
122
+ /**
123
+ * The closer to put on `AppDeps.connection_closer`. Omit for a fresh one
124
+ * — the usual case, since `create_app_server` adds the transports it
125
+ * mounts. Pass one built up front (`create_realtime_closer()`) when a
126
+ * transport is constructed before the backend and has to be added to the
127
+ * same closer the handlers will hold — e.g. an audit stream from
128
+ * `create_audit_log_sse` whose listener `audit_factory` registers.
129
+ */
130
+ connection_closer?: RealtimeCloser;
121
131
  /**
122
132
  * Additional migration namespaces to run after the builtin auth namespace.
123
133
  * The shared `schema_version` table records one row per applied migration
@@ -136,7 +146,8 @@ export interface CreateAppBackendOptions {
136
146
  *
137
147
  * Calls `create_db` → `run_migrations` (auth namespace, then any
138
148
  * `migration_namespaces` from options in order) → `audit_factory({db, log})`
139
- * and bundles the result with the provided keyring and password deps.
149
+ * and bundles the result with the provided keyring and password deps and a
150
+ * `connection_closer` (empty until `create_app_server` adds its transports).
140
151
  *
141
152
  * @param options - keyring, password deps, `audit_factory`, optional database URL, and optional `migration_namespaces`
142
153
  * @returns app backend with deps, database metadata, and combined migration results
@@ -1 +1 @@
1
- {"version":3,"file":"app_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAkB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIjG;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,oIAAoI;IACpI,QAAQ,CAAC,iBAAiB,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC3D,iEAAiE;IACjE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE;IAAE,EAAE,EAAE,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,KAAK,YAAY,CAAC;AAE7E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,EAAE,YACF,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,OAAO,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,EAAE,YAAY,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,kBAAkB,GAAU,SAAS,uBAAuB,KAAG,OAAO,CAAC,UAAU,CAiD7F,CAAC"}
1
+ {"version":3,"file":"app_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAA0B,KAAK,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAC9F,OAAO,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAkB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIjG;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,oIAAoI;IACpI,QAAQ,CAAC,iBAAiB,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC3D,iEAAiE;IACjE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE;IAAE,EAAE,EAAE,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,KAAK,YAAY,CAAC;AAE7E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,EAAE,YACF,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,OAAO,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,EAAE,YAAY,CAAC;IAC5B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,cAAc,CAAC;IACnC;;;;;;;;;;OAUG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,GAAU,SAAS,uBAAuB,KAAG,OAAO,CAAC,UAAU,CAkD7F,CAAC"}
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { Logger } from '@fuzdev/fuz_util/log.ts';
14
14
  import { create_audit_emitter } from '../auth/audit_emitter.js';
15
+ import { create_realtime_closer } from '../actions/connection_closer.js';
15
16
  import { run_migrations } from '../db/migrate.js';
16
17
  import { auth_migration_ns, reserved_migration_namespaces } from '../auth/migrations.js';
17
18
  import { create_db } from '../db/create_db.js';
@@ -32,7 +33,8 @@ export const default_audit_factory = ({ db, log }) => create_audit_emitter({ db,
32
33
  *
33
34
  * Calls `create_db` → `run_migrations` (auth namespace, then any
34
35
  * `migration_namespaces` from options in order) → `audit_factory({db, log})`
35
- * and bundles the result with the provided keyring and password deps.
36
+ * and bundles the result with the provided keyring and password deps and a
37
+ * `connection_closer` (empty until `create_app_server` adds its transports).
36
38
  *
37
39
  * @param options - keyring, password deps, `audit_factory`, optional database URL, and optional `migration_namespaces`
38
40
  * @returns app backend with deps, database metadata, and combined migration results
@@ -72,7 +74,8 @@ export const create_app_backend = async (options) => {
72
74
  read_secure_file,
73
75
  delete_file,
74
76
  log,
75
- audit
77
+ audit,
78
+ connection_closer: options.connection_closer ?? create_realtime_closer()
76
79
  }
77
80
  };
78
81
  }