@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
@@ -1,71 +1,260 @@
1
1
  /**
2
2
  * Periodic auth cleanup — sweeps expired sessions and role_grant offers.
3
3
  *
4
- * Single entry point for consumers scheduling auth maintenance. Internally
5
- * runs every known sweep and emits the corresponding audit events so
6
- * consumer code only manages cadence, not per-task wiring.
4
+ * `run_auth_cleanup` is one pass: it deletes expired sessions and closes the
5
+ * connections they opened (`cleanup_expired_sessions`), then audits newly
6
+ * expired offers (`cleanup_expired_role_grant_offers`), returning both counts.
7
+ * `start_auth_cleanup` schedules it — the one call a server makes, and what
8
+ * `create_app_server`'s `auth_cleanup` option makes for it. The per-pass
9
+ * pieces stay exported for a consumer that wants its own cadence or error
10
+ * isolation per sweep. Twin of the Rust spine's `fuz_auth::auth_cleanup`.
7
11
  *
8
- * The per-task primitives remain exported from their home modules
9
- * (`query_session_cleanup_expired`, `query_role_grant_offer_sweep_expired`);
10
- * `cleanup_expired_role_grant_offers` here wraps the latter with the required
11
- * `role_grant_offer_expire` audit emission and is the piece most likely to be
12
- * reused in a consumer's bespoke scheduler.
12
+ * ## Expired sessions: the sweep closes what it sweeps
13
13
  *
14
- * Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
15
- * concurrent sweep runs double-audit. The expected deployment pattern is a
16
- * single scheduled invocation per instance — matching
17
- * `query_session_cleanup_expired`.
14
+ * A session past `expires_at` fails every validity check, but the connections
15
+ * it opened do not read it again: a WebSocket re-authorizes the actor per
16
+ * message, never the session, and an SSE stream is authorized once at open.
17
+ * While the expired row lingers, `account_session_revoke` is the per-session
18
+ * handle on those connections — it closes for the row it deletes. The sweep
19
+ * deletes that row, so it takes over the close: every session it deletes has
20
+ * its connections closed through `AuthCleanupDeps.connection_closer`, the same
21
+ * close a revocation makes (`WS_CLOSE_SESSION_REVOKED` on a WebSocket; an SSE
22
+ * stream ends). Otherwise a swept session's connections would be reachable
23
+ * only by an account-wide revocation.
24
+ *
25
+ * The contract that follows: **a connection outlives its session's expiry by
26
+ * at most one cleanup interval** (plus the pass itself). In a process that
27
+ * does not schedule the sweep, expiry ends no connection at all. The contract
28
+ * is for sessions only: an expired API token's row is not swept, so nothing
29
+ * here closes a connection opened on one — and `account_token_revoke` still
30
+ * finds the row to close for.
31
+ *
32
+ * The closes run after the delete's commit, which is the rule every
33
+ * revocation close keeps (see `actions/connection_closer.ts`). An expired
34
+ * session is refused with or without its row, so the order is not what
35
+ * protects a client rechecking on the close or a connection still opening; it
36
+ * keeps the sweep inside the one invariant admission leans on — a close that
37
+ * missed a registration belongs to a write the re-read can see — rather than
38
+ * on the re-read's clock. A commit whose outcome is unknown still closes: the
39
+ * sessions are expired either way.
40
+ *
41
+ * That is why the delete runs in its own transaction rather than as one
42
+ * pool-level statement. The ids are in hand before the commit is sent, so a
43
+ * connection lost on the delete's response rolls the delete back for the next
44
+ * pass, and one lost on the commit's still closes — no failure leaves rows
45
+ * deleted with their ids unread. A commit that fails outright has closed too;
46
+ * the rows it left are deleted by the next pass, whose closes then find
47
+ * nothing.
48
+ *
49
+ * One pass closes for every session it swept; a session with no live
50
+ * connection costs a lookup and closes nothing. The pass logs one count, not
51
+ * a line per session.
52
+ *
53
+ * No audit row is written for a swept session: expiry is not an event anyone
54
+ * performed, and the session was already refused everywhere. So the audit
55
+ * listeners never see it, and the direct close is the only one.
56
+ *
57
+ * One limit: the closes reach this process's transports, as every revocation
58
+ * close does — a second process on the same database keeps the connections it
59
+ * holds on a session this one swept.
60
+ *
61
+ * ## Offer idempotency
62
+ *
63
+ * An expired pending offer has no terminal state — expiry is computed from
64
+ * `expires_at`, never written — so the offer sweep claims rows instead: it
65
+ * stamps `role_grant_offer.expire_audited_at` in the same transaction as each
66
+ * row's `role_grant_offer_expire` audit row, so every expiry is audited
67
+ * exactly once — a second run finds nothing, and concurrent runs claim
68
+ * disjoint rows (row locks serialize them on the re-checked stamp). A failed
69
+ * audit insert rolls the stamps back with it and the next run retries. A
70
+ * re-offer clears the stamp, so the refreshed offer's own expiry is audited
71
+ * too.
72
+ *
73
+ * ## Scheduling
74
+ *
75
+ * `start_auth_cleanup` runs the pass beside the server: a pass at once, then
76
+ * one per interval (`DEFAULT_AUTH_CLEANUP_INTERVAL_MS` unless the consumer has
77
+ * a reason to differ), until its `stop` is called. A failed pass — the
78
+ * database out of reach — is logged and retried on the next interval.
79
+ *
80
+ * Start it once migrations have run (the startup pass reads the auth tables),
81
+ * with the app's bound `AuditEmitter` so the expiry audits reach its listeners
82
+ * and the closer its revocation handlers close through. `AppDeps` is all of
83
+ * that, so the standard assembly is one option:
84
+ *
85
+ * ```ts
86
+ * const server = await create_app_server({backend, auth_cleanup: true, ...});
87
+ * // on shutdown: stops the schedule, then closes the database
88
+ * await server.close();
89
+ * ```
90
+ *
91
+ * and a hand-assembled server calls it directly:
92
+ *
93
+ * ```ts
94
+ * const auth_cleanup = start_auth_cleanup(backend.deps);
95
+ * // on shutdown, before the database closes
96
+ * await auth_cleanup.stop();
97
+ * ```
98
+ *
99
+ * The loop is `start_periodic` (`periodic.ts`), which keeps the Rust spine's
100
+ * schedule (`fuz_sys::periodic`): passes never overlap, and a pass that
101
+ * overruns its interval delays the next one instead of bursting to catch up.
102
+ * It differs on shutdown. The Rust loop drops a pass in progress when its
103
+ * token is cancelled; a promise cannot be dropped, so `stop` ends the schedule
104
+ * at once and its promise resolves when the pass in progress — if there is
105
+ * one — has finished. Awaiting it before closing the database lets that pass
106
+ * end on a live pool rather than fail on a closed one.
18
107
  *
19
108
  * @module
20
109
  */
21
110
  import type { Logger } from '@fuzdev/fuz_util/log.ts';
22
111
  import type { QueryDeps } from '../db/query_deps.js';
112
+ import { type PeriodicTask } from '../periodic.js';
113
+ import type { ConnectionCloser } from '../actions/connection_closer.js';
23
114
  import type { AuditEmitter } from './audit_emitter.js';
24
- /** Dependencies for the cleanup helpers. */
115
+ /**
116
+ * How often `start_auth_cleanup` runs a pass after its startup pass
117
+ * (10 minutes), unless the consumer passes its own. Equal to the Rust spine's
118
+ * `DEFAULT_AUTH_CLEANUP_INTERVAL`.
119
+ *
120
+ * An expired session or offer is already refused by every check that reads
121
+ * it, so the interval bounds three things: how long a connection outlives
122
+ * the expiry of the session it opened on, how late an offer expiry's audit
123
+ * row lands, and how long dead rows linger.
124
+ */
125
+ export declare const DEFAULT_AUTH_CLEANUP_INTERVAL_MS: number;
126
+ /**
127
+ * Dependencies for the cleanup helpers. `AppDeps` satisfies it structurally,
128
+ * so a server passes `backend.deps`.
129
+ */
25
130
  export interface AuthCleanupDeps extends QueryDeps {
26
131
  log: Logger;
27
132
  /**
28
- * Bound audit emitter. `cleanup_expired_role_grant_offers` writes via
29
- * `audit.emit_pool` (the captured pool + config + listener chain), so
30
- * one slot covers both row persistence and SSE/WS fan-out. Required —
31
- * production wiring always has a bound emitter on `AppDeps.audit`, and
32
- * tests that need a no-op pass `create_test_audit_emitter()`.
133
+ * Bound audit emitter. `cleanup_expired_role_grant_offers` writes its audit
134
+ * rows in its own transaction and fans each one out through `audit.notify`
135
+ * after the commit, so listeners never see an event that rolled back.
136
+ * Required — production wiring always has a bound emitter on
137
+ * `AppDeps.audit`, and tests that need a no-op pass
138
+ * `create_test_audit_emitter()`.
33
139
  */
34
140
  audit: AuditEmitter;
141
+ /**
142
+ * Closes the connections of each session the sweep deletes. Pass the
143
+ * closer the app's revocation handlers hold — `AppDeps.connection_closer`,
144
+ * the `RealtimeCloser` over every transport it serves. Required: a sweep
145
+ * with nothing to close through would strand the swept sessions'
146
+ * connections. `noop_connection_closer` is for a database no other
147
+ * process holds connections on — a sweep run beside a live server from
148
+ * another process deletes the rows that server's own pass would have
149
+ * closed for.
150
+ */
151
+ connection_closer: ConnectionCloser;
35
152
  }
36
- /** Result of `run_auth_cleanup`. */
153
+ /** Counts from one `run_auth_cleanup` pass. The Rust `AuthCleanupResult` is the same shape. */
37
154
  export interface AuthCleanupResult {
38
- /** Number of expired session rows deleted. */
155
+ /** Number of expired session rows deleted — each one's connections closed. */
39
156
  expired_sessions: number;
40
157
  /** Number of expired role_grant offer rows audit-stamped. */
41
158
  expired_offers: number;
42
159
  }
160
+ /**
161
+ * Delete every expired session and close the connections each one opened,
162
+ * returning the count of sessions deleted.
163
+ *
164
+ * The delete runs in its own transaction and the closes follow its commit,
165
+ * so on a successful pass every close is for a row already gone (see the
166
+ * module doc's § Expired sessions). Each swept session is closed by its id —
167
+ * the session-token hash the transports key on — so another session of the
168
+ * same account keeps its connections. A pass that closed something logs the
169
+ * counts at `info`. No audit row is written.
170
+ *
171
+ * A closer that throws for one session does not spare the rest: every swept
172
+ * session is attempted and each failure is logged, as the post-commit queue
173
+ * does for a revocation's close.
174
+ *
175
+ * Twin of the Rust `cleanup_expired_sessions`.
176
+ *
177
+ * @returns how many expired sessions were deleted
178
+ * @mutates `auth_session` table - deletes every row past `expires_at`
179
+ * @throws Error from opening the transaction, the delete, or the commit. The first two close nothing and delete nothing. A failed commit is thrown **after** the closes: the sessions the delete returned are expired whether or not the commit landed, so their connections are closed either way, and the next pass deletes any row the commit left.
180
+ */
181
+ export declare const cleanup_expired_sessions: (deps: AuthCleanupDeps) => Promise<number>;
43
182
  /**
44
183
  * Sweep expired role_grant offers and emit one `role_grant_offer_expire` audit
45
- * event per row.
184
+ * event per newly expired row.
46
185
  *
47
- * Returns the count of offers audit-stamped. The offer rows themselves are
186
+ * One transaction: `query_role_grant_offer_sweep_expired` claims the expired,
187
+ * not-yet-audited offers (stamping `expire_audited_at`) and each claimed row's
188
+ * audit row is inserted beside it. After the commit, each event fans out
189
+ * through `deps.audit.notify` — per-listener throws are isolated there, so one
190
+ * bad listener never starves the rest. Twin of the Rust
191
+ * `cleanup_expired_role_grant_offers`.
192
+ *
193
+ * Returns the count of offers audited. The offer rows themselves are
48
194
  * preserved — offers carry audit value for the history view even after
49
195
  * expiry, and accepted rows are the provenance for the resulting role_grant
50
196
  * (deleting expired rows would not threaten that, but keeping them uniform
51
197
  * with the retention policy for terminal rows is simpler).
52
198
  *
199
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on each swept offer
53
200
  * @mutates `audit_log` table - inserts one `role_grant_offer_expire` row per swept offer
201
+ * @throws Error if the claim or an audit insert fails — the transaction rolls back, so no offer is stamped and the next run retries
54
202
  */
55
203
  export declare const cleanup_expired_role_grant_offers: (deps: AuthCleanupDeps) => Promise<number>;
56
204
  /**
57
- * Run every auth cleanup sweep — expired sessions and expired role_grant
58
- * offers — and return the counts.
205
+ * Run every auth cleanup sweep — expired sessions, then expired role_grant
206
+ * offers — and return both counts. Twin of the Rust `run_auth_cleanup`.
207
+ *
208
+ * The session sweep (`cleanup_expired_sessions`) commits its delete and
209
+ * closes the swept sessions' connections before the offer sweep starts, so a
210
+ * failed offer sweep takes neither back; a session sweep whose commit failed
211
+ * has closed too, and the offer sweep does not run.
59
212
  *
60
- * Consumers call this from a scheduled task (setInterval, cron, etc.)
61
- * alongside their own domain cleanup. Errors from individual sweeps are
62
- * re-thrown so the caller's scheduler can log/alert; use the per-task
63
- * helpers (`query_session_cleanup_expired`, `cleanup_expired_role_grant_offers`)
64
- * directly if you need finer error isolation.
213
+ * `start_auth_cleanup` is the scheduler over this; call it directly for a
214
+ * one-off pass or from a scheduler of your own. Errors are re-thrown so the
215
+ * caller can log or alert; use the per-sweep helpers (`cleanup_expired_sessions`,
216
+ * `cleanup_expired_role_grant_offers`) for error isolation per sweep.
65
217
  *
66
218
  * @mutates `auth_session` table - deletes expired sessions
219
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on expired offers
67
220
  * @mutates `audit_log` table - emits `role_grant_offer_expire` rows for expired offers
68
- * @throws Error re-thrown from any sweep that fails (no per-sweep isolation here)
221
+ * @throws Error re-thrown from the first sweep that fails (no per-sweep isolation here)
69
222
  */
70
223
  export declare const run_auth_cleanup: (deps: AuthCleanupDeps) => Promise<AuthCleanupResult>;
224
+ /** Options for `start_auth_cleanup`. */
225
+ export interface AuthCleanupScheduleOptions {
226
+ /**
227
+ * Milliseconds between the start of one pass and the start of the next.
228
+ * Default `DEFAULT_AUTH_CLEANUP_INTERVAL_MS`. Must be a positive finite
229
+ * number no greater than `PERIODIC_INTERVAL_MS_MAX`.
230
+ */
231
+ interval_ms?: number;
232
+ }
233
+ /**
234
+ * Run `run_auth_cleanup` beside the server: a pass now, then one every
235
+ * `interval_ms`, until `stop` is called.
236
+ *
237
+ * The auth surface's upkeep in one call — start it once migrations have run
238
+ * and stop it before the database closes (see the module doc's § Scheduling).
239
+ * `create_app_server`'s `auth_cleanup` option does both.
240
+ *
241
+ * Built on `start_periodic`, which fixes the loop's rules: the startup pass
242
+ * starts before this returns, passes never overlap, a slow pass delays the
243
+ * schedule rather than bursting, a failed pass — the database out of reach —
244
+ * is logged at `warn` with its error and retried on the next interval, the
245
+ * timer is unref'd, and `stop` ends the schedule at once and resolves when a
246
+ * pass in progress has finished. A pass that swept something logs its counts
247
+ * at `info`, and one that closed a swept session's connections logs that
248
+ * count too.
249
+ *
250
+ * Twin of the Rust `spawn_auth_cleanup` over `fuz_sys::periodic::spawn_periodic`.
251
+ * The schedule is the same; shutdown differs — the Rust task drops a pass in
252
+ * progress when its token is cancelled, which a promise cannot do.
253
+ *
254
+ * @param deps - what a pass needs; `backend.deps` in a server
255
+ * @param options - the interval
256
+ * @returns the schedule's `stop`
257
+ * @throws Error if `interval_ms` is not a positive finite number or exceeds `PERIODIC_INTERVAL_MS_MAX` — at the call site, before any pass starts
258
+ */
259
+ export declare const start_auth_cleanup: (deps: AuthCleanupDeps, options?: AuthCleanupScheduleOptions) => PeriodicTask;
71
260
  //# sourceMappingURL=cleanup.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cleanup.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/cleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,4CAA4C;AAC5C,MAAM,WAAW,eAAgB,SAAQ,SAAS;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,KAAK,EAAE,YAAY,CAAC;CACpB;AAED,oCAAoC;AACpC,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,gBAAgB,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,MAAM,CAuB7F,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,iBAAiB,CAIvF,CAAC"}
1
+ {"version":3,"file":"cleanup.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/cleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4GG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAGxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAIvD;;;;;;;;;GASG;AACH,eAAO,MAAM,gCAAgC,QAAiB,CAAC;AAE/D;;;GAGG;AACH,MAAM,WAAW,eAAgB,SAAQ,SAAS;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;OAOG;IACH,KAAK,EAAE,YAAY,CAAC;IACpB;;;;;;;;;OASG;IACH,iBAAiB,EAAE,gBAAgB,CAAC;CACpC;AAED,+FAA+F;AAC/F,MAAM,WAAW,iBAAiB;IACjC,8EAA8E;IAC9E,gBAAgB,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,cAAc,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,wBAAwB,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,MAAM,CAqCpF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,iCAAiC,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,MAAM,CAgC7F,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,GAAU,MAAM,eAAe,KAAG,OAAO,CAAC,iBAAiB,CAIvF,CAAC;AAEF,wCAAwC;AACxC,MAAM,WAAW,0BAA0B;IAC1C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kBAAkB,GAC9B,MAAM,eAAe,EACrB,UAAS,0BAA+B,KACtC,YAWD,CAAC"}
@@ -1,77 +1,292 @@
1
1
  /**
2
2
  * Periodic auth cleanup — sweeps expired sessions and role_grant offers.
3
3
  *
4
- * Single entry point for consumers scheduling auth maintenance. Internally
5
- * runs every known sweep and emits the corresponding audit events so
6
- * consumer code only manages cadence, not per-task wiring.
4
+ * `run_auth_cleanup` is one pass: it deletes expired sessions and closes the
5
+ * connections they opened (`cleanup_expired_sessions`), then audits newly
6
+ * expired offers (`cleanup_expired_role_grant_offers`), returning both counts.
7
+ * `start_auth_cleanup` schedules it — the one call a server makes, and what
8
+ * `create_app_server`'s `auth_cleanup` option makes for it. The per-pass
9
+ * pieces stay exported for a consumer that wants its own cadence or error
10
+ * isolation per sweep. Twin of the Rust spine's `fuz_auth::auth_cleanup`.
7
11
  *
8
- * The per-task primitives remain exported from their home modules
9
- * (`query_session_cleanup_expired`, `query_role_grant_offer_sweep_expired`);
10
- * `cleanup_expired_role_grant_offers` here wraps the latter with the required
11
- * `role_grant_offer_expire` audit emission and is the piece most likely to be
12
- * reused in a consumer's bespoke scheduler.
12
+ * ## Expired sessions: the sweep closes what it sweeps
13
13
  *
14
- * Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
15
- * concurrent sweep runs double-audit. The expected deployment pattern is a
16
- * single scheduled invocation per instance — matching
17
- * `query_session_cleanup_expired`.
14
+ * A session past `expires_at` fails every validity check, but the connections
15
+ * it opened do not read it again: a WebSocket re-authorizes the actor per
16
+ * message, never the session, and an SSE stream is authorized once at open.
17
+ * While the expired row lingers, `account_session_revoke` is the per-session
18
+ * handle on those connections — it closes for the row it deletes. The sweep
19
+ * deletes that row, so it takes over the close: every session it deletes has
20
+ * its connections closed through `AuthCleanupDeps.connection_closer`, the same
21
+ * close a revocation makes (`WS_CLOSE_SESSION_REVOKED` on a WebSocket; an SSE
22
+ * stream ends). Otherwise a swept session's connections would be reachable
23
+ * only by an account-wide revocation.
24
+ *
25
+ * The contract that follows: **a connection outlives its session's expiry by
26
+ * at most one cleanup interval** (plus the pass itself). In a process that
27
+ * does not schedule the sweep, expiry ends no connection at all. The contract
28
+ * is for sessions only: an expired API token's row is not swept, so nothing
29
+ * here closes a connection opened on one — and `account_token_revoke` still
30
+ * finds the row to close for.
31
+ *
32
+ * The closes run after the delete's commit, which is the rule every
33
+ * revocation close keeps (see `actions/connection_closer.ts`). An expired
34
+ * session is refused with or without its row, so the order is not what
35
+ * protects a client rechecking on the close or a connection still opening; it
36
+ * keeps the sweep inside the one invariant admission leans on — a close that
37
+ * missed a registration belongs to a write the re-read can see — rather than
38
+ * on the re-read's clock. A commit whose outcome is unknown still closes: the
39
+ * sessions are expired either way.
40
+ *
41
+ * That is why the delete runs in its own transaction rather than as one
42
+ * pool-level statement. The ids are in hand before the commit is sent, so a
43
+ * connection lost on the delete's response rolls the delete back for the next
44
+ * pass, and one lost on the commit's still closes — no failure leaves rows
45
+ * deleted with their ids unread. A commit that fails outright has closed too;
46
+ * the rows it left are deleted by the next pass, whose closes then find
47
+ * nothing.
48
+ *
49
+ * One pass closes for every session it swept; a session with no live
50
+ * connection costs a lookup and closes nothing. The pass logs one count, not
51
+ * a line per session.
52
+ *
53
+ * No audit row is written for a swept session: expiry is not an event anyone
54
+ * performed, and the session was already refused everywhere. So the audit
55
+ * listeners never see it, and the direct close is the only one.
56
+ *
57
+ * One limit: the closes reach this process's transports, as every revocation
58
+ * close does — a second process on the same database keeps the connections it
59
+ * holds on a session this one swept.
60
+ *
61
+ * ## Offer idempotency
62
+ *
63
+ * An expired pending offer has no terminal state — expiry is computed from
64
+ * `expires_at`, never written — so the offer sweep claims rows instead: it
65
+ * stamps `role_grant_offer.expire_audited_at` in the same transaction as each
66
+ * row's `role_grant_offer_expire` audit row, so every expiry is audited
67
+ * exactly once — a second run finds nothing, and concurrent runs claim
68
+ * disjoint rows (row locks serialize them on the re-checked stamp). A failed
69
+ * audit insert rolls the stamps back with it and the next run retries. A
70
+ * re-offer clears the stamp, so the refreshed offer's own expiry is audited
71
+ * too.
72
+ *
73
+ * ## Scheduling
74
+ *
75
+ * `start_auth_cleanup` runs the pass beside the server: a pass at once, then
76
+ * one per interval (`DEFAULT_AUTH_CLEANUP_INTERVAL_MS` unless the consumer has
77
+ * a reason to differ), until its `stop` is called. A failed pass — the
78
+ * database out of reach — is logged and retried on the next interval.
79
+ *
80
+ * Start it once migrations have run (the startup pass reads the auth tables),
81
+ * with the app's bound `AuditEmitter` so the expiry audits reach its listeners
82
+ * and the closer its revocation handlers close through. `AppDeps` is all of
83
+ * that, so the standard assembly is one option:
84
+ *
85
+ * ```ts
86
+ * const server = await create_app_server({backend, auth_cleanup: true, ...});
87
+ * // on shutdown: stops the schedule, then closes the database
88
+ * await server.close();
89
+ * ```
90
+ *
91
+ * and a hand-assembled server calls it directly:
92
+ *
93
+ * ```ts
94
+ * const auth_cleanup = start_auth_cleanup(backend.deps);
95
+ * // on shutdown, before the database closes
96
+ * await auth_cleanup.stop();
97
+ * ```
98
+ *
99
+ * The loop is `start_periodic` (`periodic.ts`), which keeps the Rust spine's
100
+ * schedule (`fuz_sys::periodic`): passes never overlap, and a pass that
101
+ * overruns its interval delays the next one instead of bursting to catch up.
102
+ * It differs on shutdown. The Rust loop drops a pass in progress when its
103
+ * token is cancelled; a promise cannot be dropped, so `stop` ends the schedule
104
+ * at once and its promise resolves when the pass in progress — if there is
105
+ * one — has finished. Awaiting it before closing the database lets that pass
106
+ * end on a live pool rather than fail on a closed one.
18
107
  *
19
108
  * @module
20
109
  */
110
+ import { start_periodic } from '../periodic.js';
21
111
  import { query_session_cleanup_expired } from './session_queries.js';
22
112
  import { query_role_grant_offer_sweep_expired } from './role_grant_offer_queries.js';
113
+ import { query_audit_log } from './audit_log_queries.js';
114
+ /**
115
+ * How often `start_auth_cleanup` runs a pass after its startup pass
116
+ * (10 minutes), unless the consumer passes its own. Equal to the Rust spine's
117
+ * `DEFAULT_AUTH_CLEANUP_INTERVAL`.
118
+ *
119
+ * An expired session or offer is already refused by every check that reads
120
+ * it, so the interval bounds three things: how long a connection outlives
121
+ * the expiry of the session it opened on, how late an offer expiry's audit
122
+ * row lands, and how long dead rows linger.
123
+ */
124
+ export const DEFAULT_AUTH_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
125
+ /**
126
+ * Delete every expired session and close the connections each one opened,
127
+ * returning the count of sessions deleted.
128
+ *
129
+ * The delete runs in its own transaction and the closes follow its commit,
130
+ * so on a successful pass every close is for a row already gone (see the
131
+ * module doc's § Expired sessions). Each swept session is closed by its id —
132
+ * the session-token hash the transports key on — so another session of the
133
+ * same account keeps its connections. A pass that closed something logs the
134
+ * counts at `info`. No audit row is written.
135
+ *
136
+ * A closer that throws for one session does not spare the rest: every swept
137
+ * session is attempted and each failure is logged, as the post-commit queue
138
+ * does for a revocation's close.
139
+ *
140
+ * Twin of the Rust `cleanup_expired_sessions`.
141
+ *
142
+ * @returns how many expired sessions were deleted
143
+ * @mutates `auth_session` table - deletes every row past `expires_at`
144
+ * @throws Error from opening the transaction, the delete, or the commit. The first two close nothing and delete nothing. A failed commit is thrown **after** the closes: the sessions the delete returned are expired whether or not the commit landed, so their connections are closed either way, and the next pass deletes any row the commit left.
145
+ */
146
+ export const cleanup_expired_sessions = async (deps) => {
147
+ let swept = [];
148
+ let failed = false;
149
+ let failure;
150
+ try {
151
+ await deps.db.transaction(async (tx) => {
152
+ // the ids are in hand before the commit is sent: a connection lost on
153
+ // the delete's response rolls the delete back for the next pass, and
154
+ // one lost on the commit's still closes below
155
+ swept = await query_session_cleanup_expired({ db: tx });
156
+ });
157
+ }
158
+ catch (error) {
159
+ // thrown before the delete answered, `swept` is empty and nothing closes;
160
+ // thrown after, the commit failed or its answer was lost
161
+ failed = true;
162
+ failure = error;
163
+ }
164
+ // the transaction has settled. The closes are synchronous, so nothing can
165
+ // interleave between the first and the last
166
+ let closed_connections = 0;
167
+ for (const session_token_hash of swept) {
168
+ try {
169
+ closed_connections += deps.connection_closer.close_sockets_for_session(session_token_hash);
170
+ }
171
+ catch (error) {
172
+ // keep going — one transport's failure must not leave another
173
+ // expired session's connections open
174
+ deps.log.error("auth cleanup failed to close an expired session's connections:", error);
175
+ }
176
+ }
177
+ if (closed_connections > 0) {
178
+ deps.log.info('auth cleanup closed the connections of expired sessions', {
179
+ expired_sessions: swept.length,
180
+ closed_connections
181
+ });
182
+ }
183
+ if (failed)
184
+ throw failure;
185
+ return swept.length;
186
+ };
23
187
  /**
24
188
  * Sweep expired role_grant offers and emit one `role_grant_offer_expire` audit
25
- * event per row.
189
+ * event per newly expired row.
190
+ *
191
+ * One transaction: `query_role_grant_offer_sweep_expired` claims the expired,
192
+ * not-yet-audited offers (stamping `expire_audited_at`) and each claimed row's
193
+ * audit row is inserted beside it. After the commit, each event fans out
194
+ * through `deps.audit.notify` — per-listener throws are isolated there, so one
195
+ * bad listener never starves the rest. Twin of the Rust
196
+ * `cleanup_expired_role_grant_offers`.
26
197
  *
27
- * Returns the count of offers audit-stamped. The offer rows themselves are
198
+ * Returns the count of offers audited. The offer rows themselves are
28
199
  * preserved — offers carry audit value for the history view even after
29
200
  * expiry, and accepted rows are the provenance for the resulting role_grant
30
201
  * (deleting expired rows would not threaten that, but keeping them uniform
31
202
  * with the retention policy for terminal rows is simpler).
32
203
  *
204
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on each swept offer
33
205
  * @mutates `audit_log` table - inserts one `role_grant_offer_expire` row per swept offer
206
+ * @throws Error if the claim or an audit insert fails — the transaction rolls back, so no offer is stamped and the next run retries
34
207
  */
35
208
  export const cleanup_expired_role_grant_offers = async (deps) => {
36
- const expired = await query_role_grant_offer_sweep_expired(deps);
37
- for (const offer of expired) {
38
- // `role_grant_offer_expire` populates `target_actor_id` only when the
39
- // offer was actor-targeted (`to_actor_id` set at create time).
40
- // Account-grain offers (no `to_actor_id`) never bound to a
41
- // specific actor and leave the field null.
42
- // `emit_pool` swallows + logs both write errors and per-listener
43
- // throws, so a single bad row never starves the rest of the sweep.
44
- await deps.audit.emit_pool({
45
- event_type: 'role_grant_offer_expire',
46
- actor_id: offer.from_actor_id,
47
- target_account_id: offer.to_account_id,
48
- target_actor_id: offer.to_actor_id,
49
- ip: null,
50
- metadata: {
51
- offer_id: offer.id,
52
- role: offer.role,
53
- scope_id: offer.scope_id
54
- }
55
- });
56
- }
57
- return expired.length;
209
+ const events = await deps.db.transaction(async (tx) => {
210
+ const expired = await query_role_grant_offer_sweep_expired({ db: tx });
211
+ const written = [];
212
+ for (const offer of expired) {
213
+ // `role_grant_offer_expire` populates `target_actor_id` only when the
214
+ // offer was actor-targeted (`to_actor_id` set at create time).
215
+ // Account-grain offers (no `to_actor_id`) never bound to a
216
+ // specific actor and leave the field null. No request is behind a
217
+ // sweep, so `ip` stays null.
218
+ written.push(await query_audit_log({ db: tx }, {
219
+ event_type: 'role_grant_offer_expire',
220
+ actor_id: offer.from_actor_id,
221
+ target_account_id: offer.to_account_id,
222
+ target_actor_id: offer.to_actor_id,
223
+ ip: null,
224
+ metadata: {
225
+ offer_id: offer.id,
226
+ role: offer.role,
227
+ scope_id: offer.scope_id
228
+ }
229
+ }));
230
+ }
231
+ return written;
232
+ });
233
+ for (const event of events)
234
+ deps.audit.notify(event);
235
+ return events.length;
58
236
  };
59
237
  /**
60
- * Run every auth cleanup sweep — expired sessions and expired role_grant
61
- * offers — and return the counts.
238
+ * Run every auth cleanup sweep — expired sessions, then expired role_grant
239
+ * offers — and return both counts. Twin of the Rust `run_auth_cleanup`.
240
+ *
241
+ * The session sweep (`cleanup_expired_sessions`) commits its delete and
242
+ * closes the swept sessions' connections before the offer sweep starts, so a
243
+ * failed offer sweep takes neither back; a session sweep whose commit failed
244
+ * has closed too, and the offer sweep does not run.
62
245
  *
63
- * Consumers call this from a scheduled task (setInterval, cron, etc.)
64
- * alongside their own domain cleanup. Errors from individual sweeps are
65
- * re-thrown so the caller's scheduler can log/alert; use the per-task
66
- * helpers (`query_session_cleanup_expired`, `cleanup_expired_role_grant_offers`)
67
- * directly if you need finer error isolation.
246
+ * `start_auth_cleanup` is the scheduler over this; call it directly for a
247
+ * one-off pass or from a scheduler of your own. Errors are re-thrown so the
248
+ * caller can log or alert; use the per-sweep helpers (`cleanup_expired_sessions`,
249
+ * `cleanup_expired_role_grant_offers`) for error isolation per sweep.
68
250
  *
69
251
  * @mutates `auth_session` table - deletes expired sessions
252
+ * @mutates `role_grant_offer` rows - stamps `expire_audited_at` on expired offers
70
253
  * @mutates `audit_log` table - emits `role_grant_offer_expire` rows for expired offers
71
- * @throws Error re-thrown from any sweep that fails (no per-sweep isolation here)
254
+ * @throws Error re-thrown from the first sweep that fails (no per-sweep isolation here)
72
255
  */
73
256
  export const run_auth_cleanup = async (deps) => {
74
- const expired_sessions = await query_session_cleanup_expired(deps);
257
+ const expired_sessions = await cleanup_expired_sessions(deps);
75
258
  const expired_offers = await cleanup_expired_role_grant_offers(deps);
76
259
  return { expired_sessions, expired_offers };
77
260
  };
261
+ /**
262
+ * Run `run_auth_cleanup` beside the server: a pass now, then one every
263
+ * `interval_ms`, until `stop` is called.
264
+ *
265
+ * The auth surface's upkeep in one call — start it once migrations have run
266
+ * and stop it before the database closes (see the module doc's § Scheduling).
267
+ * `create_app_server`'s `auth_cleanup` option does both.
268
+ *
269
+ * Built on `start_periodic`, which fixes the loop's rules: the startup pass
270
+ * starts before this returns, passes never overlap, a slow pass delays the
271
+ * schedule rather than bursting, a failed pass — the database out of reach —
272
+ * is logged at `warn` with its error and retried on the next interval, the
273
+ * timer is unref'd, and `stop` ends the schedule at once and resolves when a
274
+ * pass in progress has finished. A pass that swept something logs its counts
275
+ * at `info`, and one that closed a swept session's connections logs that
276
+ * count too.
277
+ *
278
+ * Twin of the Rust `spawn_auth_cleanup` over `fuz_sys::periodic::spawn_periodic`.
279
+ * The schedule is the same; shutdown differs — the Rust task drops a pass in
280
+ * progress when its token is cancelled, which a promise cannot do.
281
+ *
282
+ * @param deps - what a pass needs; `backend.deps` in a server
283
+ * @param options - the interval
284
+ * @returns the schedule's `stop`
285
+ * @throws Error if `interval_ms` is not a positive finite number or exceeds `PERIODIC_INTERVAL_MS_MAX` — at the call site, before any pass starts
286
+ */
287
+ export const start_auth_cleanup = (deps, options = {}) => start_periodic('auth cleanup', options.interval_ms ?? DEFAULT_AUTH_CLEANUP_INTERVAL_MS, async () => {
288
+ const swept = await run_auth_cleanup(deps);
289
+ if (swept.expired_sessions > 0 || swept.expired_offers > 0) {
290
+ deps.log.info('auth cleanup swept expired rows', swept);
291
+ }
292
+ }, deps.log);