@fuzdev/fuz_app 0.118.0 → 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 (155) 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 +113 -32
  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 +61 -38
  44. package/dist/auth/audit_emitter.d.ts.map +1 -1
  45. package/dist/auth/audit_emitter.js +57 -14
  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/cleanup.d.ts +203 -28
  54. package/dist/auth/cleanup.d.ts.map +1 -1
  55. package/dist/auth/cleanup.js +222 -26
  56. package/dist/auth/deps.d.ts +28 -0
  57. package/dist/auth/deps.d.ts.map +1 -1
  58. package/dist/auth/resolved_auth.d.ts +105 -0
  59. package/dist/auth/resolved_auth.d.ts.map +1 -0
  60. package/dist/auth/resolved_auth.js +124 -0
  61. package/dist/auth/session_middleware.d.ts +8 -1
  62. package/dist/auth/session_middleware.d.ts.map +1 -1
  63. package/dist/auth/session_middleware.js +9 -3
  64. package/dist/auth/session_queries.d.ts +31 -6
  65. package/dist/auth/session_queries.d.ts.map +1 -1
  66. package/dist/auth/session_queries.js +31 -6
  67. package/dist/auth/standard_rpc_actions.d.ts +9 -9
  68. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  69. package/dist/auth/standard_rpc_actions.js +3 -3
  70. package/dist/db/CLAUDE.md +7 -1
  71. package/dist/db/status.d.ts +58 -1
  72. package/dist/db/status.d.ts.map +1 -1
  73. package/dist/db/status.js +205 -77
  74. package/dist/http/CLAUDE.md +7 -3
  75. package/dist/http/db_routes.d.ts +6 -2
  76. package/dist/http/db_routes.d.ts.map +1 -1
  77. package/dist/http/db_routes.js +2 -2
  78. package/dist/periodic.d.ts +80 -0
  79. package/dist/periodic.d.ts.map +1 -0
  80. package/dist/periodic.js +122 -0
  81. package/dist/realtime/sse_auth_guard.d.ts +79 -49
  82. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  83. package/dist/realtime/sse_auth_guard.js +103 -73
  84. package/dist/realtime/subscriber_registry.d.ts +119 -8
  85. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  86. package/dist/realtime/subscriber_registry.js +184 -25
  87. package/dist/server/app_backend.d.ts +12 -1
  88. package/dist/server/app_backend.d.ts.map +1 -1
  89. package/dist/server/app_backend.js +5 -2
  90. package/dist/server/app_server.d.ts +55 -15
  91. package/dist/server/app_server.d.ts.map +1 -1
  92. package/dist/server/app_server.js +41 -13
  93. package/dist/testing/CLAUDE.md +140 -48
  94. package/dist/testing/app_server.d.ts +2 -2
  95. package/dist/testing/app_server.d.ts.map +1 -1
  96. package/dist/testing/app_server.js +11 -2
  97. package/dist/testing/audit_drift_guard.d.ts +1 -48
  98. package/dist/testing/audit_drift_guard.d.ts.map +1 -1
  99. package/dist/testing/audit_drift_guard.js +0 -42
  100. package/dist/testing/connection_closer_helpers.d.ts +9 -22
  101. package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
  102. package/dist/testing/connection_closer_helpers.js +11 -17
  103. package/dist/testing/cross_backend/capabilities.d.ts +67 -13
  104. package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
  105. package/dist/testing/cross_backend/capabilities.js +27 -12
  106. package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
  107. package/dist/testing/cross_backend/default_backend_configs.js +21 -0
  108. package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
  109. package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
  110. package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
  111. package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
  112. package/dist/testing/cross_backend/session_cap.d.ts +13 -0
  113. package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
  114. package/dist/testing/cross_backend/session_cap.js +55 -19
  115. package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
  116. package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
  117. package/dist/testing/cross_backend/sse_round_trip.js +93 -26
  118. package/dist/testing/cross_backend/testing_server_bun.js +4 -4
  119. package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
  120. package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
  121. package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
  122. package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
  123. package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
  124. package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
  125. package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
  126. package/dist/testing/cross_backend/ws_round_trip.js +156 -19
  127. package/dist/testing/sse_round_trip.d.ts +6 -4
  128. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  129. package/dist/testing/stubs.d.ts +1 -1
  130. package/dist/testing/stubs.d.ts.map +1 -1
  131. package/dist/testing/stubs.js +6 -4
  132. package/dist/testing/transports/sse_transport.d.ts +1 -1
  133. package/dist/testing/transports/ws_client.d.ts +7 -0
  134. package/dist/testing/transports/ws_client.d.ts.map +1 -1
  135. package/dist/testing/transports/ws_raw_client.d.ts +51 -0
  136. package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
  137. package/dist/testing/transports/ws_raw_client.js +156 -0
  138. package/dist/testing/transports/ws_transport.d.ts +28 -0
  139. package/dist/testing/transports/ws_transport.d.ts.map +1 -1
  140. package/dist/testing/transports/ws_transport.js +45 -1
  141. package/dist/testing/ws_round_trip.d.ts +8 -3
  142. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  143. package/dist/testing/ws_round_trip.js +19 -4
  144. package/dist/ui/AdminAccounts.svelte +4 -3
  145. package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
  146. package/dist/ui/AdminOverview.svelte +3 -1
  147. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  148. package/dist/ui/CLAUDE.md +22 -6
  149. package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
  150. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  151. package/dist/ui/admin_accounts_state.svelte.js +16 -0
  152. package/dist/ui/auth_state.svelte.d.ts +25 -1
  153. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  154. package/dist/ui/auth_state.svelte.js +28 -1
  155. package/package.json +1 -1
@@ -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
  }
@@ -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';
@@ -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
  }
@@ -26,7 +26,8 @@ import { type RouteSpec } from '../http/route_spec.js';
26
26
  import type { MiddlewareSpec } from '../http/middleware_spec.js';
27
27
  import { type BootstrapStatus } from '../auth/bootstrap_routes.js';
28
28
  import type { WsEndpointSpec } from '../actions/ws_endpoint_spec.js';
29
- import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
29
+ import type { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
30
+ import { type AuthCleanupScheduleOptions } from '../auth/cleanup.js';
30
31
  /**
31
32
  * Context passed to `on_effect_error` when a pending effect rejects.
32
33
  */
@@ -185,12 +186,13 @@ export interface AppServerOptions {
185
186
  *
186
187
  * When truthy, creates an `AuditLogSse` instance internally, registers the SSE
187
188
  * listener via `backend.deps.audit.add_listener` (composing with the
188
- * consumer's `on_audit_event` callback rather than rebuilding `AppDeps`), and
189
- * auto-includes `audit_log_event_specs` in the surface. The result is exposed
190
- * on `AppServerContext` (for route factories) and `AppServer` (for the caller),
191
- * always typed as `AuditLogSse | null` — when this option is set, the field
192
- * is non-null. Use `require_audit_sse(ctx)` to assert the invariant in
193
- * route factories that depend on it.
189
+ * consumer's `on_audit_event` callback rather than rebuilding `AppDeps`),
190
+ * adds its registry to `backend.deps.connection_closer` so revocations close
191
+ * its streams, and auto-includes `audit_log_event_specs` in the surface. The
192
+ * result is exposed on `AppServerContext` (for route factories) and
193
+ * `AppServer` (for the caller), always typed as `AuditLogSse | null` — when
194
+ * this option is set, the field is non-null. Use `require_audit_sse(ctx)` to
195
+ * assert the invariant in route factories that depend on it.
194
196
  *
195
197
  * Pass `true` for defaults (admin role), or `{role: 'custom'}` for a custom role.
196
198
  * Omit to wire audit SSE manually.
@@ -198,6 +200,28 @@ export interface AppServerOptions {
198
200
  audit_log_sse?: true | {
199
201
  role?: string;
200
202
  };
203
+ /**
204
+ * Schedule the auth cleanup beside the server: `start_auth_cleanup` over
205
+ * `backend.deps` — a pass as assembly finishes, then one per interval —
206
+ * stopped by `AppServer.close`. Each pass deletes expired sessions and
207
+ * closes the connections they opened through `deps.connection_closer`
208
+ * (every WebSocket transport and audit stream this server mounts), and
209
+ * audits each newly expired role_grant offer once.
210
+ *
211
+ * **Off by default; a production server should turn it on.** Without it
212
+ * nothing sweeps: expired session rows accumulate, a WebSocket or audit
213
+ * stream outlives the expiry of the session it opened on until it
214
+ * disconnects, and an offer's expiry is never audited. With it, a
215
+ * connection outlives its session's expiry by at most one interval. The
216
+ * Rust spine's servers all schedule the twin (`spawn_auth_cleanup`).
217
+ *
218
+ * Pass `true` for the default interval
219
+ * (`DEFAULT_AUTH_CLEANUP_INTERVAL_MS`), or `{interval_ms}` for another.
220
+ * Leave it off in an in-process test harness — a background pass deletes
221
+ * rows and writes audit rows mid-test — and in a process that runs
222
+ * `run_auth_cleanup` on a schedule of its own.
223
+ */
224
+ auth_cleanup?: boolean | AuthCleanupScheduleOptions;
201
225
  /** SSE event specs for surface generation. Defaults to `[]` (no SSE events). */
202
226
  event_specs?: Array<EventSpec>;
203
227
  /**
@@ -245,13 +269,17 @@ export interface AppServerOptions {
245
269
  * Duplicate `path` values across two `WsEndpointSpec`s throw at
246
270
  * mount time (Hono would silently shadow them otherwise).
247
271
  *
272
+ * Every mounted transport is added to `deps.connection_closer`, so
273
+ * the revocation handlers' post-commit closes reach its sockets —
274
+ * unconditionally, since those closes are what ends a revoked
275
+ * credential's connections.
276
+ *
248
277
  * Each spec's `auth_guard?` defaults to `true` — the factory
249
- * composes `create_ws_auth_guard` + `create_ws_logout_closer`
250
- * against the mounted transport and registers them via
251
- * `deps.audit.add_listener`. Wiring is deduped by transport
252
- * **reference identity** so two specs sharing one
253
- * `BackendWebsocketTransport` instance get a single pair of
254
- * listeners; wrapped / proxied transports dedupe as separate
278
+ * registers `create_ws_auth_guard` against the mounted transport via
279
+ * `deps.audit.add_listener`, repeating the close on the audit row.
280
+ * Wiring is deduped by transport **reference identity** so two specs
281
+ * sharing one `BackendWebsocketTransport` instance get a single
282
+ * listener; wrapped / proxied transports dedupe as separate
255
283
  * entries (set `auth_guard: false` on duplicates and compose
256
284
  * against the underlying transport once).
257
285
  */
@@ -320,7 +348,12 @@ export interface AppServer {
320
348
  * Empty when no `ws_endpoints` were mounted.
321
349
  */
322
350
  ws_endpoints: Readonly<Record<string, BackendWebsocketTransport>>;
323
- /** Close the database connection. Propagated from `AppBackend`. */
351
+ /**
352
+ * Shut the server's resources down: stop the auth cleanup schedule when
353
+ * the `auth_cleanup` option started one — waiting for a pass in progress —
354
+ * then close the database connection (`AppBackend.close`). That wait has
355
+ * no bound of its own; a shutdown with a deadline races it.
356
+ */
324
357
  close: () => Promise<void>;
325
358
  }
326
359
  /**
@@ -354,12 +387,19 @@ export declare const DEFAULT_MAX_BODY_SIZE: number;
354
387
  * pass `migration_namespaces` to `create_app_backend`.
355
388
  *
356
389
  * When `audit_log_sse` is set, the SSE registry's listener is registered via
357
- * `backend.deps.audit.add_listener` — no shallow-copy of `AppDeps`. The
390
+ * `backend.deps.audit.add_listener` — no shallow-copy of `AppDeps`. That
391
+ * registry and every WebSocket transport mounted from `ws_endpoints` are added
392
+ * to `backend.deps.connection_closer`, so the revocation handlers built from
393
+ * `ctx.deps` close their connections with no further wiring. The
358
394
  * `audit_sse` field on the returned `AppServer` (and the
359
395
  * `AppServerContext` passed to `create_route_specs`) is non-null in that
360
396
  * case; consumers can call `require_audit_sse(ctx)` / `require_audit_sse(server)`
361
397
  * to assert the invariant.
362
398
  *
399
+ * When `auth_cleanup` is set, the auth cleanup schedule starts as the last
400
+ * step of assembly — so an assembly that throws leaves no schedule running —
401
+ * and the returned `close` stops it before closing the database.
402
+ *
363
403
  * @returns assembled Hono app, backend, surface build, and bootstrap status
364
404
  */
365
405
  export declare const create_app_server: (options: AppServerOptions) => Promise<AppServer>;
@@ -1 +1 @@
1
- {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server.ts"],"names":[],"mappings":"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"}
@@ -32,8 +32,8 @@ import { create_fuz_authorization_handler } from '../auth/request_context.js';
32
32
  import { ERROR_PAYLOAD_TOO_LARGE } from '../http/error_schemas.js';
33
33
  import { create_rpc_endpoint } from '../actions/action_rpc.js';
34
34
  import { register_ws_endpoint } from '../actions/register_ws_endpoint.js';
35
- import { create_ws_auth_guard, create_ws_logout_closer } from '../actions/transports_ws_auth_guard.js';
36
- import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
35
+ import { create_ws_auth_guard } from '../actions/transports_ws_auth_guard.js';
36
+ import { start_auth_cleanup } from '../auth/cleanup.js';
37
37
  /**
38
38
  * Assert that `audit_sse` was wired by `create_app_server` and return it
39
39
  * as a non-null `AuditLogSse`. Throws a labelled error when the
@@ -68,12 +68,19 @@ export const DEFAULT_MAX_BODY_SIZE = 1024 * 1024;
68
68
  * pass `migration_namespaces` to `create_app_backend`.
69
69
  *
70
70
  * When `audit_log_sse` is set, the SSE registry's listener is registered via
71
- * `backend.deps.audit.add_listener` — no shallow-copy of `AppDeps`. The
71
+ * `backend.deps.audit.add_listener` — no shallow-copy of `AppDeps`. That
72
+ * registry and every WebSocket transport mounted from `ws_endpoints` are added
73
+ * to `backend.deps.connection_closer`, so the revocation handlers built from
74
+ * `ctx.deps` close their connections with no further wiring. The
72
75
  * `audit_sse` field on the returned `AppServer` (and the
73
76
  * `AppServerContext` passed to `create_route_specs`) is non-null in that
74
77
  * case; consumers can call `require_audit_sse(ctx)` / `require_audit_sse(server)`
75
78
  * to assert the invariant.
76
79
  *
80
+ * When `auth_cleanup` is set, the auth cleanup schedule starts as the last
81
+ * step of assembly — so an assembly that throws leaves no schedule running —
82
+ * and the returned `close` stops it before closing the database.
83
+ *
77
84
  * @returns assembled Hono app, backend, surface build, and bootstrap status
78
85
  */
79
86
  export const create_app_server = async (options) => {
@@ -106,11 +113,13 @@ export const create_app_server = async (options) => {
106
113
  : options.action_account_rate_limiter;
107
114
  // Factory-managed audit SSE — registers a listener on the bound emitter
108
115
  // so SSE fan-out runs alongside the consumer's `on_audit_event`
109
- // without rebuilding `AppDeps`.
116
+ // without rebuilding `AppDeps`, and adds the registry to the backend's
117
+ // closer so revocations close its streams.
110
118
  const audit_sse = options.audit_log_sse
111
119
  ? create_audit_log_sse({
112
120
  log,
113
- role: typeof options.audit_log_sse === 'object' ? options.audit_log_sse.role : undefined
121
+ role: typeof options.audit_log_sse === 'object' ? options.audit_log_sse.role : undefined,
122
+ connection_closer: deps.connection_closer
114
123
  })
115
124
  : null;
116
125
  if (audit_sse) {
@@ -281,7 +290,10 @@ export const create_app_server = async (options) => {
281
290
  // committed transaction). Both queues drain here, after the handler
282
291
  // (and any wrapping `db.transaction`) returns. In test mode both are
283
292
  // awaited before the response returns; in production, eager-queue
284
- // rejections are reported via `on_effect_error`.
293
+ // rejections are reported via `on_effect_error`. Either way the
294
+ // deferred thunks are invoked without waiting for the eager writes to
295
+ // settle — a revocation's connection close must not sit behind a slow
296
+ // audit INSERT.
285
297
  app.use('*', async (c, next) => {
286
298
  c.set('pending_effects', []);
287
299
  c.set('post_commit_effects', []);
@@ -293,8 +305,11 @@ export const create_app_server = async (options) => {
293
305
  const deferred = c.var.post_commit_effects;
294
306
  if (eager.length || deferred.length) {
295
307
  if (options.await_pending_effects) {
308
+ // run the deferred thunks before awaiting the eager writes,
309
+ // so neither mode holds them behind a write
310
+ const deferred_flush = flush_post_commit_effects(deferred, log);
296
311
  await flush_pending_effects(eager, log);
297
- await flush_post_commit_effects(deferred, log);
312
+ await deferred_flush;
298
313
  }
299
314
  else {
300
315
  const error_ctx = { method: c.req.method, path: c.req.path };
@@ -348,7 +363,7 @@ export const create_app_server = async (options) => {
348
363
  }
349
364
  const seen_paths = new Set();
350
365
  // Dedupe `auth_guard` wiring by transport reference — two specs
351
- // sharing one transport instance get a single pair of listeners,
366
+ // sharing one transport instance get a single listener,
352
367
  // otherwise revocation events would fire `close_sockets_for_*`
353
368
  // twice per event (idempotent on the transport but log-spammy).
354
369
  // Cross-spec OR-semantics: any spec with `auth_guard !== false`
@@ -364,15 +379,17 @@ export const create_app_server = async (options) => {
364
379
  throw new Error(`create_app_server: ws_endpoints path collides with a GET RouteSpec: ${endpoint.path}`);
365
380
  }
366
381
  seen_paths.add(endpoint.path);
367
- const endpoint_transport = endpoint.transport ?? new BackendWebsocketTransport();
368
- register_ws_endpoint({
382
+ const { transport: endpoint_transport } = register_ws_endpoint({
369
383
  app,
370
384
  path: endpoint.path,
371
385
  upgradeWebSocket: options.upgradeWebSocket,
372
386
  allowed_origins: endpoint.allowed_origins,
373
387
  db: deps.db,
374
388
  actions: endpoint.actions,
375
- transport: endpoint_transport,
389
+ transport: endpoint.transport,
390
+ // every revocation handler's close reaches this transport
391
+ connection_closer: deps.connection_closer,
392
+ max_connections_per_account: endpoint.max_connections_per_account,
376
393
  heartbeat: endpoint.heartbeat,
377
394
  artificial_delay: endpoint.artificial_delay,
378
395
  max_message_bytes: endpoint.max_message_bytes,
@@ -387,7 +404,6 @@ export const create_app_server = async (options) => {
387
404
  if (endpoint.auth_guard !== false && !guarded_transports.has(endpoint_transport)) {
388
405
  guarded_transports.add(endpoint_transport);
389
406
  deps.audit.add_listener(create_ws_auth_guard(endpoint_transport, log));
390
- deps.audit.add_listener(create_ws_logout_closer(endpoint_transport, log));
391
407
  }
392
408
  if (endpoint.extra_audit_handlers?.length) {
393
409
  for (const handler of endpoint.extra_audit_handlers) {
@@ -407,6 +423,12 @@ export const create_app_server = async (options) => {
407
423
  app.use('/*', mw);
408
424
  }
409
425
  }
426
+ // Auth cleanup — opt-in, and last: nothing after it can throw, so a failed
427
+ // assembly never leaves a schedule running. Its closes go through
428
+ // `deps.connection_closer`, which the mounts above have filled.
429
+ const auth_cleanup = options.auth_cleanup
430
+ ? start_auth_cleanup(deps, options.auth_cleanup === true ? undefined : options.auth_cleanup)
431
+ : null;
410
432
  return {
411
433
  app,
412
434
  surface_spec,
@@ -414,6 +436,12 @@ export const create_app_server = async (options) => {
414
436
  migration_results: backend.migration_results,
415
437
  audit_sse,
416
438
  ws_endpoints: mounted_ws_endpoints,
417
- close: backend.close
439
+ close: auth_cleanup
440
+ ? async () => {
441
+ // the schedule first, so a pass in progress finishes on a live pool
442
+ await auth_cleanup.stop();
443
+ await backend.close();
444
+ }
445
+ : backend.close
418
446
  };
419
447
  };