@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.
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_event.d.ts.map +1 -1
- package/dist/actions/action_event.js +6 -0
- package/dist/actions/action_event_data.d.ts.map +1 -1
- package/dist/actions/action_event_helpers.d.ts +1 -1
- package/dist/actions/action_event_helpers.d.ts.map +1 -1
- package/dist/actions/action_event_types.d.ts.map +1 -1
- package/dist/actions/action_peer.d.ts.map +1 -1
- package/dist/actions/request_tracker.svelte.d.ts.map +1 -1
- package/dist/actions/transports.d.ts.map +1 -1
- package/dist/actions/transports_http.d.ts.map +1 -1
- package/dist/actions/transports_ws.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.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 +33 -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.d.ts +0 -2
- package/dist/realtime/sse.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 -5
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/integration_helpers.js +37 -1
- 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/dist/ui/AdminOverview.svelte +1 -0
- package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
- 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,
|
|
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();
|
package/dist/hono_context.d.ts
CHANGED
|
@@ -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"}
|
package/dist/realtime/sse.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>,
|
|
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;
|
|
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
|
|
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) {
|