@fuzdev/fuz_app 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/actions/action_codegen.d.ts.map +1 -1
  2. package/dist/actions/action_event.d.ts.map +1 -1
  3. package/dist/actions/action_event.js +6 -0
  4. package/dist/actions/action_event_data.d.ts.map +1 -1
  5. package/dist/actions/action_event_helpers.d.ts +1 -1
  6. package/dist/actions/action_event_helpers.d.ts.map +1 -1
  7. package/dist/actions/action_event_types.d.ts.map +1 -1
  8. package/dist/actions/action_peer.d.ts.map +1 -1
  9. package/dist/actions/request_tracker.svelte.d.ts.map +1 -1
  10. package/dist/actions/transports.d.ts.map +1 -1
  11. package/dist/actions/transports_http.d.ts.map +1 -1
  12. package/dist/actions/transports_ws.d.ts.map +1 -1
  13. package/dist/actions/transports_ws_backend.d.ts.map +1 -1
  14. package/dist/auth/account_routes.d.ts +30 -0
  15. package/dist/auth/account_routes.d.ts.map +1 -1
  16. package/dist/auth/account_routes.js +44 -9
  17. package/dist/auth/admin_routes.d.ts.map +1 -1
  18. package/dist/auth/admin_routes.js +33 -2
  19. package/dist/auth/audit_log_routes.d.ts +2 -1
  20. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  21. package/dist/auth/audit_log_routes.js +11 -2
  22. package/dist/auth/audit_log_schema.d.ts +1 -1
  23. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  24. package/dist/auth/audit_log_schema.js +3 -1
  25. package/dist/auth/permit_queries.d.ts +19 -0
  26. package/dist/auth/permit_queries.d.ts.map +1 -1
  27. package/dist/auth/permit_queries.js +21 -0
  28. package/dist/auth/request_context.d.ts +10 -0
  29. package/dist/auth/request_context.d.ts.map +1 -1
  30. package/dist/auth/request_context.js +14 -0
  31. package/dist/hono_context.d.ts +7 -0
  32. package/dist/hono_context.d.ts.map +1 -1
  33. package/dist/realtime/sse.d.ts +0 -2
  34. package/dist/realtime/sse.d.ts.map +1 -1
  35. package/dist/realtime/sse_auth_guard.d.ts +23 -3
  36. package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
  37. package/dist/realtime/sse_auth_guard.js +38 -2
  38. package/dist/realtime/subscriber_registry.d.ts +62 -17
  39. package/dist/realtime/subscriber_registry.d.ts.map +1 -1
  40. package/dist/realtime/subscriber_registry.js +64 -21
  41. package/dist/server/validate_nginx.d.ts.map +1 -1
  42. package/dist/server/validate_nginx.js +61 -7
  43. package/dist/testing/admin_integration.d.ts.map +1 -1
  44. package/dist/testing/admin_integration.js +8 -8
  45. package/dist/testing/app_server.d.ts +9 -0
  46. package/dist/testing/app_server.d.ts.map +1 -1
  47. package/dist/testing/app_server.js +4 -3
  48. package/dist/testing/data_exposure.d.ts.map +1 -1
  49. package/dist/testing/data_exposure.js +1 -20
  50. package/dist/testing/error_coverage.d.ts +93 -27
  51. package/dist/testing/error_coverage.d.ts.map +1 -1
  52. package/dist/testing/error_coverage.js +160 -67
  53. package/dist/testing/integration.d.ts.map +1 -1
  54. package/dist/testing/integration.js +6 -6
  55. package/dist/testing/integration_helpers.d.ts +17 -5
  56. package/dist/testing/integration_helpers.d.ts.map +1 -1
  57. package/dist/testing/integration_helpers.js +37 -1
  58. package/dist/testing/round_trip.d.ts.map +1 -1
  59. package/dist/testing/round_trip.js +41 -55
  60. package/dist/testing/sse_round_trip.d.ts +64 -0
  61. package/dist/testing/sse_round_trip.d.ts.map +1 -0
  62. package/dist/testing/sse_round_trip.js +241 -0
  63. package/dist/ui/AdminOverview.svelte +1 -0
  64. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  65. package/package.json +1 -1
@@ -23,6 +23,16 @@ export interface RequestContext {
23
23
  }
24
24
  /** Hono context variable name for the request context. */
25
25
  export declare const REQUEST_CONTEXT_KEY = "request_context";
26
+ /**
27
+ * Hono context variable name for the authenticated session token hash.
28
+ *
29
+ * Set by `create_request_context_middleware` after a successful session lookup.
30
+ * `null` when the request is unauthenticated or authenticated via a non-session
31
+ * credential (bearer token, daemon token). Exposed so handlers can scope
32
+ * per-session resources (e.g., SSE stream identity for targeted disconnection
33
+ * on `session_revoke`) without re-hashing the token.
34
+ */
35
+ export declare const AUTH_SESSION_TOKEN_HASH_KEY = "auth_session_token_hash";
26
36
  /**
27
37
  * Get the request context from a Hono context, or `null` if unauthenticated.
28
38
  *
@@ -1 +1 @@
1
- {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,cAAc,EAAE,MAAM,MAAM,EAAE,MAAK,IAAiB,KAAG,OAChB,CAAC;AAEtE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBAqCF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
1
+ {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,cAAc,EAAE,MAAM,MAAM,EAAE,MAAK,IAAiB,KAAG,OAChB,CAAC;AAEtE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBAyCF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
@@ -19,6 +19,16 @@ import { CREDENTIAL_TYPE_KEY } from '../hono_context.js';
19
19
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, } from '../http/error_schemas.js';
20
20
  /** Hono context variable name for the request context. */
21
21
  export const REQUEST_CONTEXT_KEY = 'request_context';
22
+ /**
23
+ * Hono context variable name for the authenticated session token hash.
24
+ *
25
+ * Set by `create_request_context_middleware` after a successful session lookup.
26
+ * `null` when the request is unauthenticated or authenticated via a non-session
27
+ * credential (bearer token, daemon token). Exposed so handlers can scope
28
+ * per-session resources (e.g., SSE stream identity for targeted disconnection
29
+ * on `session_revoke`) without re-hashing the token.
30
+ */
31
+ export const AUTH_SESSION_TOKEN_HASH_KEY = 'auth_session_token_hash';
22
32
  /**
23
33
  * Get the request context from a Hono context, or `null` if unauthenticated.
24
34
  *
@@ -78,6 +88,7 @@ export const create_request_context_middleware = (deps, log, session_context_key
78
88
  if (!session_token) {
79
89
  c.set(REQUEST_CONTEXT_KEY, null);
80
90
  c.set(CREDENTIAL_TYPE_KEY, null);
91
+ c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
81
92
  await next();
82
93
  return;
83
94
  }
@@ -86,6 +97,7 @@ export const create_request_context_middleware = (deps, log, session_context_key
86
97
  if (!session) {
87
98
  c.set(REQUEST_CONTEXT_KEY, null);
88
99
  c.set(CREDENTIAL_TYPE_KEY, null);
100
+ c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
89
101
  await next();
90
102
  return;
91
103
  }
@@ -93,11 +105,13 @@ export const create_request_context_middleware = (deps, log, session_context_key
93
105
  if (!ctx) {
94
106
  c.set(REQUEST_CONTEXT_KEY, null);
95
107
  c.set(CREDENTIAL_TYPE_KEY, null);
108
+ c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
96
109
  await next();
97
110
  return;
98
111
  }
99
112
  c.set(REQUEST_CONTEXT_KEY, ctx);
100
113
  c.set(CREDENTIAL_TYPE_KEY, 'session');
114
+ c.set(AUTH_SESSION_TOKEN_HASH_KEY, token_hash);
101
115
  // Touch session (fire-and-forget, don't block the request)
102
116
  void session_touch_fire_and_forget(deps, token_hash, c.var.pending_effects, log);
103
117
  await next();
@@ -37,6 +37,13 @@ declare module 'hono' {
37
37
  validated_query: unknown;
38
38
  /** How the request was authenticated (`'session'`, `'api_token'`, or `'daemon_token'`). */
39
39
  credential_type: CredentialType | null;
40
+ /**
41
+ * blake3 hash of the authenticated session token, or `null` for non-session
42
+ * credentials. Set by `create_request_context_middleware`. Used to scope
43
+ * per-session resources (e.g., SSE stream identity for `session_revoke`
44
+ * disconnection) without re-hashing the cookie in every handler.
45
+ */
46
+ auth_session_token_hash: string | null;
40
47
  /**
41
48
  * Pending fire-and-forget effects for this request (audit logs, usage tracking, etc.).
42
49
  * Initialized by `create_app_server`. In test mode (`await_pending_effects: true`),
@@ -1 +1 @@
1
- {"version":3,"file":"hono_context.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/hono_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAE9D,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,mDAAoD,CAAC;AAElF,yDAAyD;AACzD,eAAO,MAAM,cAAc;;;;EAA2B,CAAC;AACvD,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD,OAAO,QAAQ,MAAM,CAAC;IACrB,UAAU,kBAAkB;QAC3B,+DAA+D;QAC/D,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC,eAAe,EAAE,OAAO,CAAC;QACzB,gBAAgB,EAAE,OAAO,CAAC;QAC1B,eAAe,EAAE,OAAO,CAAC;QACzB,2FAA2F;QAC3F,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC;;;;WAIG;QACH,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;KACtC;CACD"}
1
+ {"version":3,"file":"hono_context.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/hono_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAE9D,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,mDAAoD,CAAC;AAElF,yDAAyD;AACzD,eAAO,MAAM,cAAc;;;;EAA2B,CAAC;AACvD,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD,OAAO,QAAQ,MAAM,CAAC;IACrB,UAAU,kBAAkB;QAC3B,+DAA+D;QAC/D,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC,eAAe,EAAE,OAAO,CAAC;QACzB,gBAAgB,EAAE,OAAO,CAAC;QAC1B,eAAe,EAAE,OAAO,CAAC;QACzB,2FAA2F;QAC3F,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC;;;;;WAKG;QACH,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAC;QACvC;;;;WAIG;QACH,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;KACtC;CACD"}
@@ -62,8 +62,6 @@ export interface EventSpec {
62
62
  description: string;
63
63
  channel?: string;
64
64
  }
65
- /** @deprecated Use `EventSpec` instead. */
66
- export type SseEventSpec = EventSpec;
67
65
  /**
68
66
  * Create a broadcaster that validates events in DEV mode.
69
67
  *
@@ -1 +1 @@
1
- {"version":3,"file":"sse.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,MAAM,CAAC;AAElC,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACrC,mDAAmD;IACnD,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACxB,6CAA6C;IAC7C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,wBAAwB;IACxB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,+FAA+F;IAC/F,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC/B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,MAAM,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,GAAG,OAAO,EAC9C,GAAG,OAAO,EACV,KAAK,MAAM,KACT;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;CAiD3C,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,qBAAqB,oBAAoB,CAAC;AAEvD,gFAAgF;AAChF,MAAM,WAAW,SAAS;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,2CAA2C;AAC3C,MAAM,MAAM,YAAY,GAAG,SAAS,CAAC;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,SAAS,eAAe,EACrE,aAAa;IAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAAC,EAC5D,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,KAAK,MAAM,KACT;IAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAmBhD,CAAC"}
1
+ {"version":3,"file":"sse.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,MAAM,CAAC;AAElC,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD;;;;GAIG;AACH,MAAM,WAAW,SAAS,CAAC,CAAC,GAAG,OAAO;IACrC,mDAAmD;IACnD,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,IAAI,CAAC;IACxB,6CAA6C;IAC7C,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,wBAAwB;IACxB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,+FAA+F;IAC/F,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACnC;AAED;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC/B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,MAAM,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,GAAI,CAAC,GAAG,OAAO,EAC9C,GAAG,OAAO,EACV,KAAK,MAAM,KACT;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;CAiD3C,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,qBAAqB,oBAAoB,CAAC;AAEvD,gFAAgF;AAChF,MAAM,WAAW,SAAS;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,4BAA4B,GAAI,CAAC,SAAS,eAAe,EACrE,aAAa;IAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAAC,EAC5D,aAAa,KAAK,CAAC,SAAS,CAAC,EAC7B,KAAK,MAAM,KACT;IAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,IAAI,CAAA;CAmBhD,CAAC"}
@@ -12,13 +12,16 @@
12
12
  */
13
13
  import type { Logger } from '@fuzdev/fuz_util/log.js';
14
14
  import { type AuditLogEvent } from '../auth/audit_log_schema.js';
15
- import { SubscriberRegistry } from './subscriber_registry.js';
15
+ import { SubscriberRegistry, type SubscribeOptions } from './subscriber_registry.js';
16
16
  import type { SseStream, SseNotification, EventSpec } from './sse.js';
17
17
  /**
18
18
  * Audit event types that trigger SSE stream disconnection.
19
19
  *
20
20
  * `permit_revoke` requires the revoked role to match the guard's `required_role`.
21
- * `session_revoke_all` and `password_change` close unconditionally for the target account.
21
+ * `session_revoke_all` and `password_change` close every stream for the target account.
22
+ * `session_revoke` closes only the stream tied to the specific revoked session
23
+ * (matched by the blake3 session hash in `event.metadata.session_id`) — closing
24
+ * all of a user's streams for a single-session revoke would be over-aggressive.
22
25
  */
23
26
  export declare const DISCONNECT_EVENT_TYPES: ReadonlySet<string>;
24
27
  /**
@@ -46,7 +49,7 @@ export declare const create_sse_auth_guard: <T>(registry: SubscriberRegistry<T>,
46
49
  */
47
50
  export interface AuditLogSse {
48
51
  /** Subscribe function — pass as part of `stream` option to `create_audit_log_route_specs`. */
49
- subscribe: (stream: SseStream<SseNotification>, channels?: Array<string>, identity?: string) => () => void;
52
+ subscribe: (stream: SseStream<SseNotification>, options?: SubscribeOptions) => () => void;
50
53
  /** Logger — pass as part of `stream` option to `create_audit_log_route_specs`. */
51
54
  log: Logger;
52
55
  /** Combined broadcast + guard callback. Pass as `on_audit_event` on `CreateAppBackendOptions`. */
@@ -85,9 +88,26 @@ export interface AuditLogSse {
85
88
  * Pass to `create_app_server`'s `event_specs` for surface generation and DEV validation.
86
89
  */
87
90
  export declare const AUDIT_LOG_EVENT_SPECS: Array<EventSpec>;
91
+ /**
92
+ * Default max concurrent SSE subscribers per session scope for the audit log.
93
+ *
94
+ * The audit log SSE subscribes with `scope = session_hash` and
95
+ * `groups = [account_id]`. Only `scope` is capped — so this limits tabs
96
+ * per session. An account's total streams across all sessions is bounded
97
+ * transitively by `max_sessions × AUDIT_LOG_SSE_MAX_PER_SCOPE`. 10 tabs
98
+ * per session is a comfortable ceiling for normal use; consumers raising
99
+ * it above ~50 should consider server-side connection limits.
100
+ */
101
+ export declare const AUDIT_LOG_SSE_MAX_PER_SCOPE = 10;
88
102
  export declare const create_audit_log_sse: (options: {
89
103
  /** Role required to access the SSE endpoint. Default `'admin'`. */
90
104
  role?: string;
91
105
  log: Logger;
106
+ /**
107
+ * Max concurrent SSE subscribers per session scope. On overflow, the oldest
108
+ * matching subscriber is closed. Default `AUDIT_LOG_SSE_MAX_PER_SCOPE`.
109
+ * Pass `null` to disable the cap.
110
+ */
111
+ max_per_scope?: number | null;
92
112
  }) => AuditLogSse;
93
113
  //# sourceMappingURL=sse_auth_guard.d.ts.map
@@ -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,EAAC,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAC,SAAS,EAAE,eAAe,EAAE,SAAS,EAAC,MAAM,UAAU,CAAC;AAEpE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAIrD,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,EACrB,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAqBjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,8FAA8F;IAC9F,SAAS,EAAE,CACV,MAAM,EAAE,SAAS,CAAC,eAAe,CAAC,EAClC,QAAQ,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,EACxB,QAAQ,CAAC,EAAE,MAAM,KACb,MAAM,IAAI,CAAC;IAChB,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,kGAAkG;IAClG,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C,yEAAyE;IACzE,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;CAC9C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAAI,SAAS;IAC7C,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACZ,KAAG,WAcH,CAAC"}
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;;;;;;;;GAQG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,EACrB,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA2CjC,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,kGAAkG;IAClG,cAAc,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC/C,yEAAyE;IACzE,QAAQ,EAAE,kBAAkB,CAAC,eAAe,CAAC,CAAC;CAC9C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,KAAK,CAAC,SAAS,CAOlD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAE9C,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"}
@@ -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) {