@fuzdev/fuz_app 0.12.0 → 0.13.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/actions/action_codegen.d.ts.map +1 -1
- package/dist/auth/account_routes.d.ts +30 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +44 -9
- package/dist/auth/admin_routes.d.ts.map +1 -1
- package/dist/auth/admin_routes.js +35 -2
- package/dist/auth/audit_log_routes.d.ts +2 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +11 -2
- package/dist/auth/audit_log_schema.d.ts +1 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +3 -1
- package/dist/auth/permit_queries.d.ts +19 -0
- package/dist/auth/permit_queries.d.ts.map +1 -1
- package/dist/auth/permit_queries.js +21 -0
- package/dist/auth/request_context.d.ts +10 -0
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +14 -0
- package/dist/hono_context.d.ts +7 -0
- package/dist/hono_context.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.d.ts +23 -3
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +38 -2
- package/dist/realtime/subscriber_registry.d.ts +62 -17
- package/dist/realtime/subscriber_registry.d.ts.map +1 -1
- package/dist/realtime/subscriber_registry.js +64 -21
- package/dist/server/validate_nginx.d.ts.map +1 -1
- package/dist/server/validate_nginx.js +61 -7
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +8 -8
- package/dist/testing/app_server.d.ts +9 -0
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +4 -3
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +1 -20
- package/dist/testing/error_coverage.d.ts +93 -27
- package/dist/testing/error_coverage.d.ts.map +1 -1
- package/dist/testing/error_coverage.js +160 -67
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +6 -6
- package/dist/testing/integration_helpers.d.ts +17 -0
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +31 -0
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +41 -55
- package/dist/testing/sse_round_trip.d.ts +64 -0
- package/dist/testing/sse_round_trip.d.ts.map +1 -0
- package/dist/testing/sse_round_trip.js +241 -0
- package/package.json +1 -1
|
@@ -16,10 +16,14 @@ import { SubscriberRegistry } from './subscriber_registry.js';
|
|
|
16
16
|
* Audit event types that trigger SSE stream disconnection.
|
|
17
17
|
*
|
|
18
18
|
* `permit_revoke` requires the revoked role to match the guard's `required_role`.
|
|
19
|
-
* `session_revoke_all` and `password_change` close
|
|
19
|
+
* `session_revoke_all` and `password_change` close every stream for the target account.
|
|
20
|
+
* `session_revoke` closes only the stream tied to the specific revoked session
|
|
21
|
+
* (matched by the blake3 session hash in `event.metadata.session_id`) — closing
|
|
22
|
+
* all of a user's streams for a single-session revoke would be over-aggressive.
|
|
20
23
|
*/
|
|
21
24
|
export const DISCONNECT_EVENT_TYPES = new Set([
|
|
22
25
|
'permit_revoke', // role revoked — user lost access
|
|
26
|
+
'session_revoke', // single session revoked — close only that stream
|
|
23
27
|
'session_revoke_all', // all sessions invalidated — user should be kicked
|
|
24
28
|
'password_change', // password changed — all sessions revoked implicitly
|
|
25
29
|
]);
|
|
@@ -43,6 +47,26 @@ export const create_sse_auth_guard = (registry, required_role, log) => {
|
|
|
43
47
|
return (event) => {
|
|
44
48
|
if (!DISCONNECT_EVENT_TYPES.has(event.event_type))
|
|
45
49
|
return;
|
|
50
|
+
// Only act on successful revocations. Failed attempts carry
|
|
51
|
+
// attacker-controlled identifiers (e.g., session_revoke with outcome=failure
|
|
52
|
+
// carries the submitted session_id even when the DB rejected the cross-account
|
|
53
|
+
// mutation) — reacting to them lets any authenticated user close another
|
|
54
|
+
// user's SSE stream by guessing or leaking a session hash.
|
|
55
|
+
if (event.outcome === 'failure')
|
|
56
|
+
return;
|
|
57
|
+
// session_revoke is session-scoped, not account-scoped — close only the
|
|
58
|
+
// stream subscribed under the revoked session's hash. The hash is already
|
|
59
|
+
// in the event metadata (set by the /sessions/:id/revoke handler).
|
|
60
|
+
if (event.event_type === 'session_revoke') {
|
|
61
|
+
const session_id = event.metadata?.session_id;
|
|
62
|
+
if (typeof session_id !== 'string' || session_id.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
const closed = registry.close_by_identity(session_id);
|
|
65
|
+
if (closed > 0) {
|
|
66
|
+
log.info(`SSE auth guard: closed ${closed} stream(s) for session ${session_id} (session_revoke)`);
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
46
70
|
// permit_revoke requires matching the specific role
|
|
47
71
|
if (event.event_type === 'permit_revoke') {
|
|
48
72
|
if (event.metadata?.role !== required_role)
|
|
@@ -95,9 +119,21 @@ export const AUDIT_LOG_EVENT_SPECS = AUDIT_EVENT_TYPES.map((event_type) => ({
|
|
|
95
119
|
description: `Audit log: ${event_type.replaceAll('_', ' ')}`,
|
|
96
120
|
channel: 'audit_log',
|
|
97
121
|
}));
|
|
122
|
+
/**
|
|
123
|
+
* Default max concurrent SSE subscribers per session scope for the audit log.
|
|
124
|
+
*
|
|
125
|
+
* The audit log SSE subscribes with `scope = session_hash` and
|
|
126
|
+
* `groups = [account_id]`. Only `scope` is capped — so this limits tabs
|
|
127
|
+
* per session. An account's total streams across all sessions is bounded
|
|
128
|
+
* transitively by `max_sessions × AUDIT_LOG_SSE_MAX_PER_SCOPE`. 10 tabs
|
|
129
|
+
* per session is a comfortable ceiling for normal use; consumers raising
|
|
130
|
+
* it above ~50 should consider server-side connection limits.
|
|
131
|
+
*/
|
|
132
|
+
export const AUDIT_LOG_SSE_MAX_PER_SCOPE = 10;
|
|
98
133
|
export const create_audit_log_sse = (options) => {
|
|
99
134
|
const role = options.role ?? 'admin';
|
|
100
|
-
const
|
|
135
|
+
const max_per_scope = options.max_per_scope === undefined ? AUDIT_LOG_SSE_MAX_PER_SCOPE : options.max_per_scope;
|
|
136
|
+
const registry = new SubscriberRegistry({ max_per_scope });
|
|
101
137
|
const guard = create_sse_auth_guard(registry, role, options.log);
|
|
102
138
|
return {
|
|
103
139
|
subscribe: registry.subscribe.bind(registry),
|
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supports channel-based filtering — subscribers connect with optional
|
|
5
5
|
* channel filters, and broadcasts reach only matching subscribers.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Two identity slots enable both targeted disconnection and per-scope cap
|
|
8
|
+
* enforcement:
|
|
9
|
+
* - `scope` — a single capped identity (e.g., session hash). Subject to
|
|
10
|
+
* the per-scope cap and matched by `close_by_identity`. Use for the
|
|
11
|
+
* narrowest identity the subscriber belongs to.
|
|
12
|
+
* - `groups` — any number of uncapped identities (e.g., account id).
|
|
13
|
+
* Matched by `close_by_identity` but not subject to any cap. Use for
|
|
14
|
+
* coarser scopes a stream should be reachable by.
|
|
15
|
+
*
|
|
16
|
+
* The split keeps "tabs-per-session" cap semantics sane when a stream also
|
|
17
|
+
* carries a broader identity for coarse close — the broader identity
|
|
18
|
+
* doesn't cap across sessions.
|
|
8
19
|
*
|
|
9
20
|
* @module
|
|
10
21
|
*/
|
|
@@ -13,23 +24,50 @@ export interface Subscriber<T> {
|
|
|
13
24
|
stream: SseStream<T>;
|
|
14
25
|
/** Channels this subscriber listens to. `null` means all channels. */
|
|
15
26
|
channels: Set<string> | null;
|
|
16
|
-
/**
|
|
17
|
-
|
|
27
|
+
/** Primary (capped) identity. `null` when none. */
|
|
28
|
+
scope: string | null;
|
|
29
|
+
/** Grouping identities for `close_by_identity`. `null` when none. */
|
|
30
|
+
groups: Set<string> | null;
|
|
31
|
+
}
|
|
32
|
+
/** Options for `SubscriberRegistry`. */
|
|
33
|
+
export interface SubscriberRegistryOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Max subscribers sharing a single `scope`. On subscribe, when the count
|
|
36
|
+
* of subscribers with the same `scope` reaches this limit, the oldest
|
|
37
|
+
* matching subscriber(s) are closed before the new one is added.
|
|
38
|
+
* `null` (default) disables the cap. `groups` identities are never capped.
|
|
39
|
+
*/
|
|
40
|
+
max_per_scope?: number | null;
|
|
41
|
+
}
|
|
42
|
+
/** Options for `SubscriberRegistry.subscribe`. */
|
|
43
|
+
export interface SubscribeOptions {
|
|
44
|
+
/** Channels to subscribe to. Empty/absent = all channels. */
|
|
45
|
+
channels?: ReadonlyArray<string>;
|
|
46
|
+
/**
|
|
47
|
+
* Primary (capped) identity — e.g., session hash. Subject to
|
|
48
|
+
* `max_per_scope` and matched by `close_by_identity`.
|
|
49
|
+
*/
|
|
50
|
+
scope?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Grouping identities — e.g., account id. Matched by `close_by_identity`
|
|
53
|
+
* but NOT subject to the cap. Use for coarse-targeted close.
|
|
54
|
+
*/
|
|
55
|
+
groups?: ReadonlyArray<string>;
|
|
18
56
|
}
|
|
19
57
|
/**
|
|
20
58
|
* Generic subscriber registry with channel-based filtering and identity-keyed disconnection.
|
|
21
59
|
*
|
|
22
|
-
* Subscribers connect with optional channel filters
|
|
23
|
-
* Broadcasts go to a specific channel and reach only
|
|
24
|
-
* `close_by_identity` force-closes all subscribers
|
|
25
|
-
*
|
|
60
|
+
* Subscribers connect with optional channel filters, a capped `scope`, and
|
|
61
|
+
* uncapped `groups`. Broadcasts go to a specific channel and reach only
|
|
62
|
+
* matching subscribers. `close_by_identity` force-closes all subscribers
|
|
63
|
+
* whose `scope` or `groups` contain the given key — use for auth revocation.
|
|
26
64
|
*
|
|
27
65
|
* @example
|
|
28
66
|
* ```ts
|
|
29
67
|
* const registry = new SubscriberRegistry<SseNotification>();
|
|
30
68
|
*
|
|
31
69
|
* // subscriber connects (from SSE endpoint)
|
|
32
|
-
* const unsubscribe = registry.subscribe(stream, ['runs']);
|
|
70
|
+
* const unsubscribe = registry.subscribe(stream, {channels: ['runs']});
|
|
33
71
|
*
|
|
34
72
|
* // when a run changes
|
|
35
73
|
* registry.broadcast('runs', {method: 'run_created', params: {run}});
|
|
@@ -40,26 +78,33 @@ export interface Subscriber<T> {
|
|
|
40
78
|
*
|
|
41
79
|
* @example
|
|
42
80
|
* ```ts
|
|
43
|
-
* //
|
|
44
|
-
* const unsubscribe = registry.subscribe(stream,
|
|
81
|
+
* // scope = session hash (capped), groups = [account id] (close-only)
|
|
82
|
+
* const unsubscribe = registry.subscribe(stream, {
|
|
83
|
+
* channels: ['audit_log'],
|
|
84
|
+
* scope: session_hash,
|
|
85
|
+
* groups: [account_id],
|
|
86
|
+
* });
|
|
45
87
|
*
|
|
46
|
-
* //
|
|
88
|
+
* // coarse — close all of a user's streams on role revocation
|
|
47
89
|
* registry.close_by_identity(account_id);
|
|
90
|
+
*
|
|
91
|
+
* // fine — close just the stream(s) tied to a specific session
|
|
92
|
+
* registry.close_by_identity(session_hash);
|
|
48
93
|
* ```
|
|
49
94
|
*/
|
|
50
95
|
export declare class SubscriberRegistry<T> {
|
|
51
96
|
#private;
|
|
97
|
+
constructor(options?: SubscriberRegistryOptions);
|
|
52
98
|
/** Number of active subscribers. */
|
|
53
99
|
get count(): number;
|
|
54
100
|
/**
|
|
55
101
|
* Add a subscriber.
|
|
56
102
|
*
|
|
57
103
|
* @param stream - SSE stream to send data to
|
|
58
|
-
* @param
|
|
59
|
-
* @param identity - optional identity key for targeted disconnection
|
|
104
|
+
* @param options - channel filter and identity slots (scope + groups)
|
|
60
105
|
* @returns unsubscribe function
|
|
61
106
|
*/
|
|
62
|
-
subscribe(stream: SseStream<T>,
|
|
107
|
+
subscribe(stream: SseStream<T>, options?: SubscribeOptions): () => void;
|
|
63
108
|
/**
|
|
64
109
|
* Broadcast data to all subscribers on a channel.
|
|
65
110
|
*
|
|
@@ -71,13 +116,13 @@ export declare class SubscriberRegistry<T> {
|
|
|
71
116
|
*/
|
|
72
117
|
broadcast(channel: string, data: T): void;
|
|
73
118
|
/**
|
|
74
|
-
* Force-close all subscribers
|
|
119
|
+
* Force-close all subscribers whose `scope` or `groups` match the given key.
|
|
75
120
|
*
|
|
76
121
|
* Closes each matching stream and removes the subscriber from the registry.
|
|
77
122
|
* Use for auth revocation — when a user's permissions change, close their
|
|
78
123
|
* SSE connections so they must reconnect and re-authenticate.
|
|
79
124
|
*
|
|
80
|
-
* @param identity - the identity key to match
|
|
125
|
+
* @param identity - the identity key to match (checked against scope and groups)
|
|
81
126
|
* @returns the number of subscribers closed
|
|
82
127
|
*/
|
|
83
128
|
close_by_identity(identity: string): number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"subscriber_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/subscriber_registry.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"subscriber_registry.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/subscriber_registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,UAAU,CAAC;AAExC,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC;IACrB,sEAAsE;IACtE,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;IAC7B,mDAAmD;IACnD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qEAAqE;IACrE,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC;CAC3B;AAED,wCAAwC;AACxC,MAAM,WAAW,yBAAyB;IACzC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,kDAAkD;AAClD,MAAM,WAAW,gBAAgB;IAChC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACjC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,qBAAa,kBAAkB,CAAC,CAAC;;gBAIpB,OAAO,CAAC,EAAE,yBAAyB;IAI/C,oCAAoC;IACpC,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;;;OAMG;IACH,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,MAAM,IAAI;IAmBvE;;;;;;;;OAQG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI;IAQzC;;;;;;;;;OASG;IACH,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;CAiC3C"}
|
|
@@ -3,25 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supports channel-based filtering — subscribers connect with optional
|
|
5
5
|
* channel filters, and broadcasts reach only matching subscribers.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Two identity slots enable both targeted disconnection and per-scope cap
|
|
8
|
+
* enforcement:
|
|
9
|
+
* - `scope` — a single capped identity (e.g., session hash). Subject to
|
|
10
|
+
* the per-scope cap and matched by `close_by_identity`. Use for the
|
|
11
|
+
* narrowest identity the subscriber belongs to.
|
|
12
|
+
* - `groups` — any number of uncapped identities (e.g., account id).
|
|
13
|
+
* Matched by `close_by_identity` but not subject to any cap. Use for
|
|
14
|
+
* coarser scopes a stream should be reachable by.
|
|
15
|
+
*
|
|
16
|
+
* The split keeps "tabs-per-session" cap semantics sane when a stream also
|
|
17
|
+
* carries a broader identity for coarse close — the broader identity
|
|
18
|
+
* doesn't cap across sessions.
|
|
8
19
|
*
|
|
9
20
|
* @module
|
|
10
21
|
*/
|
|
11
22
|
/**
|
|
12
23
|
* Generic subscriber registry with channel-based filtering and identity-keyed disconnection.
|
|
13
24
|
*
|
|
14
|
-
* Subscribers connect with optional channel filters
|
|
15
|
-
* Broadcasts go to a specific channel and reach only
|
|
16
|
-
* `close_by_identity` force-closes all subscribers
|
|
17
|
-
*
|
|
25
|
+
* Subscribers connect with optional channel filters, a capped `scope`, and
|
|
26
|
+
* uncapped `groups`. Broadcasts go to a specific channel and reach only
|
|
27
|
+
* matching subscribers. `close_by_identity` force-closes all subscribers
|
|
28
|
+
* whose `scope` or `groups` contain the given key — use for auth revocation.
|
|
18
29
|
*
|
|
19
30
|
* @example
|
|
20
31
|
* ```ts
|
|
21
32
|
* const registry = new SubscriberRegistry<SseNotification>();
|
|
22
33
|
*
|
|
23
34
|
* // subscriber connects (from SSE endpoint)
|
|
24
|
-
* const unsubscribe = registry.subscribe(stream, ['runs']);
|
|
35
|
+
* const unsubscribe = registry.subscribe(stream, {channels: ['runs']});
|
|
25
36
|
*
|
|
26
37
|
* // when a run changes
|
|
27
38
|
* registry.broadcast('runs', {method: 'run_created', params: {run}});
|
|
@@ -32,15 +43,26 @@
|
|
|
32
43
|
*
|
|
33
44
|
* @example
|
|
34
45
|
* ```ts
|
|
35
|
-
* //
|
|
36
|
-
* const unsubscribe = registry.subscribe(stream,
|
|
46
|
+
* // scope = session hash (capped), groups = [account id] (close-only)
|
|
47
|
+
* const unsubscribe = registry.subscribe(stream, {
|
|
48
|
+
* channels: ['audit_log'],
|
|
49
|
+
* scope: session_hash,
|
|
50
|
+
* groups: [account_id],
|
|
51
|
+
* });
|
|
37
52
|
*
|
|
38
|
-
* //
|
|
53
|
+
* // coarse — close all of a user's streams on role revocation
|
|
39
54
|
* registry.close_by_identity(account_id);
|
|
55
|
+
*
|
|
56
|
+
* // fine — close just the stream(s) tied to a specific session
|
|
57
|
+
* registry.close_by_identity(session_hash);
|
|
40
58
|
* ```
|
|
41
59
|
*/
|
|
42
60
|
export class SubscriberRegistry {
|
|
43
61
|
#subscribers = new Set();
|
|
62
|
+
#max_per_scope;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.#max_per_scope = options?.max_per_scope ?? null;
|
|
65
|
+
}
|
|
44
66
|
/** Number of active subscribers. */
|
|
45
67
|
get count() {
|
|
46
68
|
return this.#subscribers.size;
|
|
@@ -49,16 +71,19 @@ export class SubscriberRegistry {
|
|
|
49
71
|
* Add a subscriber.
|
|
50
72
|
*
|
|
51
73
|
* @param stream - SSE stream to send data to
|
|
52
|
-
* @param
|
|
53
|
-
* @param identity - optional identity key for targeted disconnection
|
|
74
|
+
* @param options - channel filter and identity slots (scope + groups)
|
|
54
75
|
* @returns unsubscribe function
|
|
55
76
|
*/
|
|
56
|
-
subscribe(stream,
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
subscribe(stream, options) {
|
|
78
|
+
const channels = options?.channels && options.channels.length > 0 ? new Set(options.channels) : null;
|
|
79
|
+
const scope = options?.scope ?? null;
|
|
80
|
+
const groups = options?.groups && options.groups.length > 0 ? new Set(options.groups) : null;
|
|
81
|
+
// Per-scope cap — only `scope` is capped, `groups` are never capped.
|
|
82
|
+
// Insertion order of the backing Set preserves FIFO eviction semantics.
|
|
83
|
+
if (this.#max_per_scope != null && scope !== null) {
|
|
84
|
+
this.#enforce_scope_limit(scope, this.#max_per_scope);
|
|
85
|
+
}
|
|
86
|
+
const subscriber = { stream, channels, scope, groups };
|
|
62
87
|
this.#subscribers.add(subscriber);
|
|
63
88
|
return () => {
|
|
64
89
|
this.#subscribers.delete(subscriber);
|
|
@@ -81,13 +106,13 @@ export class SubscriberRegistry {
|
|
|
81
106
|
}
|
|
82
107
|
}
|
|
83
108
|
/**
|
|
84
|
-
* Force-close all subscribers
|
|
109
|
+
* Force-close all subscribers whose `scope` or `groups` match the given key.
|
|
85
110
|
*
|
|
86
111
|
* Closes each matching stream and removes the subscriber from the registry.
|
|
87
112
|
* Use for auth revocation — when a user's permissions change, close their
|
|
88
113
|
* SSE connections so they must reconnect and re-authenticate.
|
|
89
114
|
*
|
|
90
|
-
* @param identity - the identity key to match
|
|
115
|
+
* @param identity - the identity key to match (checked against scope and groups)
|
|
91
116
|
* @returns the number of subscribers closed
|
|
92
117
|
*/
|
|
93
118
|
close_by_identity(identity) {
|
|
@@ -95,7 +120,7 @@ export class SubscriberRegistry {
|
|
|
95
120
|
// (stream.close() fires on_close listeners which may call unsubscribe)
|
|
96
121
|
const to_close = [];
|
|
97
122
|
for (const subscriber of this.#subscribers) {
|
|
98
|
-
if (subscriber.
|
|
123
|
+
if (subscriber.scope === identity || subscriber.groups?.has(identity)) {
|
|
99
124
|
to_close.push(subscriber);
|
|
100
125
|
}
|
|
101
126
|
}
|
|
@@ -105,4 +130,22 @@ export class SubscriberRegistry {
|
|
|
105
130
|
}
|
|
106
131
|
return to_close.length;
|
|
107
132
|
}
|
|
133
|
+
#enforce_scope_limit(scope, max) {
|
|
134
|
+
// count existing subscribers with this scope (in insertion order)
|
|
135
|
+
const matching = [];
|
|
136
|
+
for (const subscriber of this.#subscribers) {
|
|
137
|
+
if (subscriber.scope === scope)
|
|
138
|
+
matching.push(subscriber);
|
|
139
|
+
}
|
|
140
|
+
// close oldest first, stopping once we've freed up room for one more
|
|
141
|
+
let overflow = matching.length - (max - 1);
|
|
142
|
+
let i = 0;
|
|
143
|
+
while (overflow > 0 && i < matching.length) {
|
|
144
|
+
const victim = matching[i];
|
|
145
|
+
victim.stream.close();
|
|
146
|
+
this.#subscribers.delete(victim);
|
|
147
|
+
overflow--;
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
108
151
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate_nginx.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/validate_nginx.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACxB,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"validate_nginx.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/validate_nginx.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACrC,EAAE,EAAE,OAAO,CAAC;IACZ,QAAQ,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACxB,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAgGD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,MAAM,KAAG,qBA+FtD,CAAC"}
|
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
/**
|
|
12
12
|
* Extract location blocks from an nginx config string.
|
|
13
13
|
*
|
|
14
|
-
* Finds `location [
|
|
15
|
-
*
|
|
14
|
+
* Finds `location [modifier] <path> {` directives (modifier may be `=`, `~`,
|
|
15
|
+
* `~*`, `^~`, or absent) and returns the full block content including nested
|
|
16
|
+
* braces.
|
|
16
17
|
*/
|
|
17
18
|
const extract_location_blocks = (config) => {
|
|
18
19
|
const blocks = [];
|
|
19
|
-
const location_regex = /location\s+(
|
|
20
|
+
const location_regex = /location\s+(=|~\*?|\^~)?\s*(\S+)\s*\{/g;
|
|
20
21
|
let match;
|
|
21
22
|
while ((match = location_regex.exec(config)) !== null) {
|
|
22
|
-
const
|
|
23
|
+
const modifier = match[1] ?? '';
|
|
24
|
+
const path = match[2];
|
|
23
25
|
const open_brace_index = match.index + match[0].length - 1;
|
|
24
26
|
let depth = 1;
|
|
25
27
|
let block_end = open_brace_index + 1;
|
|
@@ -34,10 +36,57 @@ const extract_location_blocks = (config) => {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
|
-
blocks.push({ path, content: config.slice(match.index, block_end) });
|
|
39
|
+
blocks.push({ modifier, path, content: config.slice(match.index, block_end) });
|
|
38
40
|
}
|
|
39
41
|
return blocks;
|
|
40
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Canonical `/api` URIs used to probe regex location patterns.
|
|
45
|
+
*
|
|
46
|
+
* Two probes cover the common regex shapes:
|
|
47
|
+
* - `/api` catches `^/api$`, `^/api(/|$)`, `^/(admin|api)`, etc.
|
|
48
|
+
* - `/api/` catches `^/api/` (which requires the trailing slash).
|
|
49
|
+
*
|
|
50
|
+
* Only consulted from the regex branch of `location_matches_api` — non-regex
|
|
51
|
+
* blocks compare `block.path` literally.
|
|
52
|
+
*/
|
|
53
|
+
const API_TEST_URIS = ['/api', '/api/'];
|
|
54
|
+
/**
|
|
55
|
+
* Does a location block route `/api` traffic?
|
|
56
|
+
*
|
|
57
|
+
* The matching strategy is deliberately asymmetric across modifier types:
|
|
58
|
+
*
|
|
59
|
+
* - **Regex (`~`, `~*`)**: compiles `block.path` as a regex and tests it
|
|
60
|
+
* against `API_TEST_URIS`. `~*` gets the `i` flag. Any match flags the
|
|
61
|
+
* block as `/api`-handling. Invalid regex returns `false` (nginx would
|
|
62
|
+
* reject it too). URI-probing is needed because regex patterns don't
|
|
63
|
+
* admit a reliable substring check — `^/(admin|api)` has no `/api` prefix
|
|
64
|
+
* textually but routes `/api` traffic.
|
|
65
|
+
* - **Prefix (no modifier, `^~`) and exact (`=`)**: literal check —
|
|
66
|
+
* `block.path === '/api'` or `block.path.startsWith('/api/')`. We do NOT
|
|
67
|
+
* probe with `API_TEST_URIS` here because that would produce false
|
|
68
|
+
* positives on overly broad prefixes: a catch-all `location /` technically
|
|
69
|
+
* routes `/api` requests, but nginx would prefer a more specific `/api`
|
|
70
|
+
* block when one exists — and we want the separate "No /api block found"
|
|
71
|
+
* error when one doesn't.
|
|
72
|
+
*
|
|
73
|
+
* Known blind spot: a regex matching only a sub-path that isn't in
|
|
74
|
+
* `API_TEST_URIS` (e.g. `^/api/v99$`, or `^/api/.+` which requires content
|
|
75
|
+
* after the slash) won't be flagged. Acceptable for fuz_app deploy configs,
|
|
76
|
+
* which route all `/api` traffic through a single broad block.
|
|
77
|
+
*/
|
|
78
|
+
const location_matches_api = (block) => {
|
|
79
|
+
if (block.modifier === '~' || block.modifier === '~*') {
|
|
80
|
+
try {
|
|
81
|
+
const re = new RegExp(block.path, block.modifier === '~*' ? 'i' : '');
|
|
82
|
+
return API_TEST_URIS.some((uri) => re.test(uri));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return block.path === '/api' || block.path.startsWith('/api/');
|
|
89
|
+
};
|
|
41
90
|
/**
|
|
42
91
|
* Validate an nginx config template string for security properties.
|
|
43
92
|
*
|
|
@@ -57,8 +106,13 @@ export const validate_nginx_config = (config) => {
|
|
|
57
106
|
const warnings = [];
|
|
58
107
|
const all_blocks = extract_location_blocks(config);
|
|
59
108
|
// 1. proxy_set_header Authorization "" in /api location blocks
|
|
60
|
-
const api_blocks = all_blocks.filter(
|
|
61
|
-
if (api_blocks.length
|
|
109
|
+
const api_blocks = all_blocks.filter(location_matches_api);
|
|
110
|
+
if (api_blocks.length === 0) {
|
|
111
|
+
errors.push('No /api location block found — config must have an /api location block ' +
|
|
112
|
+
'with Authorization header stripping. If you intentionally route /api ' +
|
|
113
|
+
'through a different structure, skip this validator.');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
62
116
|
const has_auth_strip = api_blocks.some((block) => block.content.includes('proxy_set_header Authorization ""') ||
|
|
63
117
|
block.content.includes("proxy_set_header Authorization ''"));
|
|
64
118
|
if (!has_auth_strip) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin_integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/admin_integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAC9D,OAAO,KAAK,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAA0B,KAAK,gBAAgB,EAAC,MAAM,wBAAwB,CAAC;AAGtF,OAAO,EAIN,KAAK,SAAS,EACd,MAAM,SAAS,CAAC;AAUjB;;GAEG;AACH,MAAM,WAAW,mCAAmC;IACnD,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,wDAAwD;IACxD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,4GAA4G;IAC5G,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,WAAW,CAAC,EAAE,OAAO,CACpB,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,CAAC,CAC5E,CAAC;IACF;;;OAGG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;CAChC;AAgDD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,mCAAmC,KAC1C,
|
|
1
|
+
{"version":3,"file":"admin_integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/admin_integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAC9D,OAAO,KAAK,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAA0B,KAAK,gBAAgB,EAAC,MAAM,wBAAwB,CAAC;AAGtF,OAAO,EAIN,KAAK,SAAS,EACd,MAAM,SAAS,CAAC;AAUjB;;GAEG;AACH,MAAM,WAAW,mCAAmC;IACnD,4CAA4C;IAC5C,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,wDAAwD;IACxD,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,4GAA4G;IAC5G,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,WAAW,CAAC,EAAE,OAAO,CACpB,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,CAAC,CAC5E,CAAC;IACF;;;OAGG;IACH,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;CAChC;AAgDD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,mCAAmC,KAC1C,IAwnCF,CAAC"}
|
|
@@ -130,9 +130,9 @@ export const describe_standard_admin_integration_tests = (options) => {
|
|
|
130
130
|
headers: test_app.create_session_headers(),
|
|
131
131
|
});
|
|
132
132
|
assert.strictEqual(res.status, 403);
|
|
133
|
-
|
|
134
|
-
const body = await res.json();
|
|
133
|
+
const body = await res.clone().json();
|
|
135
134
|
assert.strictEqual(body.error, 'insufficient_permissions');
|
|
135
|
+
await error_collector.assert_and_record(test_app.route_specs, 'GET', accounts_route.path, res);
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
138
|
// --- 2. Permit grant lifecycle ---
|
|
@@ -167,9 +167,9 @@ export const describe_standard_admin_integration_tests = (options) => {
|
|
|
167
167
|
body: JSON.stringify({ role: ROLE_KEEPER }),
|
|
168
168
|
});
|
|
169
169
|
assert.strictEqual(res.status, 403);
|
|
170
|
-
|
|
171
|
-
const body = await res.json();
|
|
170
|
+
const body = await res.clone().json();
|
|
172
171
|
assert.strictEqual(body.error, 'role_not_web_grantable');
|
|
172
|
+
await error_collector.assert_and_record(test_app.route_specs, 'POST', grant_route.path, res);
|
|
173
173
|
});
|
|
174
174
|
test('granting same role twice is idempotent (returns same permit)', async () => {
|
|
175
175
|
const test_app = await create_test_app(build_admin_test_app_options(options, get_db()));
|
|
@@ -227,9 +227,9 @@ export const describe_standard_admin_integration_tests = (options) => {
|
|
|
227
227
|
body: JSON.stringify({ role: grantable_role }),
|
|
228
228
|
});
|
|
229
229
|
assert.strictEqual(res.status, 404);
|
|
230
|
-
|
|
231
|
-
const body = await res.json();
|
|
230
|
+
const body = await res.clone().json();
|
|
232
231
|
assert.strictEqual(body.error, 'account_not_found');
|
|
232
|
+
await error_collector.assert_and_record(test_app.route_specs, 'POST', grant_route.path, res);
|
|
233
233
|
});
|
|
234
234
|
test('admin can revoke a permit', async () => {
|
|
235
235
|
const test_app = await create_test_app(build_admin_test_app_options(options, get_db()));
|
|
@@ -309,9 +309,9 @@ export const describe_standard_admin_integration_tests = (options) => {
|
|
|
309
309
|
headers: test_app.create_session_headers(),
|
|
310
310
|
});
|
|
311
311
|
assert.strictEqual(second.status, 404);
|
|
312
|
-
|
|
313
|
-
const body = await second.json();
|
|
312
|
+
const body = await second.clone().json();
|
|
314
313
|
assert.strictEqual(body.error, 'permit_not_found');
|
|
314
|
+
await error_collector.assert_and_record(test_app.route_specs, 'POST', revoke_route.path, second);
|
|
315
315
|
});
|
|
316
316
|
});
|
|
317
317
|
// --- 3. Admin session management ---
|
|
@@ -17,6 +17,7 @@ import { type Keyring } from '../auth/keyring.js';
|
|
|
17
17
|
import type { Db, DbType } from '../db/db.js';
|
|
18
18
|
import type { PasswordHashDeps } from '../auth/password.js';
|
|
19
19
|
import { type SessionOptions } from '../auth/session_cookie.js';
|
|
20
|
+
import type { AuditLogEvent } from '../auth/audit_log_schema.js';
|
|
20
21
|
import type { AppBackend } from '../server/app_backend.js';
|
|
21
22
|
import { type AppServerOptions, type AppServerContext } from '../server/app_server.js';
|
|
22
23
|
import type { AppSurface, AppSurfaceSpec } from '../http/surface.js';
|
|
@@ -100,6 +101,14 @@ export interface TestAppServerOptions {
|
|
|
100
101
|
password_value?: string;
|
|
101
102
|
/** Roles to grant. Default: `[ROLE_KEEPER]`. */
|
|
102
103
|
roles?: Array<string>;
|
|
104
|
+
/**
|
|
105
|
+
* Backend audit event callback — wired into `backend.deps.on_audit_event`.
|
|
106
|
+
* When `audit_log_sse: true` is passed to `create_app_server`, this runs
|
|
107
|
+
* after the audit SSE broadcast (composed downstream by app_server).
|
|
108
|
+
* Use to wire consumer SSE auth guards in tests.
|
|
109
|
+
* Default: no-op.
|
|
110
|
+
*/
|
|
111
|
+
on_audit_event?: (event: AuditLogEvent) => void;
|
|
103
112
|
}
|
|
104
113
|
export declare const create_test_app_server: (options: TestAppServerOptions) => Promise<TestAppServer>;
|
|
105
114
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,MAAM,CAAC;AAK/B,OAAO,EAA2B,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAE1E,OAAO,KAAK,EAAC,EAAE,EAAE,MAAM,EAAC,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,qBAAqB,CAAC;AAU1D,OAAO,EAA8B,KAAK,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAG3F,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,0BAA0B,CAAC;AACzD,OAAO,EAEN,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACrB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAC,UAAU,EAAE,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACnE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAUrD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,EAAE,gBAIhC,CAAC;AAEF,gFAAgF;AAChF,eAAO,MAAM,kBAAkB,QAAiB,CAAC;AASjD;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC3C,EAAE,EAAE,EAAE,CAAC;IACP,OAAO,EAAE,OAAO,CAAC;IACjB,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,GAClC,SAAS,2BAA2B,KAClC,OAAO,CAAC;IACV,OAAO,EAAE;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACxC,KAAK,EAAE;QAAC,EAAE,EAAE,MAAM,CAAA;KAAC,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACvB,CAyCA,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,aAAc,SAAQ,UAAU;IAChD,gCAAgC;IAChC,OAAO,EAAE;QAAC,EAAE,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;IACxC,uCAAuC;IACvC,KAAK,EAAE;QAAC,EAAE,EAAE,MAAM,CAAA;KAAC,CAAC;IACpB,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,cAAc,EAAE,MAAM,CAAC;IACvB,+FAA+F;IAC/F,OAAO,EAAE,OAAO,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACpC,mDAAmD;IACnD,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,kGAAkG;IAClG,EAAE,CAAC,EAAE,EAAE,CAAC;IACR,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yHAAyH;IACzH,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gDAAgD;IAChD,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
|
|
@@ -88,7 +88,8 @@ export const bootstrap_test_account = async (options) => {
|
|
|
88
88
|
/** Silent logger for tests — suppresses all output. */
|
|
89
89
|
const test_log = new Logger('test', { level: 'off' });
|
|
90
90
|
export const create_test_app_server = async (options) => {
|
|
91
|
-
const { session_options, db: existing_db, db_type = 'pglite-memory', password = stub_password_deps, username = 'keeper', password_value = 'test-password-123', roles = [ROLE_KEEPER],
|
|
91
|
+
const { session_options, db: existing_db, db_type = 'pglite-memory', password = stub_password_deps, username = 'keeper', password_value = 'test-password-123', roles = [ROLE_KEEPER], on_audit_event = () => { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
|
92
|
+
} = options;
|
|
92
93
|
// Keyring from test secret
|
|
93
94
|
const keyring_result = create_validated_keyring(TEST_COOKIE_SECRET);
|
|
94
95
|
if (!keyring_result.ok) {
|
|
@@ -117,7 +118,7 @@ export const create_test_app_server = async (options) => {
|
|
|
117
118
|
password,
|
|
118
119
|
db: existing_db,
|
|
119
120
|
log: test_log,
|
|
120
|
-
on_audit_event
|
|
121
|
+
on_audit_event,
|
|
121
122
|
...fs_stubs,
|
|
122
123
|
},
|
|
123
124
|
};
|
|
@@ -137,7 +138,7 @@ export const create_test_app_server = async (options) => {
|
|
|
137
138
|
password,
|
|
138
139
|
db,
|
|
139
140
|
log: test_log,
|
|
140
|
-
on_audit_event
|
|
141
|
+
on_audit_event,
|
|
141
142
|
...fs_stubs,
|
|
142
143
|
},
|
|
143
144
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"data_exposure.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/data_exposure.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAgB7B,OAAO,KAAK,EAAC,UAAU,EAAE,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACnE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AACrD,OAAO,KAAK,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAG9D,OAAO,EAAwB,KAAK,SAAS,EAAC,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"data_exposure.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/data_exposure.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAgB7B,OAAO,KAAK,EAAC,UAAU,EAAE,cAAc,EAAC,MAAM,oBAAoB,CAAC;AACnE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AACrD,OAAO,KAAK,EAAC,gBAAgB,EAAE,gBAAgB,EAAC,MAAM,yBAAyB,CAAC;AAChF,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAG9D,OAAO,EAAwB,KAAK,SAAS,EAAC,MAAM,SAAS,CAAC;AAe9D;;;;;;;;GAQG;AACH,eAAO,MAAM,kCAAkC,GAAI,QAAQ,OAAO,KAAG,GAAG,CAAC,MAAM,CAuB9E,CAAC;AAIF;;;;;GAKG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,UAAU,EACnB,mBAAkB,aAAa,CAAC,MAAM,CAA6B,KACjE,IAWF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,wCAAwC,GACpD,SAAS,UAAU,EACnB,oBAAmB,aAAa,CAAC,MAAM,CAA8B,KACnE,IAcF,CAAC;AAIF,kDAAkD;AAClD,MAAM,WAAW,uBAAuB;IACvC,4DAA4D;IAC5D,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,wCAAwC;IACxC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,4CAA4C;IAC5C,kBAAkB,EAAE,CAAC,GAAG,EAAE,gBAAgB,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC;IAChE,2FAA2F;IAC3F,gBAAgB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACzC,iGAAiG;IACjG,iBAAiB,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC1C,iDAAiD;IACjD,WAAW,CAAC,EAAE,OAAO,CACpB,IAAI,CAAC,gBAAgB,EAAE,SAAS,GAAG,iBAAiB,GAAG,oBAAoB,CAAC,CAC5E,CAAC;IACF,qEAAqE;IACrE,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAChC,kDAAkD;IAClD,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,GAAI,SAAS,uBAAuB,KAAG,IAmC/E,CAAC"}
|
|
@@ -18,7 +18,7 @@ import { resolve_valid_path, generate_valid_body } from './schema_generators.js'
|
|
|
18
18
|
import { run_migrations } from '../db/migrate.js';
|
|
19
19
|
import { AUTH_MIGRATION_NS } from '../auth/migrations.js';
|
|
20
20
|
import { is_null_schema, is_strict_object_schema } from '../http/schema_helpers.js';
|
|
21
|
-
import { SENSITIVE_FIELD_BLOCKLIST, ADMIN_ONLY_FIELD_BLOCKLIST, assert_no_sensitive_fields_in_json, } from './integration_helpers.js';
|
|
21
|
+
import { SENSITIVE_FIELD_BLOCKLIST, ADMIN_ONLY_FIELD_BLOCKLIST, assert_no_sensitive_fields_in_json, pick_auth_headers, } from './integration_helpers.js';
|
|
22
22
|
// --- Schema introspection ---
|
|
23
23
|
/**
|
|
24
24
|
* Recursively collect all property names from a JSON Schema.
|
|
@@ -275,22 +275,3 @@ const describe_data_exposure_runtime_tests = (options) => {
|
|
|
275
275
|
});
|
|
276
276
|
}
|
|
277
277
|
};
|
|
278
|
-
/**
|
|
279
|
-
* Pick auth headers matching a route spec's auth requirement.
|
|
280
|
-
*/
|
|
281
|
-
const pick_auth_headers = (spec, test_app, authed_account, admin_account) => {
|
|
282
|
-
switch (spec.auth.type) {
|
|
283
|
-
case 'none':
|
|
284
|
-
return { host: 'localhost', origin: 'http://localhost:5173' };
|
|
285
|
-
case 'authenticated':
|
|
286
|
-
return authed_account.create_session_headers();
|
|
287
|
-
case 'role':
|
|
288
|
-
if (spec.auth.role === ROLE_ADMIN) {
|
|
289
|
-
return admin_account.create_session_headers();
|
|
290
|
-
}
|
|
291
|
-
// keeper role uses the bootstrapped account
|
|
292
|
-
return test_app.create_session_headers();
|
|
293
|
-
case 'keeper':
|
|
294
|
-
return test_app.create_daemon_token_headers();
|
|
295
|
-
}
|
|
296
|
-
};
|