@fuzdev/fuz_app 0.145.0 → 0.146.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 +7 -6
- package/dist/auth/CLAUDE.md +5 -2
- package/dist/auth/account_action_specs.d.ts.map +1 -1
- package/dist/auth/account_action_specs.js +2 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +9 -2
- package/dist/auth/account_routes.d.ts +8 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +12 -3
- package/dist/auth/request_context.d.ts +23 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +32 -1
- package/dist/auth/token_scope.d.ts +22 -14
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +23 -14
- package/dist/http/CLAUDE.md +2 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +11 -6
- package/dist/http/proxy.d.ts +22 -21
- package/dist/http/proxy.d.ts.map +1 -1
- package/dist/http/proxy.js +28 -33
- package/dist/http/route_spec.d.ts +8 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +1 -1
- package/dist/server/fact_security_headers.d.ts +5 -0
- package/dist/server/fact_security_headers.d.ts.map +1 -1
- package/dist/server/fact_security_headers.js +5 -0
- package/dist/server/serve_fact_route.d.ts +12 -7
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +30 -23
- package/dist/server/x_accel.d.ts +37 -9
- package/dist/server/x_accel.d.ts.map +1 -1
- package/dist/server/x_accel.js +136 -19
- package/dist/testing/CLAUDE.md +73 -22
- package/dist/testing/cross_backend/capabilities.d.ts +0 -18
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +1 -4
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +0 -7
- package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.js +85 -12
- package/dist/testing/cross_backend/identity_parity.d.ts +11 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/identity_parity.js +124 -9
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +28 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts +70 -0
- package/dist/testing/cross_backend/revocation_drivers.d.ts.map +1 -0
- package/dist/testing/cross_backend/revocation_drivers.js +104 -0
- package/dist/testing/cross_backend/sse_round_trip.d.ts +6 -1
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +52 -16
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/token_scope_surface.js +33 -0
- package/dist/testing/cross_backend/ws_round_trip.d.ts +13 -1
- package/dist/testing/cross_backend/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/ws_round_trip.js +155 -39
- package/package.json +1 -1
|
@@ -34,12 +34,17 @@ export interface CrossProcessSseTestOptions {
|
|
|
34
34
|
* skips the case.
|
|
35
35
|
*/
|
|
36
36
|
readonly max_per_scope?: number | null;
|
|
37
|
+
/** REST logout route path, for the logout close case. Default `/api/account/logout`. */
|
|
38
|
+
readonly logout_path?: string;
|
|
39
|
+
/** REST password-change route path, for its close case. Default `/api/account/password`. */
|
|
40
|
+
readonly password_path?: string;
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
43
|
* Register the cross-process SSE round-trip suite. Its cases run over a
|
|
40
44
|
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
41
45
|
* close-on-revoke, session-scoped close-on-revoke, close on account delete,
|
|
42
|
-
*
|
|
46
|
+
* close on admin token revoke-all, logout, password change and purge, and the
|
|
47
|
+
* per-session cap.
|
|
43
48
|
*/
|
|
44
49
|
export declare const describe_cross_process_sse_tests: (options: CrossProcessSseTestOptions) => void;
|
|
45
50
|
//# sourceMappingURL=sse_round_trip.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"sse_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/sse_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAkF9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAEtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAK5C,4DAA4D;AAC5D,MAAM,WAAW,0BAA0B;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6EAA6E;IAC7E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,wFAAwF;IACxF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,4FAA4F;IAC5F,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CAChC;AA0BD;;;;;;GAMG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IAyStF,CAAC"}
|
|
@@ -35,18 +35,21 @@ import '../assert_dev_env.js';
|
|
|
35
35
|
* `capabilities.account_lifecycle`) — the keeper soft-deletes a second
|
|
36
36
|
* admin whose stream is open; the delete revokes its credentials and
|
|
37
37
|
* closes its stream.
|
|
38
|
-
* 6. **
|
|
38
|
+
* 6. **close on the other account-wide revocations** (gated on `rpc_path`;
|
|
39
|
+
* purge also on `capabilities.account_lifecycle`) — `admin_token_revoke_all`,
|
|
40
|
+
* `POST /logout`, `POST /password`, and `account_purge` of a second admin
|
|
41
|
+
* each close its stream — the rows `create_account_revocations` shares with
|
|
42
|
+
* the WS suite.
|
|
43
|
+
* 7. **per-session cap** — one stream past `max_per_scope` on one session
|
|
39
44
|
* closes the oldest (evict-oldest, no final frame) and leaves every other
|
|
40
45
|
* stream open. Needs no RPC: the cap runs inside the subscribe itself.
|
|
41
46
|
*
|
|
42
|
-
* The close-on-revoke matrix is layered: cases 3–
|
|
43
|
-
* and session-scoped paths cross-process;
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
|
|
47
|
-
* tests and the in-process guard self-test
|
|
48
|
-
* `token_revoke_all`-with-zero-tokens case (which may emit no audit row) stays
|
|
49
|
-
* out to keep the spawned-backend suite non-flaky.
|
|
47
|
+
* The close-on-revoke matrix is layered: cases 3–6 exercise the account-wide
|
|
48
|
+
* and session-scoped paths cross-process; `token_revoke` (token-scoped — the
|
|
49
|
+
* stock stream admits sessions only, so its bearer close is covered on the
|
|
50
|
+
* WebSocket by `describe_cross_process_ws_tests`) and `role_grant_revoke`
|
|
51
|
+
* (role-matched) are covered by the spine's `fuz_realtime` SSE-registry unit
|
|
52
|
+
* tests and the in-process guard self-test.
|
|
50
53
|
*
|
|
51
54
|
* Gated on `capabilities.sse` — backends without an end-to-end SSE stream
|
|
52
55
|
* skip (the cases still surface as `.skip` in the report). Cross-process
|
|
@@ -63,7 +66,9 @@ import { AUDIT_LOG_SSE_MAX_PER_SCOPE, audit_log_event_specs } from '../../realti
|
|
|
63
66
|
import { SSE_CONNECTED_COMMENT } from '../../realtime/sse_constants.js';
|
|
64
67
|
import { create_sse_transport } from '../transports/sse_transport.js';
|
|
65
68
|
import { create_rpc_post_init } from '../rpc_helpers.js';
|
|
69
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
66
70
|
import { test_if } from './capabilities.js';
|
|
71
|
+
import { create_account_revocations } from './revocation_drivers.js';
|
|
67
72
|
/** Default audit-log SSE stream path — the standard fuz_app `/api/admin/audit/stream`. */
|
|
68
73
|
const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
|
|
69
74
|
/**
|
|
@@ -85,10 +90,11 @@ const assert_audit_data_frame = (frame) => {
|
|
|
85
90
|
* Register the cross-process SSE round-trip suite. Its cases run over a
|
|
86
91
|
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
87
92
|
* close-on-revoke, session-scoped close-on-revoke, close on account delete,
|
|
88
|
-
*
|
|
93
|
+
* close on admin token revoke-all, logout, password change and purge, and the
|
|
94
|
+
* per-session cap.
|
|
89
95
|
*/
|
|
90
96
|
export const describe_cross_process_sse_tests = (options) => {
|
|
91
|
-
const { setup_test, capabilities, base_url, rpc_path, origin } = options;
|
|
97
|
+
const { setup_test, capabilities, base_url, rpc_path, origin, logout_path = '/api/account/logout', password_path = '/api/account/password' } = options;
|
|
92
98
|
const sse_path = options.sse_path ?? DEFAULT_SSE_PATH;
|
|
93
99
|
const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
|
|
94
100
|
describe('cross-process sse', () => {
|
|
@@ -166,11 +172,8 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
166
172
|
// TS registry's `close_by_identity(session_hash)`). The keeper holds
|
|
167
173
|
// exactly one session, so revoking it by its blake3 hash drops the stream
|
|
168
174
|
// opened under it. This is the close-on-revoke path the account-wide case
|
|
169
|
-
// above doesn't exercise;
|
|
170
|
-
//
|
|
171
|
-
// share the close the `session_revoke_all` and account-delete cases cover,
|
|
172
|
-
// and `token_revoke`'s token-scoped and `role_grant_revoke`'s role-matched
|
|
173
|
-
// closes are covered by `fuz_realtime`'s SSE registry unit tests.
|
|
175
|
+
// above doesn't exercise; `role_grant_revoke`'s role-matched close is
|
|
176
|
+
// covered by `fuz_realtime`'s SSE registry unit tests.
|
|
174
177
|
test_if(capabilities.sse && rpc_path !== undefined, 'stream closes on a single session_revoke of the subscriber session', async () => {
|
|
175
178
|
const fixture = await setup_test();
|
|
176
179
|
const sse = await create_sse_transport({
|
|
@@ -223,6 +226,39 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
223
226
|
await sse.close();
|
|
224
227
|
}
|
|
225
228
|
});
|
|
229
|
+
// The account-wide revocations past session revoke-all and delete
|
|
230
|
+
// (`revocation_drivers.ts`, shared with the WS suite) each close the
|
|
231
|
+
// target admin's stream. Without `rpc_path` the rows still register, as
|
|
232
|
+
// skipped cases, and never run — so the empty path is never sent.
|
|
233
|
+
const revocations = create_account_revocations({
|
|
234
|
+
capabilities,
|
|
235
|
+
rpc_path: rpc_path ?? '',
|
|
236
|
+
logout_path,
|
|
237
|
+
password_path
|
|
238
|
+
});
|
|
239
|
+
for (const { label, slug, gate, revoke } of revocations) {
|
|
240
|
+
test_if(capabilities.sse && gate && rpc_path !== undefined, `stream closes on ${label} of the subscriber account`, async () => {
|
|
241
|
+
const fixture = await setup_test();
|
|
242
|
+
const target = await fixture.create_account({
|
|
243
|
+
username: `sse_revoke_${slug}`,
|
|
244
|
+
password_value: DEFAULT_TEST_PASSWORD,
|
|
245
|
+
roles: [ROLE_ADMIN]
|
|
246
|
+
});
|
|
247
|
+
const cookie = target.create_session_headers().cookie;
|
|
248
|
+
assert.ok(cookie, 'expected a session cookie for the target account');
|
|
249
|
+
const sse = await create_sse_transport({ base_url, sse_path, cookies: [cookie], origin });
|
|
250
|
+
try {
|
|
251
|
+
const first = await sse.read_frame();
|
|
252
|
+
assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, 'first frame must be the connected comment');
|
|
253
|
+
await revoke(fixture, target);
|
|
254
|
+
const closed = await sse.wait_for_close(2000);
|
|
255
|
+
assert.ok(closed, `stream did not close within 2s after ${label}`);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await sse.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
226
262
|
// One stream past the cap on one session → the oldest closes, the newest
|
|
227
263
|
// stays open. Opened in sequence, each awaited to its connect comment, so
|
|
228
264
|
// the server registered them in order and "oldest" is stream 0 on both
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token_scope_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/token_scope_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"token_scope_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/token_scope_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAgF9B,OAAO,KAAK,EAAE,uCAAuC,EAAE,MAAM,YAAY,CAAC;AAqB1E,wDAAwD;AACxD,MAAM,WAAW,iCAAkC,SAAQ,uCAAuC;IACjG,6DAA6D;IAC7D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC3B;AA6DD,eAAO,MAAM,wCAAwC,GACpD,SAAS,iCAAiC,KACxC,IA+JF,CAAC"}
|
|
@@ -35,6 +35,11 @@ import '../assert_dev_env.js';
|
|
|
35
35
|
* `surface:audit_stream`'s own capability string is consequently unreachable
|
|
36
36
|
* on the default mount and has no cross-impl pin — each spine's census holds
|
|
37
37
|
* its declaration, the way the WS upgrade's is held.
|
|
38
|
+
* - **The account status read answers a narrowed token as anonymous** —
|
|
39
|
+
* `GET /api/account/status` answers anonymous callers, so rule 3 there is the
|
|
40
|
+
* anonymous answer (`401` plus the bootstrap flag), byte for byte, never the
|
|
41
|
+
* account's email, its verification, or its role grants. A full-scope bearer
|
|
42
|
+
* reading the account is the control.
|
|
38
43
|
* - **Scope outranks role** — a narrowed token whose account *also* lacks the
|
|
39
44
|
* gating role must hear `token_scope_required`, not
|
|
40
45
|
* `insufficient_permissions`. This is the observable the TS↔Rust ordering
|
|
@@ -70,6 +75,8 @@ import { mint_narrowed_api_token } from './narrowed_api_token.js';
|
|
|
70
75
|
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
71
76
|
/** Default audit-log SSE path — the `audit_stream` surface. */
|
|
72
77
|
const DEFAULT_SSE_PATH = '/api/admin/audit/stream';
|
|
78
|
+
/** The account status read, which answers a narrowed token as anonymous. */
|
|
79
|
+
const STATUS_PATH = '/api/account/status';
|
|
73
80
|
/**
|
|
74
81
|
* A well-formed hash that no fact has. The scope gate is a pre-authorization
|
|
75
82
|
* guard, so it fires before the handler ever looks the hash up — but *params*
|
|
@@ -172,6 +179,32 @@ export const describe_token_scope_surface_cross_tests = (options) => {
|
|
|
172
179
|
const body = (await res.json().catch(() => undefined));
|
|
173
180
|
assert.notStrictEqual(body?.error, ERROR_TOKEN_SCOPE_REQUIRED, 'a full-authority bearer must not be refused on scope grounds');
|
|
174
181
|
});
|
|
182
|
+
/**
|
|
183
|
+
* The status read answers anonymous callers, so a narrowed token is
|
|
184
|
+
* answered as one — byte for byte, status and body — rather than refused.
|
|
185
|
+
* The full-scope bearer of the same account reads the account, so the
|
|
186
|
+
* 401 is the scope, not the bearer.
|
|
187
|
+
*/
|
|
188
|
+
test_if(capabilities.account_status, 'a narrowed token reads the account status as the anonymous caller', async () => {
|
|
189
|
+
const fixture = await setup_test();
|
|
190
|
+
const token = await mint_narrowed_api_token({
|
|
191
|
+
app: fixture.transport,
|
|
192
|
+
rpc_path,
|
|
193
|
+
session_headers: fixture.create_session_headers(),
|
|
194
|
+
methods: [ADMITTED_METHOD]
|
|
195
|
+
});
|
|
196
|
+
const anonymous = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {});
|
|
197
|
+
const narrowed = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {
|
|
198
|
+
headers: { authorization: `Bearer ${token}` }
|
|
199
|
+
});
|
|
200
|
+
assert.strictEqual(anonymous.status, 401, 'anonymous status must be 401');
|
|
201
|
+
assert.strictEqual(narrowed.status, 401, 'a narrowed token must read status as anonymous');
|
|
202
|
+
assert.strictEqual(await narrowed.text(), await anonymous.text(), 'the narrowed answer must be the anonymous one, byte for byte');
|
|
203
|
+
const full = await fixture.fresh_transport({ origin: null })(STATUS_PATH, {
|
|
204
|
+
headers: fixture.create_bearer_headers()
|
|
205
|
+
});
|
|
206
|
+
assert.strictEqual(full.status, 200, 'a full-scope bearer must read the account');
|
|
207
|
+
});
|
|
175
208
|
/**
|
|
176
209
|
* **Scope outranks role**, and this is the case that had no cross-impl
|
|
177
210
|
* pin while the two spines disagreed about it.
|
|
@@ -31,13 +31,25 @@ export interface CrossProcessWsTestOptions {
|
|
|
31
31
|
* the server cap.
|
|
32
32
|
*/
|
|
33
33
|
readonly max_message_bytes?: number;
|
|
34
|
+
/** REST logout route path, for the logout close case. Default `/api/account/logout`. */
|
|
35
|
+
readonly logout_path?: string;
|
|
36
|
+
/** REST password-change route path, for its close case. Default `/api/account/password`. */
|
|
37
|
+
readonly password_path?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The backend's per-account API token cap, which the token-cap close case
|
|
40
|
+
* mints past. Defaults to `DEFAULT_MAX_TOKENS`; `null` (a backend with the
|
|
41
|
+
* cap disabled) skips the case.
|
|
42
|
+
*/
|
|
43
|
+
readonly max_tokens?: number | null;
|
|
34
44
|
}
|
|
35
45
|
/**
|
|
36
46
|
* Register the cross-process WS round-trip suite over a real upgrade: authed
|
|
37
47
|
* `heartbeat` round-trip, `heartbeat`'s parameterless-shape contract, an
|
|
38
48
|
* oversized message closing with `WS_CLOSE_MESSAGE_TOO_BIG`, anonymous-upgrade
|
|
39
49
|
* refusal, disallowed-origin refusal, and — when `rpc_path` is supplied —
|
|
40
|
-
*
|
|
50
|
+
* each listed revocation (session and token revokes, admin revoke-all, logout,
|
|
51
|
+
* password change, token-cap eviction, account delete and purge) closing the
|
|
52
|
+
* live socket, plus a
|
|
41
53
|
* self-revoking socket reading its reply before its close, and a WS mutation's
|
|
42
54
|
* audit row read back after `_testing_drain_effects`.
|
|
43
55
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/ws_round_trip.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAyH9B,OAAO,EAAE,KAAK,mBAAmB,EAAW,MAAM,mBAAmB,CAAC;AAGtE,OAAO,KAAK,EAAE,SAAS,EAAmC,MAAM,YAAY,CAAC;AAK7E,2DAA2D;AAC3D,MAAM,WAAW,yBAAyB;IACzC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,uEAAuE;IACvE,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,2EAA2E;IAC3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,wFAAwF;IACxF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,4FAA4F;IAC5F,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,+BAA+B,GAAI,SAAS,yBAAyB,KAAG,IA4epF,CAAC"}
|
|
@@ -43,21 +43,30 @@ import '../assert_dev_env.js';
|
|
|
43
43
|
* server-initiated close.
|
|
44
44
|
* Per-message dispatch never re-checks credential validity, so an open socket
|
|
45
45
|
* ends only when a revocation closes it — which every revocation does, once
|
|
46
|
-
* its transaction commits
|
|
47
|
-
*
|
|
46
|
+
* its transaction commits, and every close here is asserted with
|
|
47
|
+
* `WS_CLOSE_SESSION_REVOKED` and `WS_CLOSE_SESSION_REVOKED_REASON`, so a
|
|
48
|
+
* dropped connection can't pass for one. An authenticated socket is dropped
|
|
49
|
+
* when the account's sessions are revoked over the keeper's session channel
|
|
48
50
|
* (`account_session_revoke_all`), and when its account is deleted
|
|
49
|
-
* (`account_delete`, also gated on `capabilities.account_lifecycle`).
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
* (`account_delete`, also gated on `capabilities.account_lifecycle`). The
|
|
52
|
+
* rest of the revocation table follows, each closing a second account's
|
|
53
|
+
* socket:
|
|
54
|
+
* `account_token_revoke` closes the revoked token's bearer socket and leaves
|
|
55
|
+
* a socket on the account's other token open; `admin_token_revoke_all`,
|
|
56
|
+
* `POST /logout`, `POST /password`, and `account_purge` (also gated on
|
|
57
|
+
* `capabilities.account_lifecycle`) close the account's bearer and session
|
|
58
|
+
* sockets — the rows `create_account_revocations` shares with the SSE suite;
|
|
59
|
+
* and a token the per-account cap evicts (`max_tokens` further mints) has its
|
|
60
|
+
* bearer socket closed. Omit `rpc_path` to skip them
|
|
61
|
+
* (consumers without the standard account and admin actions on their RPC
|
|
62
|
+
* endpoint).
|
|
52
63
|
*
|
|
53
64
|
* The last cases are gated on `capabilities.ws_account_actions` — the backend's
|
|
54
65
|
* WS endpoint mounts the self-service account actions: a socket that revokes
|
|
55
66
|
* its own sessions *over that socket* is closed with
|
|
56
|
-
* `WS_CLOSE_SESSION_REVOKED
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* ahead of it, so the caller is never left without an answer; the Rust spine
|
|
60
|
-
* does not answer yet, which that flag records. Under the same gate (and
|
|
67
|
+
* `WS_CLOSE_SESSION_REVOKED` after reading the reply to its request — both
|
|
68
|
+
* spines answer the revoking request ahead of the close, so the caller is
|
|
69
|
+
* never left without an answer. Under the same gate (and
|
|
61
70
|
* `rpc_path`), a socket mints an API token over WS and the token's
|
|
62
71
|
* `token_create` audit row is read back after `_testing_drain_effects` — the
|
|
63
72
|
* barrier a WS mutation needs, since a WS reply can precede its audit write.
|
|
@@ -73,16 +82,19 @@ import '../assert_dev_env.js';
|
|
|
73
82
|
import { assert, describe } from 'vitest';
|
|
74
83
|
import { assert_rejects } from '@fuzdev/fuz_util/testing.ts';
|
|
75
84
|
import { heartbeat_action_spec } from '../../actions/heartbeat.js';
|
|
76
|
-
import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_SESSION_REVOKED } from '../../actions/transports.js';
|
|
77
|
-
import { account_session_revoke_all_action_spec, account_token_create_action_spec } from '../../auth/account_action_specs.js';
|
|
85
|
+
import { DEFAULT_WS_MAX_MESSAGE_BYTES, WS_CLOSE_MESSAGE_TOO_BIG, WS_CLOSE_SESSION_REVOKED, WS_CLOSE_SESSION_REVOKED_REASON } from '../../actions/transports.js';
|
|
86
|
+
import { account_session_revoke_all_action_spec, account_token_create_action_spec, account_token_revoke_action_spec, TokenCreateOutput, TokenRevokeOutput } from '../../auth/account_action_specs.js';
|
|
87
|
+
import { DEFAULT_MAX_TOKENS } from '../../auth/account_route_schema.js';
|
|
78
88
|
import { account_delete_action_spec, audit_log_list_action_spec, AuditLogListOutput } from '../../auth/admin_action_specs.js';
|
|
79
89
|
import { JSONRPC_ERROR_CODES } from '../../http/jsonrpc_errors.js';
|
|
80
90
|
import { is_response_for } from '../transports/ws_client.js';
|
|
81
|
-
import { create_ws_transport } from '../transports/ws_transport.js';
|
|
91
|
+
import { create_admitted_ws_transport, create_ws_transport } from '../transports/ws_transport.js';
|
|
82
92
|
import { connect_raw_ws, is_raw_ws_close, RAW_WS_OPCODE_TEXT } from '../transports/ws_raw_client.js';
|
|
83
93
|
import { create_rpc_post_init } from '../rpc_helpers.js';
|
|
94
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
84
95
|
import { test_if } from './capabilities.js';
|
|
85
96
|
import { cross_rpc_call, expect_output } from './cell_cross_helpers.js';
|
|
97
|
+
import { create_account_revocations } from './revocation_drivers.js';
|
|
86
98
|
/** Origin guaranteed to fail the `http://localhost:*` allowlist the test backends run with. */
|
|
87
99
|
const DISALLOWED_ORIGIN = 'http://disallowed.example';
|
|
88
100
|
/**
|
|
@@ -90,12 +102,15 @@ const DISALLOWED_ORIGIN = 'http://disallowed.example';
|
|
|
90
102
|
* `heartbeat` round-trip, `heartbeat`'s parameterless-shape contract, an
|
|
91
103
|
* oversized message closing with `WS_CLOSE_MESSAGE_TOO_BIG`, anonymous-upgrade
|
|
92
104
|
* refusal, disallowed-origin refusal, and — when `rpc_path` is supplied —
|
|
93
|
-
*
|
|
105
|
+
* each listed revocation (session and token revokes, admin revoke-all, logout,
|
|
106
|
+
* password change, token-cap eviction, account delete and purge) closing the
|
|
107
|
+
* live socket, plus a
|
|
94
108
|
* self-revoking socket reading its reply before its close, and a WS mutation's
|
|
95
109
|
* audit row read back after `_testing_drain_effects`.
|
|
96
110
|
*/
|
|
97
111
|
export const describe_cross_process_ws_tests = (options) => {
|
|
98
|
-
const { setup_test, capabilities, base_url, ws_path, origin, rpc_path, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES } = options;
|
|
112
|
+
const { setup_test, capabilities, base_url, ws_path, origin, rpc_path, max_message_bytes = DEFAULT_WS_MAX_MESSAGE_BYTES, logout_path = '/api/account/logout', password_path = '/api/account/password' } = options;
|
|
113
|
+
const max_tokens = options.max_tokens === undefined ? DEFAULT_MAX_TOKENS : options.max_tokens;
|
|
99
114
|
const open_authed = async () => {
|
|
100
115
|
const fixture = await setup_test();
|
|
101
116
|
return create_ws_transport({ base_url, ws_path, cookies: fixture.transport.cookies(), origin });
|
|
@@ -229,6 +244,54 @@ export const describe_cross_process_ws_tests = (options) => {
|
|
|
229
244
|
test_if(capabilities.ws, 'disallowed-origin upgrade is refused', async () => {
|
|
230
245
|
await assert_rejects(() => create_ws_transport({ base_url, ws_path, cookies: [], origin: DISALLOWED_ORIGIN }));
|
|
231
246
|
});
|
|
247
|
+
// Every revocation closes with 4001 "Session revoked" — the code and its
|
|
248
|
+
// reason, so a dropped connection (1006) or a crash can't pass for one.
|
|
249
|
+
const assert_revoked_close = async (client, label) => {
|
|
250
|
+
assert.ok(await client.wait_for_close(2000), `socket did not close within 2s after ${label}`);
|
|
251
|
+
assert.strictEqual(client.close_code, WS_CLOSE_SESSION_REVOKED, label);
|
|
252
|
+
assert.strictEqual(client.close_reason, WS_CLOSE_SESSION_REVOKED_REASON, label);
|
|
253
|
+
};
|
|
254
|
+
// A bearer socket opens outside a browser context (no Origin, no cookie),
|
|
255
|
+
// as a CLI client's would.
|
|
256
|
+
const open_bearer = (token) => create_admitted_ws_transport({
|
|
257
|
+
base_url,
|
|
258
|
+
ws_path,
|
|
259
|
+
cookies: [],
|
|
260
|
+
origin: null,
|
|
261
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
262
|
+
});
|
|
263
|
+
const open_session = (account) => {
|
|
264
|
+
const { cookie } = account.create_session_headers();
|
|
265
|
+
assert.ok(cookie, 'expected a session cookie');
|
|
266
|
+
return create_admitted_ws_transport({ base_url, ws_path, cookies: [cookie], origin });
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Open each socket in turn, run `fn` over them, and close every socket
|
|
270
|
+
* that opened — also when a later open fails, so a refused upgrade leaves
|
|
271
|
+
* no socket registered on the shared backend for the rest of the run.
|
|
272
|
+
*
|
|
273
|
+
* Every close is awaited whatever the others do. When the open or `fn`
|
|
274
|
+
* failed, that error propagates and a close error is dropped, so it never
|
|
275
|
+
* hides the case's own failure; when they succeeded, the first close error
|
|
276
|
+
* is thrown, so a transport error surfacing at close still fails the case.
|
|
277
|
+
*/
|
|
278
|
+
const with_sockets = async (opens, fn) => {
|
|
279
|
+
const clients = [];
|
|
280
|
+
try {
|
|
281
|
+
for (const open of opens)
|
|
282
|
+
clients.push(await open());
|
|
283
|
+
await fn(...clients);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
await Promise.allSettled(clients.map((c) => c.close()));
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
const closes = await Promise.allSettled(clients.map((c) => c.close()));
|
|
290
|
+
for (const close of closes) {
|
|
291
|
+
if (close.status === 'rejected')
|
|
292
|
+
throw close.reason;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
232
295
|
// Per-message dispatch never re-checks credential validity, so a live
|
|
233
296
|
// socket drops only when a revocation closes it. Revoke the keeper's
|
|
234
297
|
// sessions over its own session channel → the handler's post-commit
|
|
@@ -248,8 +311,7 @@ export const describe_cross_process_ws_tests = (options) => {
|
|
|
248
311
|
await client.request(1, heartbeat_action_spec.method, {});
|
|
249
312
|
const res = await fixture.transport(rpc_path, create_rpc_post_init(account_session_revoke_all_action_spec.method));
|
|
250
313
|
assert.strictEqual(res.status, 200, `account_session_revoke_all RPC failed (status=${res.status})`);
|
|
251
|
-
|
|
252
|
-
assert.ok(closed, 'socket did not close within 2s after session_revoke_all');
|
|
314
|
+
await assert_revoked_close(client, 'account_session_revoke_all');
|
|
253
315
|
}
|
|
254
316
|
finally {
|
|
255
317
|
await client.close();
|
|
@@ -273,22 +335,80 @@ export const describe_cross_process_ws_tests = (options) => {
|
|
|
273
335
|
account_id: target.account.id
|
|
274
336
|
}));
|
|
275
337
|
assert.strictEqual(res.status, 200, `account_delete RPC failed (status=${res.status})`);
|
|
276
|
-
|
|
277
|
-
assert.ok(closed, 'socket did not close within 2s after account_delete');
|
|
278
|
-
assert.strictEqual(client.close_code, WS_CLOSE_SESSION_REVOKED);
|
|
338
|
+
await assert_revoked_close(client, 'account_delete');
|
|
279
339
|
}
|
|
280
340
|
finally {
|
|
281
341
|
await client.close();
|
|
282
342
|
}
|
|
283
343
|
});
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
344
|
+
// The rest of the revocation table (docs/security.md §Closing on
|
|
345
|
+
// Revocation), one case per revocation, each closing a socket of a second
|
|
346
|
+
// account with 4001 "Session revoked".
|
|
347
|
+
/** Mint a token for `account` over its own session. */
|
|
348
|
+
const mint_token = async (fixture, account, name) => expect_output(await cross_rpc_call(fixture.fresh_transport(), rpc_path, account_token_create_action_spec.method, { name, scope: { kind: 'full' }, lifetime: { kind: 'eternal' } }, account.create_session_headers()), TokenCreateOutput);
|
|
349
|
+
// `account_token_revoke` closes the revoked token's connections and no
|
|
350
|
+
// others: a socket on the account's other token stays open.
|
|
351
|
+
test_if(capabilities.ws && rpc_path !== undefined, "account_token_revoke closes the revoked token's bearer socket only", async () => {
|
|
352
|
+
const fixture = await setup_test();
|
|
353
|
+
const target = await fixture.create_account({ username: 'ws_token_revoke' });
|
|
354
|
+
const minted = await mint_token(fixture, target, 'ws revoke probe');
|
|
355
|
+
await with_sockets([() => open_bearer(minted.token), () => open_bearer(target.api_token)], async (revoked, other) => {
|
|
356
|
+
const res = expect_output(await cross_rpc_call(fixture.fresh_transport(), rpc_path, account_token_revoke_action_spec.method, { token_id: minted.id }, target.create_session_headers()), TokenRevokeOutput);
|
|
357
|
+
assert.strictEqual(res.revoked, true);
|
|
358
|
+
await assert_revoked_close(revoked, 'account_token_revoke');
|
|
359
|
+
// the account's other token keeps its socket
|
|
360
|
+
assert.deepStrictEqual(await other.request(2, heartbeat_action_spec.method, {}), {});
|
|
361
|
+
assert.strictEqual(other.close_code, null);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
// The account-wide revocations (`revocation_drivers.ts`, shared with the
|
|
365
|
+
// SSE suite) close the account's connections — its bearer socket, and its
|
|
366
|
+
// session socket too.
|
|
367
|
+
// Without `rpc_path` the rows still register, as skipped cases, and never
|
|
368
|
+
// run — so the empty path is never sent.
|
|
369
|
+
const revocations = create_account_revocations({
|
|
370
|
+
capabilities,
|
|
371
|
+
rpc_path: rpc_path ?? '',
|
|
372
|
+
logout_path,
|
|
373
|
+
password_path
|
|
374
|
+
});
|
|
375
|
+
for (const { label, slug, gate, revoke } of revocations) {
|
|
376
|
+
test_if(capabilities.ws && gate && rpc_path !== undefined, `${label} closes the account's bearer and session sockets`, async () => {
|
|
377
|
+
const fixture = await setup_test();
|
|
378
|
+
const target = await fixture.create_account({
|
|
379
|
+
username: `ws_revoke_${slug}`,
|
|
380
|
+
password_value: DEFAULT_TEST_PASSWORD
|
|
381
|
+
});
|
|
382
|
+
await with_sockets([() => open_bearer(target.api_token), () => open_session(target)], async (bearer, session) => {
|
|
383
|
+
await revoke(fixture, target);
|
|
384
|
+
await assert_revoked_close(bearer, `${label} (bearer socket)`);
|
|
385
|
+
await assert_revoked_close(session, `${label} (session socket)`);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// `max_tokens` further mints leave the watched token outside the newest
|
|
390
|
+
// `max_tokens`, so the cap evicts it whatever tokens the account held
|
|
391
|
+
// before it, and its socket closes once the evicting mint commits.
|
|
392
|
+
test_if(capabilities.ws && rpc_path !== undefined && max_tokens !== null, 'a token the cap evicts has its bearer socket closed', async () => {
|
|
393
|
+
const fixture = await setup_test();
|
|
394
|
+
const target = await fixture.create_account({ username: 'ws_token_cap' });
|
|
395
|
+
const watched = await mint_token(fixture, target, 'ws cap victim');
|
|
396
|
+
const client = await open_bearer(watched.token);
|
|
397
|
+
try {
|
|
398
|
+
for (let i = 0; i < max_tokens; i++) {
|
|
399
|
+
await mint_token(fixture, target, `ws cap filler ${i}`);
|
|
400
|
+
}
|
|
401
|
+
await assert_revoked_close(client, `${max_tokens} further token mints`);
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
await client.close();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
// A socket that revokes the session it is running on. Its request is
|
|
408
|
+
// answered first, the close following from behind the revocation's
|
|
287
409
|
// commit. A caller left with only the close cannot tell a completed
|
|
288
410
|
// revocation from a dropped connection.
|
|
289
|
-
test_if(capabilities.ws && capabilities.ws_account_actions,
|
|
290
|
-
? 'a socket that revokes its own sessions reads the reply, then the revocation close'
|
|
291
|
-
: 'a socket that revokes its own sessions is closed with the revocation code', async () => {
|
|
411
|
+
test_if(capabilities.ws && capabilities.ws_account_actions, 'a socket that revokes its own sessions reads the reply, then the revocation close', async () => {
|
|
292
412
|
const client = await open_authed();
|
|
293
413
|
try {
|
|
294
414
|
await client.request(1, heartbeat_action_spec.method, {});
|
|
@@ -298,19 +418,15 @@ export const describe_cross_process_ws_tests = (options) => {
|
|
|
298
418
|
id: revoke_id,
|
|
299
419
|
method: account_session_revoke_all_action_spec.method
|
|
300
420
|
});
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const result = reply.result;
|
|
311
|
-
assert.strictEqual(result.ok, true);
|
|
312
|
-
assert.ok((result.count ?? 0) >= 1, "at least the socket's own session was revoked");
|
|
313
|
-
}
|
|
421
|
+
await assert_revoked_close(client, 'revoking its own sessions');
|
|
422
|
+
// frames arrive in wire order and nothing arrives after the
|
|
423
|
+
// close, so a reply in hand was written ahead of it
|
|
424
|
+
const reply = client.messages.find(is_response_for(revoke_id));
|
|
425
|
+
assert.ok(reply, 'the reply must reach the caller before the close');
|
|
426
|
+
assert.ok('result' in reply, JSON.stringify(reply));
|
|
427
|
+
const result = reply.result;
|
|
428
|
+
assert.strictEqual(result.ok, true);
|
|
429
|
+
assert.ok((result.count ?? 0) >= 1, "at least the socket's own session was revoked");
|
|
314
430
|
}
|
|
315
431
|
finally {
|
|
316
432
|
await client.close();
|