@fuzdev/fuz_app 0.71.1 → 0.72.1
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/realtime/sse_auth_guard.d.ts +14 -5
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +16 -5
- package/dist/testing/cross_backend/sse_round_trip.d.ts +3 -3
- package/dist/testing/cross_backend/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/cross_backend/sse_round_trip.js +55 -8
- package/package.json +7 -1
|
@@ -17,15 +17,23 @@ import type { SseStream, SseNotification, EventSpec } from './sse.js';
|
|
|
17
17
|
/** SSE channel the audit-log stream route publishes on. */
|
|
18
18
|
export declare const AUDIT_LOG_CHANNEL = "audit_log";
|
|
19
19
|
/**
|
|
20
|
-
* Audit event types that trigger SSE stream disconnection
|
|
20
|
+
* Audit event types that trigger SSE stream disconnection — the union of
|
|
21
|
+
* access-invalidation events. Over-closing a one-way admin feed is cheap (the
|
|
22
|
+
* client reconnects if still authorized), so the SSE set is the full union.
|
|
21
23
|
*
|
|
22
24
|
* `role_grant_revoke` requires the revoked role to match the guard's `required_role`
|
|
23
25
|
* (or is skipped entirely when `required_role` is `null` — useful for streams
|
|
24
|
-
* not gated by any specific role_grant).
|
|
25
|
-
*
|
|
26
|
+
* not gated by any specific role_grant). The WS half deliberately omits this
|
|
27
|
+
* event (per-message re-authorization picks role changes up there); a one-way
|
|
28
|
+
* SSE stream has no per-message recheck, so it must close here.
|
|
29
|
+
* `session_revoke_all` / `token_revoke_all` / `password_change` / `logout` close
|
|
30
|
+
* every stream for the target account.
|
|
26
31
|
* `session_revoke` closes only the stream tied to the specific revoked session
|
|
27
32
|
* (matched by the blake3 session hash in `event.metadata.session_id`) — closing
|
|
28
33
|
* all of a user's streams for a single-session revoke would be over-aggressive.
|
|
34
|
+
* The single `token_revoke` the WS half handles is omitted: an SSE stream is
|
|
35
|
+
* opened under a cookie session, never an API token, so no stream is keyed by a
|
|
36
|
+
* single token id.
|
|
29
37
|
*/
|
|
30
38
|
export declare const disconnect_event_types: ReadonlySet<string>;
|
|
31
39
|
/**
|
|
@@ -33,8 +41,9 @@ export declare const disconnect_event_types: ReadonlySet<string>;
|
|
|
33
41
|
*
|
|
34
42
|
* Closes streams when:
|
|
35
43
|
* - `role_grant_revoke` fires for the `required_role` targeting a connected subscriber
|
|
36
|
-
* - `
|
|
37
|
-
* - `
|
|
44
|
+
* - `session_revoke` targets the specific revoked session (session-hash-scoped)
|
|
45
|
+
* - `session_revoke_all` / `token_revoke_all` / `password_change` / `logout`
|
|
46
|
+
* target a connected subscriber (account-wide)
|
|
38
47
|
*
|
|
39
48
|
* The registry must use `account_id` as the identity key when subscribing
|
|
40
49
|
* (passed as the third argument to `registry.subscribe()`).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAGN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAC,kBAAkB,EAAE,KAAK,gBAAgB,EAAC,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAC,SAAS,EAAE,eAAe,EAAE,SAAS,EAAC,MAAM,UAAU,CAAC;AAEpE,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C
|
|
1
|
+
{"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAGN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAC,kBAAkB,EAAE,KAAK,gBAAgB,EAAC,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAC,SAAS,EAAE,eAAe,EAAE,SAAS,EAAC,MAAM,UAAU,CAAC;AAEpE,2DAA2D;AAC3D,eAAO,MAAM,iBAAiB,cAAc,CAAC;AAE7C;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAOrD,CAAC;AAEH;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA6CjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,8FAA8F;IAC9F,SAAS,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;IAC1F,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,yJAAyJ;IACzJ,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C,yEAAyE;IACzE,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;CAC9C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS;IAC7C,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B,KAAG,WAgBH,CAAC"}
|
|
@@ -15,29 +15,40 @@ import { SubscriberRegistry } from './subscriber_registry.js';
|
|
|
15
15
|
/** SSE channel the audit-log stream route publishes on. */
|
|
16
16
|
export const AUDIT_LOG_CHANNEL = 'audit_log';
|
|
17
17
|
/**
|
|
18
|
-
* Audit event types that trigger SSE stream disconnection
|
|
18
|
+
* Audit event types that trigger SSE stream disconnection — the union of
|
|
19
|
+
* access-invalidation events. Over-closing a one-way admin feed is cheap (the
|
|
20
|
+
* client reconnects if still authorized), so the SSE set is the full union.
|
|
19
21
|
*
|
|
20
22
|
* `role_grant_revoke` requires the revoked role to match the guard's `required_role`
|
|
21
23
|
* (or is skipped entirely when `required_role` is `null` — useful for streams
|
|
22
|
-
* not gated by any specific role_grant).
|
|
23
|
-
*
|
|
24
|
+
* not gated by any specific role_grant). The WS half deliberately omits this
|
|
25
|
+
* event (per-message re-authorization picks role changes up there); a one-way
|
|
26
|
+
* SSE stream has no per-message recheck, so it must close here.
|
|
27
|
+
* `session_revoke_all` / `token_revoke_all` / `password_change` / `logout` close
|
|
28
|
+
* every stream for the target account.
|
|
24
29
|
* `session_revoke` closes only the stream tied to the specific revoked session
|
|
25
30
|
* (matched by the blake3 session hash in `event.metadata.session_id`) — closing
|
|
26
31
|
* all of a user's streams for a single-session revoke would be over-aggressive.
|
|
32
|
+
* The single `token_revoke` the WS half handles is omitted: an SSE stream is
|
|
33
|
+
* opened under a cookie session, never an API token, so no stream is keyed by a
|
|
34
|
+
* single token id.
|
|
27
35
|
*/
|
|
28
36
|
export const disconnect_event_types = new Set([
|
|
29
37
|
'role_grant_revoke', // role revoked — user lost access
|
|
30
38
|
'session_revoke', // single session revoked — close only that stream
|
|
31
39
|
'session_revoke_all', // all sessions invalidated — user should be kicked
|
|
40
|
+
'token_revoke_all', // all API tokens invalidated — close the account's streams
|
|
32
41
|
'password_change', // password changed — all sessions revoked implicitly
|
|
42
|
+
'logout', // explicit logout — close the account's streams
|
|
33
43
|
]);
|
|
34
44
|
/**
|
|
35
45
|
* Create an audit event handler that closes SSE streams on auth changes.
|
|
36
46
|
*
|
|
37
47
|
* Closes streams when:
|
|
38
48
|
* - `role_grant_revoke` fires for the `required_role` targeting a connected subscriber
|
|
39
|
-
* - `
|
|
40
|
-
* - `
|
|
49
|
+
* - `session_revoke` targets the specific revoked session (session-hash-scoped)
|
|
50
|
+
* - `session_revoke_all` / `token_revoke_all` / `password_change` / `logout`
|
|
51
|
+
* target a connected subscriber (account-wide)
|
|
41
52
|
*
|
|
42
53
|
* The registry must use `account_id` as the identity key when subscribing
|
|
43
54
|
* (passed as the third argument to `registry.subscribe()`).
|
|
@@ -29,9 +29,9 @@ export interface CrossProcessSseTestOptions {
|
|
|
29
29
|
readonly origin?: string;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* Register the cross-process SSE round-trip suite. Up to
|
|
33
|
-
* real streaming `fetch`: connected-comment, audit data frame,
|
|
34
|
-
* close-on-revoke.
|
|
32
|
+
* Register the cross-process SSE round-trip suite. Up to four cases over a
|
|
33
|
+
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
34
|
+
* close-on-revoke, and session-scoped close-on-revoke.
|
|
35
35
|
*/
|
|
36
36
|
export declare const describe_cross_process_sse_tests: (options: CrossProcessSseTestOptions) => void;
|
|
37
37
|
//# 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;AA+D9B,OAAO,EAAC,KAAK,mBAAmB,EAAU,MAAM,mBAAmB,CAAC;AACpE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAK1C,kEAAkE;AAClE,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;CACzB;AA0BD;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IAqKtF,CAAC"}
|
|
@@ -11,7 +11,7 @@ import '../assert_dev_env.js';
|
|
|
11
11
|
* (`describe_standard_cross_process_tests`) omits SSE by design, so consumers
|
|
12
12
|
* call this alongside it (paralleling `describe_cross_process_ws_tests`).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
14
|
+
* Four cases, mirroring the in-process SSE self-test against fuz_app's
|
|
15
15
|
* standard audit-log stream:
|
|
16
16
|
*
|
|
17
17
|
* 1. **connects** — the stream opens and emits the `: connected` comment.
|
|
@@ -22,10 +22,23 @@ import '../assert_dev_env.js';
|
|
|
22
22
|
* the secondary, not the subscriber). The secondary is minted *before* the
|
|
23
23
|
* stream opens so `create_account`'s own audit events (invite / signup /
|
|
24
24
|
* login / token) don't land on it.
|
|
25
|
-
* 3. **close-on-revoke** (gated on `rpc_path`) — the subscriber's
|
|
26
|
-
* sessions are revoked (`account_session_revoke_all`), so the
|
|
25
|
+
* 3. **close-on-revoke, account-wide** (gated on `rpc_path`) — the subscriber's
|
|
26
|
+
* *own* sessions are revoked (`account_session_revoke_all`), so the
|
|
27
27
|
* `session_revoke_all` event targets the keeper and the audit guard drops
|
|
28
|
-
* the live stream
|
|
28
|
+
* the live stream via the account-wide `close_for_account` path. Asserted
|
|
29
|
+
* via `SseTransport.wait_for_close`.
|
|
30
|
+
* 4. **close-on-revoke, session-scoped** (gated on `rpc_path`) — the
|
|
31
|
+
* subscriber's *own* single session is revoked (`account_session_revoke`),
|
|
32
|
+
* so the `session_revoke` event drops the stream via the session-hash-scoped
|
|
33
|
+
* `close_for_session` path (the distinct primitive cases 2–3 don't reach).
|
|
34
|
+
*
|
|
35
|
+
* The close-on-revoke matrix is layered: cases 3–4 exercise the account-wide
|
|
36
|
+
* and session-scoped paths cross-process; the remaining union events
|
|
37
|
+
* (`token_revoke_all` / `logout` / `password_change`, all account-wide; and
|
|
38
|
+
* `role_grant_revoke`, role-matched) are covered by the spine's `fuz_realtime`
|
|
39
|
+
* SSE-registry unit tests and the in-process guard self-test, so a cross-process
|
|
40
|
+
* `token_revoke_all`-with-zero-tokens case (which may emit no audit row) stays
|
|
41
|
+
* out to keep the spawned-backend suite non-flaky.
|
|
29
42
|
*
|
|
30
43
|
* Gated on `capabilities.sse` — backends without an end-to-end SSE stream
|
|
31
44
|
* skip (the cases still surface as `.skip` in the report). Cross-process
|
|
@@ -35,7 +48,7 @@ import '../assert_dev_env.js';
|
|
|
35
48
|
* @module
|
|
36
49
|
*/
|
|
37
50
|
import { assert, describe } from 'vitest';
|
|
38
|
-
import { account_session_revoke_all_action_spec } from '../../auth/account_action_specs.js';
|
|
51
|
+
import { account_session_list_action_spec, account_session_revoke_action_spec, account_session_revoke_all_action_spec, } from '../../auth/account_action_specs.js';
|
|
39
52
|
import { admin_session_revoke_all_action_spec } from '../../auth/admin_action_specs.js';
|
|
40
53
|
import { audit_log_event_specs } from '../../realtime/sse_auth_guard.js';
|
|
41
54
|
import { SSE_CONNECTED_COMMENT } from '../../realtime/sse.js';
|
|
@@ -60,9 +73,9 @@ const assert_audit_data_frame = (frame) => {
|
|
|
60
73
|
assert.ok(result.success, `audit data frame params mismatch for '${String(payload.method)}': ${result.success ? '' : JSON.stringify(result.error.issues)}`);
|
|
61
74
|
};
|
|
62
75
|
/**
|
|
63
|
-
* Register the cross-process SSE round-trip suite. Up to
|
|
64
|
-
* real streaming `fetch`: connected-comment, audit data frame,
|
|
65
|
-
* close-on-revoke.
|
|
76
|
+
* Register the cross-process SSE round-trip suite. Up to four cases over a
|
|
77
|
+
* real streaming `fetch`: connected-comment, audit data frame, account-wide
|
|
78
|
+
* close-on-revoke, and session-scoped close-on-revoke.
|
|
66
79
|
*/
|
|
67
80
|
export const describe_cross_process_sse_tests = (options) => {
|
|
68
81
|
const { setup_test, capabilities, base_url, rpc_path, origin } = options;
|
|
@@ -133,5 +146,39 @@ export const describe_cross_process_sse_tests = (options) => {
|
|
|
133
146
|
await sse.close();
|
|
134
147
|
}
|
|
135
148
|
});
|
|
149
|
+
// Single `session_revoke` of the subscriber's OWN session → the
|
|
150
|
+
// session-hash-scoped close path (`close_for_session` / the TS guard's
|
|
151
|
+
// `close_by_identity(session_id)`). The keeper holds exactly one session,
|
|
152
|
+
// so revoking it by its blake3 hash drops the stream opened under it.
|
|
153
|
+
// This is the close-on-revoke path the account-wide cases above don't
|
|
154
|
+
// exercise; the remaining union events (`token_revoke_all` / `logout` /
|
|
155
|
+
// `password_change`) share the account-wide `close_for_account` path the
|
|
156
|
+
// `session_revoke_all` case already covers, and `role_grant_revoke`'s
|
|
157
|
+
// role-matched path is covered by `fuz_realtime`'s SSE registry unit tests.
|
|
158
|
+
test_if(capabilities.sse && rpc_path !== undefined, 'stream closes on a single session_revoke of the subscriber session', async () => {
|
|
159
|
+
const fixture = await setup_test();
|
|
160
|
+
const sse = await create_sse_transport({
|
|
161
|
+
base_url,
|
|
162
|
+
sse_path,
|
|
163
|
+
cookies: fixture.transport.cookies(),
|
|
164
|
+
origin,
|
|
165
|
+
});
|
|
166
|
+
try {
|
|
167
|
+
const first = await sse.read_frame();
|
|
168
|
+
assert.strictEqual(first + '\n\n', SSE_CONNECTED_COMMENT, 'first frame must be the connected comment');
|
|
169
|
+
const list_res = await fixture.transport(rpc_path, create_rpc_post_init(account_session_list_action_spec.method));
|
|
170
|
+
assert.strictEqual(list_res.status, 200, `account_session_list RPC failed (status=${list_res.status})`);
|
|
171
|
+
const list_body = (await list_res.json());
|
|
172
|
+
const session_id = list_body.result?.sessions?.[0]?.id;
|
|
173
|
+
assert.ok(typeof session_id === 'string' && session_id.length > 0, 'expected the subscriber session id (blake3 hash) to revoke');
|
|
174
|
+
const revoke_res = await fixture.transport(rpc_path, create_rpc_post_init(account_session_revoke_action_spec.method, { session_id }));
|
|
175
|
+
assert.strictEqual(revoke_res.status, 200, `account_session_revoke RPC failed (status=${revoke_res.status})`);
|
|
176
|
+
const closed = await sse.wait_for_close(2000);
|
|
177
|
+
assert.ok(closed, 'stream did not close within 2s after session_revoke');
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await sse.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
136
183
|
});
|
|
137
184
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.1",
|
|
4
4
|
"description": "fullstack app library",
|
|
5
5
|
"glyph": "🗝",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
"@hono/node-ws": {
|
|
49
49
|
"optional": true
|
|
50
50
|
},
|
|
51
|
+
"@node-rs/argon2": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"hono": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
51
57
|
"pg": {
|
|
52
58
|
"optional": true
|
|
53
59
|
},
|