@fuzdev/fuz_app 0.15.0 → 0.16.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * WebSocket JSON-RPC dispatch — the canonical WS transport binding.
3
+ *
4
+ * Symmetric to `create_rpc_endpoint` (from `actions/action_rpc.ts`):
5
+ * consumer supplies action specs + a handler map, the dispatcher parses the
6
+ * envelope, checks per-action auth, validates input, invokes the handler with
7
+ * a per-request context, and writes the response.
8
+ *
9
+ * Extracted from zzz's `register_websocket_actions` to converge pattern drift
10
+ * across consumers (zzz, tx, undying). Broadcast-style notifications remain
11
+ * domain-shaped today — this module only covers per-request dispatch + the
12
+ * socket-scoped `ctx.notify` + per-socket `ctx.signal`. See
13
+ * `BackendWebsocketTransport.send` for broadcast.
14
+ *
15
+ * ## Auth expectations
16
+ *
17
+ * The consumer is responsible for rejecting unauthenticated upgrades *before*
18
+ * routing to this handler (fuz_app's `require_auth` middleware). Inside the
19
+ * dispatcher, `get_request_context(c)` is treated as guaranteed non-null and
20
+ * per-action auth is enforced on each message.
21
+ *
22
+ * @module
23
+ */
24
+ import type { Context, Hono } from 'hono';
25
+ import type { UpgradeWebSocket } from 'hono/ws';
26
+ import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
27
+ import { type JsonrpcRequestId } from '../http/jsonrpc.js';
28
+ import type { ActionSpecUnion } from './action_spec.js';
29
+ import { BackendWebsocketTransport } from './transports_ws_backend.js';
30
+ /**
31
+ * Minimum per-request context every handler receives.
32
+ *
33
+ * Consumers extend this with domain-specific fields via
34
+ * `RegisterActionWsOptions.extend_context` (e.g., a `backend` singleton
35
+ * or the authenticated `RequestContext`). Keeping the base minimal matches
36
+ * the HTTP-side `ActionContext` (from `actions/action_rpc.ts`) and mirrors
37
+ * Rust's `Ctx<'a>` shape (`request_id` + `NotifyFn` + `CancellationToken`).
38
+ */
39
+ export interface BaseHandlerContext {
40
+ /** JSON-RPC envelope request id — echoed back on the response. */
41
+ request_id: JsonrpcRequestId;
42
+ /**
43
+ * Send a request-scoped JSON-RPC notification to the originating socket.
44
+ * Not a broadcast — the message only reaches the client whose request
45
+ * triggered this handler. Streaming handlers (e.g. `completion_progress`)
46
+ * route chunks through this.
47
+ */
48
+ notify: (method: string, params: unknown) => void;
49
+ /** Fires on socket close; streaming handlers poll for early termination. */
50
+ signal: AbortSignal;
51
+ }
52
+ /** Handler signature — receives validated input and per-request context. */
53
+ export type WsActionHandler<TCtx extends BaseHandlerContext> = (input: unknown, ctx: TCtx) => unknown;
54
+ /** Options for `register_action_ws`. */
55
+ export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
56
+ /** Mount path (e.g., `/api/ws`). */
57
+ path: string;
58
+ /** The Hono app to mount on. */
59
+ app: Hono;
60
+ /** Hono's `upgradeWebSocket` helper from the runtime adapter. */
61
+ upgradeWebSocket: UpgradeWebSocket;
62
+ /** Action specs — drives method lookup, per-action auth, and input/output validation. */
63
+ specs: ReadonlyArray<ActionSpecUnion>;
64
+ /** Handler map keyed by `spec.method`. */
65
+ handlers: Record<string, WsActionHandler<TCtx>>;
66
+ /**
67
+ * Build the per-request context from the base and the upgrade-time Hono
68
+ * context. Called once per incoming message. Consumers use this to attach
69
+ * domain singletons (`backend`) or per-socket auth (`auth`,
70
+ * `credential_type`) without re-reading them from `c` inside every handler.
71
+ */
72
+ extend_context: (base: BaseHandlerContext, c: Context) => TCtx;
73
+ /**
74
+ * Existing transport to register connections with. When omitted, a fresh
75
+ * one is created and returned in the result. Pass your own to keep a
76
+ * handle for `create_ws_auth_guard` and `send_to`/`broadcast`.
77
+ */
78
+ transport?: BackendWebsocketTransport;
79
+ /** Optional per-message delay for testing loading states. Ignored when `0`. */
80
+ artificial_delay?: number;
81
+ /** Optional logger; defaults to `[ws]` namespace. */
82
+ log?: LoggerType;
83
+ }
84
+ /** Result of `register_action_ws`. */
85
+ export interface RegisterActionWsResult {
86
+ /** The transport bound to the endpoint — supplied or freshly created. */
87
+ transport: BackendWebsocketTransport;
88
+ }
89
+ /**
90
+ * Mount a JSON-RPC WebSocket endpoint that dispatches to the supplied handler
91
+ * map. Per-request context is built from the base + consumer-provided
92
+ * `RegisterActionWsOptions.extend_context`.
93
+ *
94
+ * Wire behavior:
95
+ * - Batch JSON-RPC is rejected (single-message only).
96
+ * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
97
+ * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
98
+ * already verified identity); `keeper` requires `daemon_token` credential
99
+ * type *and* the keeper role; role-based `{role}` is currently rejected as
100
+ * not-yet-supported.
101
+ * - DEV mode validates handler output against the spec's `output` schema and
102
+ * warns on mismatches.
103
+ *
104
+ * @returns the transport (supplied or freshly created) — retain it to wire
105
+ * `create_ws_auth_guard` or broadcast on audit events.
106
+ */
107
+ export declare const register_action_ws: <TCtx extends BaseHandlerContext>(options: RegisterActionWsOptions<TCtx>) => RegisterActionWsResult;
108
+ //# sourceMappingURL=register_action_ws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAC,OAAO,EAAE,IAAI,EAAC,MAAM,MAAM,CAAC;AACxC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,SAAS,CAAC;AAE9C,OAAO,EAAS,KAAK,MAAM,IAAI,UAAU,EAAC,MAAM,yBAAyB,CAAC;AAK1E,OAAO,EAAkB,KAAK,gBAAgB,EAAC,MAAM,oBAAoB,CAAC;AAY1E,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAC,yBAAyB,EAAC,MAAM,4BAA4B,CAAC;AAErE;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IAClC,kEAAkE;IAClE,UAAU,EAAE,gBAAgB,CAAC;IAC7B;;;;;OAKG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD,4EAA4E;IAC5E,MAAM,EAAE,WAAW,CAAC;CACpB;AAED,4EAA4E;AAC5E,MAAM,MAAM,eAAe,CAAC,IAAI,SAAS,kBAAkB,IAAI,CAC9D,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,IAAI,KACL,OAAO,CAAC;AAEb,wCAAwC;AACxC,MAAM,WAAW,uBAAuB,CAAC,IAAI,SAAS,kBAAkB;IACvE,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,GAAG,EAAE,IAAI,CAAC;IACV,iEAAiE;IACjE,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,yFAAyF;IACzF,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;IAChD;;;;;OAKG;IACH,cAAc,EAAE,CAAC,IAAI,EAAE,kBAAkB,EAAE,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/D;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,qDAAqD;IACrD,GAAG,CAAC,EAAE,UAAU,CAAC;CACjB;AAED,sCAAsC;AACtC,MAAM,WAAW,sBAAsB;IACtC,yEAAyE;IACzE,SAAS,EAAE,yBAAyB,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,kBAAkB,GAAI,IAAI,SAAS,kBAAkB,EACjE,SAAS,uBAAuB,CAAC,IAAI,CAAC,KACpC,sBA0NF,CAAC"}
@@ -0,0 +1,187 @@
1
+ /**
2
+ * WebSocket JSON-RPC dispatch — the canonical WS transport binding.
3
+ *
4
+ * Symmetric to `create_rpc_endpoint` (from `actions/action_rpc.ts`):
5
+ * consumer supplies action specs + a handler map, the dispatcher parses the
6
+ * envelope, checks per-action auth, validates input, invokes the handler with
7
+ * a per-request context, and writes the response.
8
+ *
9
+ * Extracted from zzz's `register_websocket_actions` to converge pattern drift
10
+ * across consumers (zzz, tx, undying). Broadcast-style notifications remain
11
+ * domain-shaped today — this module only covers per-request dispatch + the
12
+ * socket-scoped `ctx.notify` + per-socket `ctx.signal`. See
13
+ * `BackendWebsocketTransport.send` for broadcast.
14
+ *
15
+ * ## Auth expectations
16
+ *
17
+ * The consumer is responsible for rejecting unauthenticated upgrades *before*
18
+ * routing to this handler (fuz_app's `require_auth` middleware). Inside the
19
+ * dispatcher, `get_request_context(c)` is treated as guaranteed non-null and
20
+ * per-action auth is enforced on each message.
21
+ *
22
+ * @module
23
+ */
24
+ import { DEV } from 'esm-env';
25
+ import { wait } from '@fuzdev/fuz_util/async.js';
26
+ import { Logger } from '@fuzdev/fuz_util/log.js';
27
+ import { get_request_context, has_role } from '../auth/request_context.js';
28
+ import { hash_session_token } from '../auth/session_queries.js';
29
+ import { ROLE_KEEPER } from '../auth/role_schema.js';
30
+ import { JSONRPC_VERSION } from '../http/jsonrpc.js';
31
+ import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
32
+ import { create_jsonrpc_error_response, create_jsonrpc_error_response_from_thrown, create_jsonrpc_notification, to_jsonrpc_message_id, to_jsonrpc_params, is_jsonrpc_request, } from '../http/jsonrpc_helpers.js';
33
+ import { CREDENTIAL_TYPE_KEY, AUTH_API_TOKEN_ID_KEY } from '../hono_context.js';
34
+ import { BackendWebsocketTransport } from './transports_ws_backend.js';
35
+ /**
36
+ * Mount a JSON-RPC WebSocket endpoint that dispatches to the supplied handler
37
+ * map. Per-request context is built from the base + consumer-provided
38
+ * `RegisterActionWsOptions.extend_context`.
39
+ *
40
+ * Wire behavior:
41
+ * - Batch JSON-RPC is rejected (single-message only).
42
+ * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
43
+ * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
44
+ * already verified identity); `keeper` requires `daemon_token` credential
45
+ * type *and* the keeper role; role-based `{role}` is currently rejected as
46
+ * not-yet-supported.
47
+ * - DEV mode validates handler output against the spec's `output` schema and
48
+ * warns on mismatches.
49
+ *
50
+ * @returns the transport (supplied or freshly created) — retain it to wire
51
+ * `create_ws_auth_guard` or broadcast on audit events.
52
+ */
53
+ export const register_action_ws = (options) => {
54
+ const { path, app, upgradeWebSocket, specs, handlers, extend_context, transport = new BackendWebsocketTransport(), artificial_delay = 0, log = new Logger('[ws]'), } = options;
55
+ // Build spec lookup for per-action auth and input validation.
56
+ const spec_by_method = new Map(specs.map((spec) => [spec.method, spec]));
57
+ app.get(path, upgradeWebSocket((c) => {
58
+ // Upgrade-time auth extraction — `require_auth` middleware has already
59
+ // rejected unauthenticated requests, so request_context is guaranteed
60
+ // non-null by the time we get here.
61
+ const request_context = get_request_context(c);
62
+ const account_id = request_context.account.id;
63
+ const credential_type = c.get(CREDENTIAL_TYPE_KEY);
64
+ // Session-based connections have a token hash for targeted revocation.
65
+ // Bearer/daemon connections pass null — still reachable via
66
+ // `close_sockets_for_account` / `close_sockets_for_token`.
67
+ const token_hash = credential_type === 'session' ? hash_session_token(c.get('auth_session_id')) : null;
68
+ // `api_token.id` — set only for bearer connections; enables
69
+ // `close_sockets_for_token` to tear down just this socket on
70
+ // `token_revoke` without affecting the account's other sockets.
71
+ const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY);
72
+ // Per-socket abort controller — fires on socket close, threaded into
73
+ // every in-flight handler's ctx.signal on this connection. A
74
+ // dedicated per-request controller linked to this is future work;
75
+ // a single socket-scoped signal is sufficient today since cancel
76
+ // granularity tracks connection lifetime, not individual requests.
77
+ const socket_abort_controller = new AbortController();
78
+ return {
79
+ onOpen: (event, ws) => {
80
+ const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
81
+ log.debug('ws opened', connection_id, event);
82
+ },
83
+ onMessage: async (event, ws) => {
84
+ let json;
85
+ try {
86
+ json = JSON.parse(String(event.data)); // eslint-disable-line @typescript-eslint/no-base-to-string
87
+ }
88
+ catch (error) {
89
+ log.error('JSON parse error:', error);
90
+ ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.parse_error())));
91
+ return;
92
+ }
93
+ // Batch JSON-RPC is not supported on the WebSocket path.
94
+ if (Array.isArray(json)) {
95
+ ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'))));
96
+ return;
97
+ }
98
+ // Only handle requests (method + id). Notifications (no id) are silenced per JSON-RPC spec.
99
+ if (!is_jsonrpc_request(json)) {
100
+ if (typeof json === 'object' && json !== null && 'method' in json && !('id' in json)) {
101
+ return;
102
+ }
103
+ ws.send(JSON.stringify(create_jsonrpc_error_response(to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request())));
104
+ return;
105
+ }
106
+ const { method, id, params } = json;
107
+ // Per-action auth check — enforce auth level from spec.
108
+ const spec = spec_by_method.get(method);
109
+ if (!spec) {
110
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.method_not_found(method))));
111
+ return;
112
+ }
113
+ const { auth } = spec;
114
+ if (auth === 'keeper') {
115
+ if (credential_type !== 'daemon_token' || !has_role(request_context, ROLE_KEEPER)) {
116
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.forbidden('keeper actions require daemon_token credential with keeper role'))));
117
+ return;
118
+ }
119
+ }
120
+ else if (typeof auth === 'object' && auth !== null) {
121
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.internal_error('role-based action auth is not yet supported on WebSocket'))));
122
+ return;
123
+ }
124
+ // Look up handler — method is validated against spec_by_method above.
125
+ const handler = handlers[method];
126
+ if (!handler) {
127
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.method_not_found(method))));
128
+ return;
129
+ }
130
+ // Validate input against spec schema.
131
+ const parsed = spec.input.safeParse(params);
132
+ if (!parsed.success) {
133
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.invalid_params(`invalid params for ${method}`, {
134
+ issues: parsed.error.issues,
135
+ }))));
136
+ return;
137
+ }
138
+ const validated_input = parsed.data;
139
+ if (artificial_delay > 0) {
140
+ log.debug(`throttling ${artificial_delay}ms`);
141
+ await wait(artificial_delay);
142
+ }
143
+ // Socket-scoped notification — routes to originator only, not
144
+ // broadcast. Future work (websockets quest Phase 3/4): other
145
+ // audiences — account-scoped, ACL-filtered, broadcast —
146
+ // likely via a transport-level policy hook.
147
+ const notify = (notify_method, notify_params) => {
148
+ try {
149
+ const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
150
+ ws.send(JSON.stringify(notification));
151
+ }
152
+ catch (error) {
153
+ log.error('notify send failed:', notify_method, error);
154
+ }
155
+ };
156
+ const base = {
157
+ request_id: id,
158
+ notify,
159
+ signal: socket_abort_controller.signal,
160
+ };
161
+ const ctx = extend_context(base, c);
162
+ try {
163
+ const output = await handler(validated_input, ctx);
164
+ // DEV-only output validation — catches handler bugs during development.
165
+ if (DEV) {
166
+ const output_parsed = spec.output.safeParse(output);
167
+ if (!output_parsed.success) {
168
+ log.error(`output validation failed for ${method}:`, output_parsed.error.issues);
169
+ }
170
+ }
171
+ // Send result directly — null stays null, matching the HTTP RPC path.
172
+ ws.send(JSON.stringify({ jsonrpc: JSONRPC_VERSION, id, result: output }));
173
+ }
174
+ catch (error) {
175
+ log.error('handler error:', method, error);
176
+ ws.send(JSON.stringify(create_jsonrpc_error_response_from_thrown(id, error)));
177
+ }
178
+ },
179
+ onClose: (event, ws) => {
180
+ socket_abort_controller.abort();
181
+ transport.remove_connection(ws);
182
+ log.debug('ws closed', event);
183
+ },
184
+ };
185
+ }));
186
+ return { transport };
187
+ };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * WebSocket auth guard — bridges audit events to {@link BackendWebsocketTransport}.
2
+ * WebSocket auth guard — bridges audit events to `BackendWebsocketTransport`.
3
3
  *
4
4
  * Mirror of `realtime/sse_auth_guard.ts` for the backend WebSocket transport.
5
5
  * Dispatches audit events to the right `close_sockets_for_*` method so
@@ -1,5 +1,5 @@
1
1
  /**
2
- * WebSocket auth guard — bridges audit events to {@link BackendWebsocketTransport}.
2
+ * WebSocket auth guard — bridges audit events to `BackendWebsocketTransport`.
3
3
  *
4
4
  * Mirror of `realtime/sse_auth_guard.ts` for the backend WebSocket transport.
5
5
  * Dispatches audit events to the right `close_sockets_for_*` method so
@@ -14,7 +14,7 @@ import { type Transport } from './transports.js';
14
14
  * One record per connection. `token_hash` is set for cookie-session
15
15
  * connections, `api_token_id` for bearer (`api_token`) connections, and
16
16
  * both are null for daemon-token connections (reachable only via
17
- * {@link BackendWebsocketTransport.close_sockets_for_account}).
17
+ * `BackendWebsocketTransport.close_sockets_for_account`).
18
18
  */
19
19
  export interface ConnectionIdentity {
20
20
  /** Blake3 session token hash, or null for non-session credentials. */
@@ -34,7 +34,7 @@ export declare class BackendWebsocketTransport implements Transport {
34
34
  * socket can be closed when that specific token is revoked without
35
35
  * tearing down the account's other sockets. Daemon-token connections
36
36
  * pass `null` for both — they're only reachable via
37
- * {@link close_sockets_for_account}.
37
+ * `close_sockets_for_account`.
38
38
  */
39
39
  add_connection(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null): Uuid;
40
40
  /**
@@ -24,7 +24,7 @@ export class BackendWebsocketTransport {
24
24
  * socket can be closed when that specific token is revoked without
25
25
  * tearing down the account's other sockets. Daemon-token connections
26
26
  * pass `null` for both — they're only reachable via
27
- * {@link close_sockets_for_account}.
27
+ * `close_sockets_for_account`.
28
28
  */
29
29
  add_connection(ws, token_hash, account_id, api_token_id = null) {
30
30
  const connection_id = create_uuid();
@@ -61,7 +61,7 @@ export declare const create_keyring: (env_value: string | undefined) => Keyring
61
61
  *
62
62
  * Returns an error when no keys are configured (undefined, empty string,
63
63
  * or all-separator input like `'____'`), and for each key shorter than
64
- * {@link MIN_KEY_LENGTH} characters.
64
+ * `MIN_KEY_LENGTH` characters.
65
65
  *
66
66
  * @param env_value - the SECRET_COOKIE_KEYS environment variable
67
67
  * @returns array of validation errors (empty if valid)
@@ -75,7 +75,7 @@ export const create_keyring = (env_value) => {
75
75
  *
76
76
  * Returns an error when no keys are configured (undefined, empty string,
77
77
  * or all-separator input like `'____'`), and for each key shorter than
78
- * {@link MIN_KEY_LENGTH} characters.
78
+ * `MIN_KEY_LENGTH` characters.
79
79
  *
80
80
  * @param env_value - the SECRET_COOKIE_KEYS environment variable
81
81
  * @returns array of validation errors (empty if valid)
@@ -17,7 +17,9 @@ import type { SseStream, SseNotification, EventSpec } from './sse.js';
17
17
  /**
18
18
  * Audit event types that trigger SSE stream disconnection.
19
19
  *
20
- * `permit_revoke` requires the revoked role to match the guard's `required_role`.
20
+ * `permit_revoke` requires the revoked role to match the guard's `required_role`
21
+ * (or is skipped entirely when `required_role` is `null` — useful for streams
22
+ * not gated by any specific permit).
21
23
  * `session_revoke_all` and `password_change` close every stream for the target account.
22
24
  * `session_revoke` closes only the stream tied to the specific revoked session
23
25
  * (matched by the blake3 session hash in `event.metadata.session_id`) — closing
@@ -36,11 +38,13 @@ export declare const DISCONNECT_EVENT_TYPES: ReadonlySet<string>;
36
38
  * (passed as the third argument to `registry.subscribe()`).
37
39
  *
38
40
  * @param registry - the subscriber registry to guard
39
- * @param required_role - the role that grants access to the SSE endpoint
41
+ * @param required_role - the role that grants access to the SSE endpoint,
42
+ * or `null` to skip `permit_revoke` handling entirely (for streams not gated
43
+ * by a specific permit)
40
44
  * @param log - logger for disconnect events
41
45
  * @returns an `on_audit_event` callback
42
46
  */
43
- export declare const create_sse_auth_guard: <T>(registry: SubscriberRegistry<T>, required_role: string, log: Logger) => ((event: AuditLogEvent) => void);
47
+ export declare const create_sse_auth_guard: <T>(registry: SubscriberRegistry<T>, required_role: string | null, log: Logger) => ((event: AuditLogEvent) => void);
44
48
  /**
45
49
  * Convenience factory result for audit log SSE.
46
50
  *
@@ -1 +1 @@
1
- {"version":3,"file":"sse_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/realtime/sse_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAGN,KAAK,aAAa,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAAC,kBAAkB,EAAE,KAAK,gBAAgB,EAAC,MAAM,0BAA0B,CAAC;AACnF,OAAO,KAAK,EAAC,SAAS,EAAE,eAAe,EAAE,SAAS,EAAC,MAAM,UAAU,CAAC;AAEpE;;;;;;;;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"}
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;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,EACtC,UAAU,kBAAkB,CAAC,CAAC,CAAC,EAC/B,eAAe,MAAM,GAAG,IAAI,EAC5B,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA6CjC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC3B,8FAA8F;IAC9F,SAAS,EAAE,CAAC,MAAM,EAAE,SAAS,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;IAC1F,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAC;IACZ,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"}
@@ -15,7 +15,9 @@ import { SubscriberRegistry } from './subscriber_registry.js';
15
15
  /**
16
16
  * Audit event types that trigger SSE stream disconnection.
17
17
  *
18
- * `permit_revoke` requires the revoked role to match the guard's `required_role`.
18
+ * `permit_revoke` requires the revoked role to match the guard's `required_role`
19
+ * (or is skipped entirely when `required_role` is `null` — useful for streams
20
+ * not gated by any specific permit).
19
21
  * `session_revoke_all` and `password_change` close every stream for the target account.
20
22
  * `session_revoke` closes only the stream tied to the specific revoked session
21
23
  * (matched by the blake3 session hash in `event.metadata.session_id`) — closing
@@ -39,7 +41,9 @@ export const DISCONNECT_EVENT_TYPES = new Set([
39
41
  * (passed as the third argument to `registry.subscribe()`).
40
42
  *
41
43
  * @param registry - the subscriber registry to guard
42
- * @param required_role - the role that grants access to the SSE endpoint
44
+ * @param required_role - the role that grants access to the SSE endpoint,
45
+ * or `null` to skip `permit_revoke` handling entirely (for streams not gated
46
+ * by a specific permit)
43
47
  * @param log - logger for disconnect events
44
48
  * @returns an `on_audit_event` callback
45
49
  */
@@ -67,8 +71,11 @@ export const create_sse_auth_guard = (registry, required_role, log) => {
67
71
  }
68
72
  return;
69
73
  }
70
- // permit_revoke requires matching the specific role
74
+ // permit_revoke requires matching the specific role. `null` means the
75
+ // stream isn't gated by a specific permit, so permit_revoke is a no-op.
71
76
  if (event.event_type === 'permit_revoke') {
77
+ if (required_role === null)
78
+ return;
72
79
  if (event.metadata?.role !== required_role)
73
80
  return;
74
81
  }
@@ -5,7 +5,7 @@
5
5
  * `attempted` state (set on submit attempt). Errors show after a field is blurred or
6
6
  * after a submit attempt, avoiding premature validation while the user is still typing.
7
7
  *
8
- * The {@link FormState.form | form} attachment also handles Enter key advancing
8
+ * The `FormState.form` attachment also handles Enter key advancing
9
9
  * between focusable elements.
10
10
  *
11
11
  * All trackable inputs must have a `name` attribute — an error is thrown in dev
@@ -5,7 +5,7 @@
5
5
  * `attempted` state (set on submit attempt). Errors show after a field is blurred or
6
6
  * after a submit attempt, avoiding premature validation while the user is still typing.
7
7
  *
8
- * The {@link FormState.form | form} attachment also handles Enter key advancing
8
+ * The `FormState.form` attachment also handles Enter key advancing
9
9
  * between focusable elements.
10
10
  *
11
11
  * All trackable inputs must have a `name` attribute — an error is thrown in dev
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",