@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.
Files changed (49) hide show
  1. package/dist/actions/action_codegen.d.ts.map +1 -1
  2. package/dist/auth/account_routes.d.ts +30 -0
  3. package/dist/auth/account_routes.d.ts.map +1 -1
  4. package/dist/auth/account_routes.js +44 -9
  5. package/dist/auth/admin_routes.d.ts.map +1 -1
  6. package/dist/auth/admin_routes.js +35 -2
  7. package/dist/auth/audit_log_routes.d.ts +2 -1
  8. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  9. package/dist/auth/audit_log_routes.js +11 -2
  10. package/dist/auth/audit_log_schema.d.ts +1 -1
  11. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  12. package/dist/auth/audit_log_schema.js +3 -1
  13. package/dist/auth/permit_queries.d.ts +19 -0
  14. package/dist/auth/permit_queries.d.ts.map +1 -1
  15. package/dist/auth/permit_queries.js +21 -0
  16. package/dist/auth/request_context.d.ts +10 -0
  17. package/dist/auth/request_context.d.ts.map +1 -1
  18. package/dist/auth/request_context.js +14 -0
  19. package/dist/hono_context.d.ts +7 -0
  20. package/dist/hono_context.d.ts.map +1 -1
  21. package/dist/realtime/sse_auth_guard.d.ts +23 -3
  22. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  23. package/dist/realtime/sse_auth_guard.js +38 -2
  24. package/dist/realtime/subscriber_registry.d.ts +62 -17
  25. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  26. package/dist/realtime/subscriber_registry.js +64 -21
  27. package/dist/server/validate_nginx.d.ts.map +1 -1
  28. package/dist/server/validate_nginx.js +61 -7
  29. package/dist/testing/admin_integration.d.ts.map +1 -1
  30. package/dist/testing/admin_integration.js +8 -8
  31. package/dist/testing/app_server.d.ts +9 -0
  32. package/dist/testing/app_server.d.ts.map +1 -1
  33. package/dist/testing/app_server.js +4 -3
  34. package/dist/testing/data_exposure.d.ts.map +1 -1
  35. package/dist/testing/data_exposure.js +1 -20
  36. package/dist/testing/error_coverage.d.ts +93 -27
  37. package/dist/testing/error_coverage.d.ts.map +1 -1
  38. package/dist/testing/error_coverage.js +160 -67
  39. package/dist/testing/integration.d.ts.map +1 -1
  40. package/dist/testing/integration.js +6 -6
  41. package/dist/testing/integration_helpers.d.ts +17 -0
  42. package/dist/testing/integration_helpers.d.ts.map +1 -1
  43. package/dist/testing/integration_helpers.js +31 -0
  44. package/dist/testing/round_trip.d.ts.map +1 -1
  45. package/dist/testing/round_trip.js +41 -55
  46. package/dist/testing/sse_round_trip.d.ts +64 -0
  47. package/dist/testing/sse_round_trip.d.ts.map +1 -0
  48. package/dist/testing/sse_round_trip.js +241 -0
  49. 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 unconditionally for the target account.
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 registry = new SubscriberRegistry();
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
- * Optional identity keys enable force-closing subscribers by identity
7
- * (e.g., close all streams for a specific account when their permissions change).
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
- /** Optional identity key for targeted disconnection (e.g., account_id). */
17
- identity: string | null;
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 and an optional identity key.
23
- * Broadcasts go to a specific channel and reach only matching subscribers.
24
- * `close_by_identity` force-closes all subscribers with a given identity —
25
- * use for auth revocation (close streams when a user's permissions change).
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
- * // identity-keyed subscription for auth revocation
44
- * const unsubscribe = registry.subscribe(stream, ['audit_log'], account_id);
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
- * // when admin revokes the user's role close their streams
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 channels - channels to subscribe to (`undefined` or empty = all channels)
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>, channels?: Array<string>, identity?: string): () => void;
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 with the given identity.
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;;;;;;;;;GASG;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,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,qBAAa,kBAAkB,CAAC,CAAC;;IAGhC,oCAAoC;IACpC,IAAI,KAAK,IAAI,MAAM,CAElB;IAED;;;;;;;OAOG;IACH,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,IAAI;IAYxF;;;;;;;;OAQG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI;IAQzC;;;;;;;;;OASG;IACH,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;CAe3C"}
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
- * Optional identity keys enable force-closing subscribers by identity
7
- * (e.g., close all streams for a specific account when their permissions change).
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 and an optional identity key.
15
- * Broadcasts go to a specific channel and reach only matching subscribers.
16
- * `close_by_identity` force-closes all subscribers with a given identity —
17
- * use for auth revocation (close streams when a user's permissions change).
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
- * // identity-keyed subscription for auth revocation
36
- * const unsubscribe = registry.subscribe(stream, ['audit_log'], account_id);
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
- * // when admin revokes the user's role close their streams
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 channels - channels to subscribe to (`undefined` or empty = all channels)
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, channels, identity) {
57
- const subscriber = {
58
- stream,
59
- channels: channels && channels.length > 0 ? new Set(channels) : null,
60
- identity: identity ?? null,
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 with the given identity.
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.identity === identity) {
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;AAgCD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,qBAAqB,GAAI,QAAQ,MAAM,KAAG,qBA2FtD,CAAC"}
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 [= ] <path> {` directives and extracts the full block
15
- * content including nested braces. Returns the path and full block text.
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+(?:=\s+)?(\S+)\s*\{/g;
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 path = match[1];
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((b) => b.path === '/api' || b.path.startsWith('/api/') || b.path.startsWith('/api{'));
61
- if (api_blocks.length > 0) {
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,IAomCF,CAAC"}
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
- error_collector.record(test_app.route_specs, 'GET', accounts_route.path, 403);
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
- error_collector.record(test_app.route_specs, 'POST', grant_route.path, 403);
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
- error_collector.record(test_app.route_specs, 'POST', grant_route.path, 404);
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
- error_collector.record(test_app.route_specs, 'POST', revoke_route.path, 404);
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":"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"}
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], } = 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], 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: () => { }, // eslint-disable-line @typescript-eslint/no-empty-function
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: () => { }, // eslint-disable-line @typescript-eslint/no-empty-function
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;AAc9D;;;;;;;;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"}
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
- };