@fuzdev/fuz_app 0.118.0 → 0.120.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.
- package/dist/actions/CLAUDE.md +301 -73
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +16 -1
- package/dist/actions/connection_closer.d.ts +136 -51
- package/dist/actions/connection_closer.d.ts.map +1 -1
- package/dist/actions/connection_closer.js +133 -34
- package/dist/actions/register_action_ws.d.ts +176 -24
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +581 -241
- package/dist/actions/register_ws_endpoint.d.ts +1 -0
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +1 -0
- package/dist/actions/socket.svelte.d.ts +43 -9
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/socket.svelte.js +59 -8
- package/dist/actions/transports.d.ts +55 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports.js +55 -1
- package/dist/actions/transports_ws_auth_guard.d.ts +53 -67
- package/dist/actions/transports_ws_auth_guard.d.ts.map +1 -1
- package/dist/actions/transports_ws_auth_guard.js +87 -111
- package/dist/actions/transports_ws_backend.d.ts +212 -30
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +327 -70
- package/dist/actions/ws_endpoint_spec.d.ts +26 -13
- package/dist/actions/ws_endpoint_spec.d.ts.map +1 -1
- package/dist/auth/CLAUDE.md +113 -32
- package/dist/auth/account_actions.d.ts +12 -18
- package/dist/auth/account_actions.d.ts.map +1 -1
- package/dist/auth/account_actions.js +47 -33
- package/dist/auth/account_routes.d.ts +5 -11
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +40 -36
- package/dist/auth/account_schema.d.ts +27 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +22 -0
- package/dist/auth/admin_actions.d.ts +8 -17
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +33 -21
- package/dist/auth/api_token_queries.d.ts +23 -2
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +39 -3
- package/dist/auth/audit_emitter.d.ts +61 -38
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +57 -14
- package/dist/auth/audit_log_route_schema.js +7 -7
- package/dist/auth/audit_log_routes.d.ts +31 -6
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +85 -11
- package/dist/auth/audit_log_schema.d.ts +48 -0
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +58 -0
- package/dist/auth/cleanup.d.ts +203 -28
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +222 -26
- package/dist/auth/deps.d.ts +31 -3
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/resolved_auth.d.ts +105 -0
- package/dist/auth/resolved_auth.d.ts.map +1 -0
- package/dist/auth/resolved_auth.js +124 -0
- package/dist/auth/session_middleware.d.ts +8 -1
- package/dist/auth/session_middleware.d.ts.map +1 -1
- package/dist/auth/session_middleware.js +9 -3
- package/dist/auth/session_queries.d.ts +31 -6
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +31 -6
- package/dist/auth/standard_rpc_actions.d.ts +9 -9
- package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
- package/dist/auth/standard_rpc_actions.js +3 -3
- package/dist/db/CLAUDE.md +19 -8
- package/dist/db/fact_disk_storage.d.ts +10 -8
- package/dist/db/fact_disk_storage.d.ts.map +1 -1
- package/dist/db/fact_disk_storage.js +13 -17
- package/dist/db/fact_store.d.ts +39 -38
- package/dist/db/fact_store.d.ts.map +1 -1
- package/dist/db/fact_store.js +48 -86
- package/dist/db/file_fact_url.d.ts +6 -6
- package/dist/db/file_fact_url.js +6 -6
- package/dist/db/status.d.ts +58 -1
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +205 -77
- package/dist/http/CLAUDE.md +7 -3
- package/dist/http/db_routes.d.ts +6 -2
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +2 -2
- package/dist/periodic.d.ts +80 -0
- package/dist/periodic.d.ts.map +1 -0
- package/dist/periodic.js +122 -0
- package/dist/realtime/sse_auth_guard.d.ts +79 -49
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +103 -73
- package/dist/realtime/subscriber_registry.d.ts +119 -8
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +184 -25
- package/dist/server/app_backend.d.ts +12 -1
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_backend.js +5 -2
- package/dist/server/app_server.d.ts +55 -15
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +41 -13
- package/dist/server/serve_fact_route.d.ts +10 -7
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +11 -8
- package/dist/testing/CLAUDE.md +140 -48
- package/dist/testing/app_server.d.ts +2 -2
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +11 -2
- package/dist/testing/audit_drift_guard.d.ts +1 -48
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +0 -42
- package/dist/testing/connection_closer_helpers.d.ts +9 -22
- package/dist/testing/connection_closer_helpers.d.ts.map +1 -1
- package/dist/testing/connection_closer_helpers.js +11 -17
- package/dist/testing/cross_backend/capabilities.d.ts +67 -13
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +27 -12
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +21 -0
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.d.ts.map +1 -1
- package/dist/testing/cross_backend/role_grant_offer_notification_ws.js +13 -4
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.js +4 -1
- package/dist/testing/cross_backend/session_cap.d.ts +13 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -1
- package/dist/testing/cross_backend/session_cap.js +55 -19
- package/dist/testing/cross_backend/sse_round_trip.d.ts +10 -2
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +93 -26
- package/dist/testing/cross_backend/testing_server_bun.js +4 -4
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +10 -2
- package/dist/testing/cross_backend/ws_connection_cap.d.ts +35 -0
- package/dist/testing/cross_backend/ws_connection_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/ws_connection_cap.js +131 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +2 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +156 -19
- package/dist/testing/sse_round_trip.d.ts +6 -4
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/stubs.d.ts +1 -1
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +6 -4
- package/dist/testing/transports/sse_transport.d.ts +1 -1
- package/dist/testing/transports/ws_client.d.ts +7 -0
- package/dist/testing/transports/ws_client.d.ts.map +1 -1
- package/dist/testing/transports/ws_raw_client.d.ts +51 -0
- package/dist/testing/transports/ws_raw_client.d.ts.map +1 -0
- package/dist/testing/transports/ws_raw_client.js +156 -0
- package/dist/testing/transports/ws_transport.d.ts +28 -0
- package/dist/testing/transports/ws_transport.d.ts.map +1 -1
- package/dist/testing/transports/ws_transport.js +45 -1
- package/dist/testing/ws_round_trip.d.ts +8 -3
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +19 -4
- package/dist/ui/AdminAccounts.svelte +4 -3
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminOverview.svelte +3 -1
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +22 -6
- package/dist/ui/admin_accounts_state.svelte.d.ts +15 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +16 -0
- package/dist/ui/auth_state.svelte.d.ts +25 -1
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +28 -1
- package/package.json +2 -2
- package/dist/server/file_fact_fetcher.d.ts +0 -42
- package/dist/server/file_fact_fetcher.d.ts.map +0 -1
- package/dist/server/file_fact_fetcher.js +0 -60
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
80
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
141
|
-
let i = 0;
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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`),
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
-
/**
|
|
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`.
|
|
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":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,IAAI,EAAE,KAAK,OAAO,EAAE,MAAM,MAAM,CAAC;AAG1C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAEN,KAAK,cAAc,EAEnB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAC5E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAGN,KAAK,WAAW,EAChB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EAKN,KAAK,WAAW,EAChB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAGhE,OAAO,oBAAoB,CAAC;AAE5B,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEhF,OAAO,EAEN,KAAK,cAAc,EAEnB,KAAK,eAAe,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAIN,KAAK,SAAS,EACd,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAGN,KAAK,eAAe,EACpB,MAAM,6BAA6B,CAAC;AASrC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;
|
|
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
|
|
36
|
-
import {
|
|
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`.
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
};
|