@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.
- 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 +125 -31
- 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 +72 -44
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_emitter.js +60 -16
- 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/bootstrap_account.d.ts +3 -2
- package/dist/auth/bootstrap_account.d.ts.map +1 -1
- package/dist/auth/bootstrap_account.js +3 -3
- package/dist/auth/cleanup.d.ts +219 -30
- package/dist/auth/cleanup.d.ts.map +1 -1
- package/dist/auth/cleanup.js +260 -45
- package/dist/auth/deps.d.ts +29 -1
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +27 -0
- 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/role_grant_offer_ddl.d.ts +4 -2
- package/dist/auth/role_grant_offer_ddl.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_ddl.js +10 -3
- package/dist/auth/role_grant_offer_queries.d.ts +16 -7
- package/dist/auth/role_grant_offer_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_offer_queries.js +33 -15
- package/dist/auth/role_grant_offer_schema.d.ts +9 -0
- package/dist/auth/role_grant_offer_schema.d.ts.map +1 -1
- 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 +7 -1
- package/dist/db/migrate.d.ts +8 -13
- package/dist/db/migrate.d.ts.map +1 -1
- package/dist/db/migrate.js +9 -38
- package/dist/db/status.d.ts +58 -7
- package/dist/db/status.d.ts.map +1 -1
- package/dist/db/status.js +205 -97
- 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/runtime/deno.d.ts.map +1 -1
- package/dist/runtime/deno.js +9 -6
- package/dist/runtime/deps.d.ts +3 -2
- package/dist/runtime/deps.d.ts.map +1 -1
- package/dist/runtime/secure_file.d.ts +23 -9
- package/dist/runtime/secure_file.d.ts.map +1 -1
- package/dist/runtime/secure_file.js +29 -10
- package/dist/server/app_backend.d.ts +13 -2
- 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/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 +3 -52
- package/dist/testing/audit_drift_guard.d.ts.map +1 -1
- package/dist/testing/audit_drift_guard.js +2 -46
- 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/expected_schema.json +1 -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
|
@@ -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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deno.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deno.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAA6B,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"deno.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deno.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,WAAW,EAA6B,MAAM,WAAW,CAAC;AAyExE;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,KAAG,WA0KhE,CAAC"}
|
package/dist/runtime/deno.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import { to_error_message } from '@fuzdev/fuz_util/error.ts';
|
|
11
|
-
import { assert_secure_mode, assert_secure_size, read_secure_bounded } from './secure_file.js';
|
|
11
|
+
import { assert_secure_mode, assert_secure_regular_file, assert_secure_size, read_secure_bounded } from './secure_file.js';
|
|
12
12
|
/**
|
|
13
13
|
* Create a runtime backed by Deno APIs.
|
|
14
14
|
*
|
|
@@ -46,17 +46,20 @@ export const create_deno_runtime = (args) => ({
|
|
|
46
46
|
read_text_file: (path) => Deno.readTextFile(path),
|
|
47
47
|
read_file: (path) => Deno.readFile(path),
|
|
48
48
|
read_secure_file: async (path) => {
|
|
49
|
-
// Deno exposes no O_NOFOLLOW, so the symlink
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
49
|
+
// Deno exposes no O_NOFOLLOW or O_NONBLOCK, so the symlink and
|
|
50
|
+
// regular-file checks are a pre-open lstat (a TOCTOU window the Node
|
|
51
|
+
// impl doesn't have — a FIFO swapped in after it would block the open;
|
|
52
|
+
// the checks below still run on the open descriptor, so a post-open swap
|
|
53
|
+
// can't defeat them). The checks themselves are the shared
|
|
54
|
+
// `runtime/secure_file.ts` helpers, same as `load_secure_file_node`.
|
|
54
55
|
const l = await Deno.lstat(path);
|
|
55
56
|
if (l.isSymlink)
|
|
56
57
|
throw new Error(`secure file is a symlink: ${path}`);
|
|
58
|
+
assert_secure_regular_file(path, l.isFile);
|
|
57
59
|
const file = await Deno.open(path, { read: true });
|
|
58
60
|
try {
|
|
59
61
|
const s = await file.stat();
|
|
62
|
+
assert_secure_regular_file(path, s.isFile);
|
|
60
63
|
// `mode` is null where POSIX modes don't apply (Windows).
|
|
61
64
|
if (s.mode !== null)
|
|
62
65
|
assert_secure_mode(path, s.mode);
|
package/dist/runtime/deps.d.ts
CHANGED
|
@@ -103,8 +103,9 @@ export interface WriteFileOptions {
|
|
|
103
103
|
*/
|
|
104
104
|
export interface FsSecureReadDeps {
|
|
105
105
|
/**
|
|
106
|
-
* Read a small secret file with fail-loud checks: rejects symlinks,
|
|
107
|
-
*
|
|
106
|
+
* Read a small secret file with fail-loud checks: rejects symlinks,
|
|
107
|
+
* non-regular files (FIFO, device, directory — real runtimes only; the mock
|
|
108
|
+
* has no file types), any group/other-accessible mode (must be `0600`/`0400`; skipped where POSIX
|
|
108
109
|
* modes don't apply), and files over a 4 KiB cap. Permission checks run on
|
|
109
110
|
* the open descriptor where the platform allows, so the file can't be
|
|
110
111
|
* swapped between check and read. Throws on any refusal — a refused secret
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deps.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deps.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;GAEG;AACH,MAAM,WAAW,UAAU;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;;;;;OASG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IACjC,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,OAAO;IACvB,yCAAyC;IACzC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IAC9C,mCAAmC;IACnC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/C;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAChC;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAChC
|
|
1
|
+
{"version":3,"file":"deps.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/deps.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,18 +4,20 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The file this guards is typically the highest-value credential on the host
|
|
6
6
|
* (the bootstrap token mints the keeper account), so the read fails loud
|
|
7
|
-
* rather than degrading: a symlink, a
|
|
8
|
-
*
|
|
9
|
-
* token at `0600`, so the check
|
|
10
|
-
* hand-placed `0644` file fails at boot
|
|
7
|
+
* rather than degrading: a symlink, a non-regular file (FIFO, device node,
|
|
8
|
+
* directory), a group/other-accessible mode, or an oversized file is refused,
|
|
9
|
+
* never returned. The deploy recipe places the token at `0600`, so the check
|
|
10
|
+
* ratifies the shape zap produces; a hand-placed `0644` file fails at boot
|
|
11
|
+
* instead of being read.
|
|
11
12
|
*
|
|
12
13
|
* The Node implementation lives here (`load_secure_file_node`, wired as
|
|
13
14
|
* `create_node_runtime().read_secure_file`); the Deno runtime implements the
|
|
14
15
|
* same contract over `Deno.open` in `runtime/deno.ts`, and the mock honors
|
|
15
|
-
* the mode/size checks over its in-memory map
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* the mode/size checks over its in-memory map (it has no file types). The real
|
|
17
|
+
* runtimes refuse through the shared `assert_secure_regular_file` /
|
|
18
|
+
* `assert_secure_mode` / `assert_secure_size` / `read_secure_bounded` helpers,
|
|
19
|
+
* and the mock through the mode and size ones, so the checks — and their
|
|
20
|
+
* operator-facing messages — can't drift between runtimes.
|
|
19
21
|
*
|
|
20
22
|
* @module
|
|
21
23
|
*/
|
|
@@ -25,6 +27,13 @@
|
|
|
25
27
|
* `MAX_SECURE_FILE_SIZE`.
|
|
26
28
|
*/
|
|
27
29
|
export declare const MAX_SECURE_FILE_SIZE = 4096;
|
|
30
|
+
/**
|
|
31
|
+
* Refuse anything but a regular file — a FIFO, device node, socket, or
|
|
32
|
+
* directory at the secret's path.
|
|
33
|
+
*
|
|
34
|
+
* @throws Error naming the path
|
|
35
|
+
*/
|
|
36
|
+
export declare const assert_secure_regular_file: (path: string, is_file: boolean) => void;
|
|
28
37
|
/**
|
|
29
38
|
* Refuse any group/other-accessible mode (only `0600`/`0400` pass).
|
|
30
39
|
*
|
|
@@ -57,6 +66,11 @@ export declare const read_secure_bounded: (path: string, read_chunk: (target: Ui
|
|
|
57
66
|
* Read a secret file with fail-loud checks (Node implementation).
|
|
58
67
|
*
|
|
59
68
|
* - `O_NOFOLLOW` atomically rejects symlinks during open (no TOCTOU window)
|
|
69
|
+
* - `O_NONBLOCK` keeps the open from waiting on a FIFO that has no writer (it
|
|
70
|
+
* has no effect on a regular file); the regular-file check on the open
|
|
71
|
+
* descriptor then refuses it, a device node, or a directory
|
|
72
|
+
* - `O_NOCTTY` keeps a terminal device at the path from becoming the process's
|
|
73
|
+
* controlling terminal in the moment before that check refuses it
|
|
60
74
|
* - the permission check runs on the open descriptor, not the path, so the
|
|
61
75
|
* file can't be swapped between check and read — any group/other access
|
|
62
76
|
* (not `0600`/`0400`) is refused
|
|
@@ -65,7 +79,7 @@ export declare const read_secure_bounded: (path: string, read_chunk: (target: Ui
|
|
|
65
79
|
*
|
|
66
80
|
* @param path - path to the secret file
|
|
67
81
|
* @returns the file's bytes
|
|
68
|
-
* @throws Error on a missing file, symlink, permissive mode, oversized file, or I/O failure
|
|
82
|
+
* @throws Error on a missing file, symlink, non-regular file, permissive mode, oversized file, or I/O failure
|
|
69
83
|
*/
|
|
70
84
|
export declare const load_secure_file_node: (path: string) => Promise<Uint8Array>;
|
|
71
85
|
//# sourceMappingURL=secure_file.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secure_file.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/secure_file.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"secure_file.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/runtime/secure_file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAMH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAUzC;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GAAI,MAAM,MAAM,EAAE,SAAS,OAAO,KAAG,IAI3E,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,IAM/D,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,EAAE,MAAM,MAAM,KAAG,IAI/D,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAC/B,MAAM,MAAM,EACZ,YAAY,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,KACxD,OAAO,CAAC,UAAU,CAUpB,CAAC;AAEF;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,qBAAqB,GAAU,MAAM,MAAM,KAAG,OAAO,CAAC,UAAU,CA2B5E,CAAC"}
|
|
@@ -4,18 +4,20 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The file this guards is typically the highest-value credential on the host
|
|
6
6
|
* (the bootstrap token mints the keeper account), so the read fails loud
|
|
7
|
-
* rather than degrading: a symlink, a
|
|
8
|
-
*
|
|
9
|
-
* token at `0600`, so the check
|
|
10
|
-
* hand-placed `0644` file fails at boot
|
|
7
|
+
* rather than degrading: a symlink, a non-regular file (FIFO, device node,
|
|
8
|
+
* directory), a group/other-accessible mode, or an oversized file is refused,
|
|
9
|
+
* never returned. The deploy recipe places the token at `0600`, so the check
|
|
10
|
+
* ratifies the shape zap produces; a hand-placed `0644` file fails at boot
|
|
11
|
+
* instead of being read.
|
|
11
12
|
*
|
|
12
13
|
* The Node implementation lives here (`load_secure_file_node`, wired as
|
|
13
14
|
* `create_node_runtime().read_secure_file`); the Deno runtime implements the
|
|
14
15
|
* same contract over `Deno.open` in `runtime/deno.ts`, and the mock honors
|
|
15
|
-
* the mode/size checks over its in-memory map
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* the mode/size checks over its in-memory map (it has no file types). The real
|
|
17
|
+
* runtimes refuse through the shared `assert_secure_regular_file` /
|
|
18
|
+
* `assert_secure_mode` / `assert_secure_size` / `read_secure_bounded` helpers,
|
|
19
|
+
* and the mock through the mode and size ones, so the checks — and their
|
|
20
|
+
* operator-facing messages — can't drift between runtimes.
|
|
19
21
|
*
|
|
20
22
|
* @module
|
|
21
23
|
*/
|
|
@@ -35,6 +37,17 @@ export const MAX_SECURE_FILE_SIZE = 4096;
|
|
|
35
37
|
* The mock runtime checks unconditionally — its modes are simulated.
|
|
36
38
|
*/
|
|
37
39
|
const modes_apply = process.platform !== 'win32';
|
|
40
|
+
/**
|
|
41
|
+
* Refuse anything but a regular file — a FIFO, device node, socket, or
|
|
42
|
+
* directory at the secret's path.
|
|
43
|
+
*
|
|
44
|
+
* @throws Error naming the path
|
|
45
|
+
*/
|
|
46
|
+
export const assert_secure_regular_file = (path, is_file) => {
|
|
47
|
+
if (!is_file) {
|
|
48
|
+
throw new Error(`not a regular file: ${path}`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
38
51
|
/**
|
|
39
52
|
* Refuse any group/other-accessible mode (only `0600`/`0400` pass).
|
|
40
53
|
*
|
|
@@ -86,6 +99,11 @@ export const read_secure_bounded = async (path, read_chunk) => {
|
|
|
86
99
|
* Read a secret file with fail-loud checks (Node implementation).
|
|
87
100
|
*
|
|
88
101
|
* - `O_NOFOLLOW` atomically rejects symlinks during open (no TOCTOU window)
|
|
102
|
+
* - `O_NONBLOCK` keeps the open from waiting on a FIFO that has no writer (it
|
|
103
|
+
* has no effect on a regular file); the regular-file check on the open
|
|
104
|
+
* descriptor then refuses it, a device node, or a directory
|
|
105
|
+
* - `O_NOCTTY` keeps a terminal device at the path from becoming the process's
|
|
106
|
+
* controlling terminal in the moment before that check refuses it
|
|
89
107
|
* - the permission check runs on the open descriptor, not the path, so the
|
|
90
108
|
* file can't be swapped between check and read — any group/other access
|
|
91
109
|
* (not `0600`/`0400`) is refused
|
|
@@ -94,12 +112,12 @@ export const read_secure_bounded = async (path, read_chunk) => {
|
|
|
94
112
|
*
|
|
95
113
|
* @param path - path to the secret file
|
|
96
114
|
* @returns the file's bytes
|
|
97
|
-
* @throws Error on a missing file, symlink, permissive mode, oversized file, or I/O failure
|
|
115
|
+
* @throws Error on a missing file, symlink, non-regular file, permissive mode, oversized file, or I/O failure
|
|
98
116
|
*/
|
|
99
117
|
export const load_secure_file_node = async (path) => {
|
|
100
118
|
let handle;
|
|
101
119
|
try {
|
|
102
|
-
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
120
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | constants.O_NOCTTY);
|
|
103
121
|
}
|
|
104
122
|
catch (err) {
|
|
105
123
|
// ELOOP means the path was a symlink (O_NOFOLLOW refused it).
|
|
@@ -110,6 +128,7 @@ export const load_secure_file_node = async (path) => {
|
|
|
110
128
|
}
|
|
111
129
|
try {
|
|
112
130
|
const s = await handle.stat();
|
|
131
|
+
assert_secure_regular_file(path, s.isFile());
|
|
113
132
|
if (modes_apply)
|
|
114
133
|
assert_secure_mode(path, s.mode);
|
|
115
134
|
assert_secure_size(path, s.size);
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
14
14
|
import type { AppDeps } from '../auth/deps.js';
|
|
15
15
|
import { type AuditEmitter } from '../auth/audit_emitter.js';
|
|
16
|
+
import { type RealtimeCloser } from '../actions/connection_closer.js';
|
|
16
17
|
import type { DbType, Db } from '../db/db.js';
|
|
17
18
|
import type { Keyring } from '../auth/keyring.js';
|
|
18
19
|
import type { PasswordHashDeps } from '../auth/password.js';
|
|
@@ -85,7 +86,7 @@ export interface CreateAppBackendOptions {
|
|
|
85
86
|
/**
|
|
86
87
|
* Hardened secret-file read for the bootstrap token — pass the runtime's
|
|
87
88
|
* `read_secure_file` (see `FsSecureReadDeps`). Rejects symlinks,
|
|
88
|
-
* group/other-accessible modes, and oversized files.
|
|
89
|
+
* non-regular files, group/other-accessible modes, and oversized files.
|
|
89
90
|
*/
|
|
90
91
|
read_secure_file: (path: string) => Promise<Uint8Array>;
|
|
91
92
|
/** Delete a file. */
|
|
@@ -118,6 +119,15 @@ export interface CreateAppBackendOptions {
|
|
|
118
119
|
* `audit.add_listener(...)`.
|
|
119
120
|
*/
|
|
120
121
|
audit_factory: AuditFactory;
|
|
122
|
+
/**
|
|
123
|
+
* The closer to put on `AppDeps.connection_closer`. Omit for a fresh one
|
|
124
|
+
* — the usual case, since `create_app_server` adds the transports it
|
|
125
|
+
* mounts. Pass one built up front (`create_realtime_closer()`) when a
|
|
126
|
+
* transport is constructed before the backend and has to be added to the
|
|
127
|
+
* same closer the handlers will hold — e.g. an audit stream from
|
|
128
|
+
* `create_audit_log_sse` whose listener `audit_factory` registers.
|
|
129
|
+
*/
|
|
130
|
+
connection_closer?: RealtimeCloser;
|
|
121
131
|
/**
|
|
122
132
|
* Additional migration namespaces to run after the builtin auth namespace.
|
|
123
133
|
* The shared `schema_version` table records one row per applied migration
|
|
@@ -136,7 +146,8 @@ export interface CreateAppBackendOptions {
|
|
|
136
146
|
*
|
|
137
147
|
* Calls `create_db` → `run_migrations` (auth namespace, then any
|
|
138
148
|
* `migration_namespaces` from options in order) → `audit_factory({db, log})`
|
|
139
|
-
* and bundles the result with the provided keyring and password deps
|
|
149
|
+
* and bundles the result with the provided keyring and password deps and a
|
|
150
|
+
* `connection_closer` (empty until `create_app_server` adds its transports).
|
|
140
151
|
*
|
|
141
152
|
* @param options - keyring, password deps, `audit_factory`, optional database URL, and optional `migration_namespaces`
|
|
142
153
|
* @returns app backend with deps, database metadata, and combined migration results
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEjD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAkB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIjG;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,oIAAoI;IACpI,QAAQ,CAAC,iBAAiB,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC3D,iEAAiE;IACjE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE;IAAE,EAAE,EAAE,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,KAAK,YAAY,CAAC;AAE7E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,EAAE,YACF,CAAC;AAEnC;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACvC;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;IACxD,qBAAqB;IACrB,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,MAAM,CAAC;IACrB,wCAAwC;IACxC,OAAO,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,EAAE,YAAY,CAAC;IAC5B;;;;;;;;;;OAUG;IACH,oBAAoB,CAAC,EAAE,aAAa,CAAC,kBAAkB,CAAC,CAAC;CACzD;AAED
|
|
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
|
}
|