@fuzdev/fuz_app 0.15.0 → 0.17.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,99 @@
1
+ /**
2
+ * Backend-initiated broadcast notification plumbing — generic across consumers.
3
+ *
4
+ * Builds a typed `{method_name: (input) => Promise<void>}` object from a list
5
+ * of action specs. Each call validates input against the spec, wraps it in a
6
+ * JSON-RPC notification, and either broadcasts to every connection or
7
+ * fans out with a per-connection ACL predicate.
8
+ *
9
+ * Counterpart to `register_action_ws`: that handles request-scoped dispatch
10
+ * (frontend-initiated), this handles broadcast (backend-initiated). Together
11
+ * they cover the two primitives fuz_app consumers share. Request-scoped
12
+ * streaming (`completion_progress`, `tx_apply` events) stays on
13
+ * `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
14
+ *
15
+ * Extracted from zzz's `backend_actions_api.ts` as part of the websockets
16
+ * quest (Phase 3) to stop the pattern from drifting across zzz, tx, and
17
+ * undying.
18
+ *
19
+ * @module
20
+ */
21
+ import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
22
+ import type { ActionPeer } from './action_peer.js';
23
+ import type { ActionSpecUnion } from './action_spec.js';
24
+ import { type ConnectionIdentity } from './transports_ws_backend.js';
25
+ /**
26
+ * Per-connection delivery predicate for subscription ACLs.
27
+ *
28
+ * Called once per connection for every broadcast send. Returning `false`
29
+ * skips that connection. Keep it fast — this runs in the broadcast hot path.
30
+ *
31
+ * `input` is the already-validated payload (matches the spec's input schema);
32
+ * `method` is the action method name.
33
+ */
34
+ export type ShouldDeliverFn = (connection: ConnectionIdentity, method: string, input: unknown) => boolean;
35
+ /** Options for `create_broadcast_api`. */
36
+ export interface CreateBroadcastApiOptions {
37
+ /** The peer holding the transport registry used for sends. */
38
+ peer: ActionPeer;
39
+ /**
40
+ * Notification specs to expose as broadcast methods. Typically the
41
+ * `remote_notification` specs whose initiator is `backend` (or `both`).
42
+ * Other kinds are accepted — the helper only uses `spec.method` and
43
+ * `spec.input` — but the typical use is notifications.
44
+ */
45
+ specs: ReadonlyArray<ActionSpecUnion>;
46
+ /** Logger for validation/send errors. Defaults to a `[broadcast]` namespace. */
47
+ log?: LoggerType | null;
48
+ /**
49
+ * Optional per-connection ACL predicate. When set, the broadcast fans out
50
+ * via the transport's `broadcast_filtered` (feature-detected) — each
51
+ * connection's identity is checked before the message is sent. When
52
+ * unset, the transport broadcasts unfiltered via `transport.send`.
53
+ *
54
+ * Requires a transport that implements `FilterableBroadcastTransport`
55
+ * (today: only `BackendWebsocketTransport`). If set and the active
56
+ * transport is not filterable, the send is skipped and an error logged.
57
+ */
58
+ should_deliver?: ShouldDeliverFn;
59
+ }
60
+ /**
61
+ * Loose base shape for a broadcast API. Consumers typically declare a
62
+ * stricter per-method interface (e.g. `BackendActionsApi`) and pin it via
63
+ * the type parameter on `create_broadcast_api`.
64
+ */
65
+ export type BroadcastApi = Record<string, (input: never) => Promise<void>>;
66
+ /**
67
+ * Builds a typed broadcast API from a set of action specs.
68
+ *
69
+ * For each spec, adds a method keyed by `spec.method` that:
70
+ * - Validates `input` against the spec's Zod schema (logs and returns on failure)
71
+ * - Creates a JSON-RPC notification from the validated input
72
+ * - Broadcasts via the peer (filtered by `should_deliver` when supplied)
73
+ *
74
+ * Silently returns when no transport is ready (e.g. before any clients
75
+ * connect). Errors during send are logged but never thrown — broadcasts are
76
+ * fire-and-forget from the handler's perspective.
77
+ *
78
+ * ## Typed consumer surface
79
+ *
80
+ * Consumers declare an explicit interface and pin it via the type parameter:
81
+ *
82
+ * ```ts
83
+ * export interface BackendActionsApi {
84
+ * filer_change: (input: ActionInputs['filer_change']) => Promise<void>;
85
+ * workspace_changed: (input: ActionInputs['workspace_changed']) => Promise<void>;
86
+ * }
87
+ *
88
+ * const api = create_broadcast_api<BackendActionsApi>({
89
+ * peer: backend.peer,
90
+ * specs: [filer_change_action_spec, workspace_changed_action_spec],
91
+ * });
92
+ * ```
93
+ *
94
+ * The cast is unchecked — callers must keep the interface and the `specs`
95
+ * array in sync. Codegen (`action_collections.gen.ts`) is a natural fit
96
+ * if the consumer already generates per-method type maps.
97
+ */
98
+ export declare const create_broadcast_api: <TApi = BroadcastApi>(options: CreateBroadcastApiOptions) => TApi;
99
+ //# sourceMappingURL=broadcast_api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAS,KAAK,MAAM,IAAI,UAAU,EAAC,MAAM,yBAAyB,CAAC;AAG1E,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AACjD,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAEN,KAAK,kBAAkB,EACvB,MAAM,4BAA4B,CAAC;AAEpC;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG,CAC7B,UAAU,EAAE,kBAAkB,EAC9B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,KACV,OAAO,CAAC;AAEb,0CAA0C;AAC1C,MAAM,WAAW,yBAAyB;IACzC,8DAA8D;IAC9D,IAAI,EAAE,UAAU,CAAC;IACjB;;;;;OAKG;IACH,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC,gFAAgF;IAChF,GAAG,CAAC,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB;;;;;;;;;OASG;IACH,cAAc,CAAC,EAAE,eAAe,CAAC;CACjC;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AAE3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,eAAO,MAAM,oBAAoB,GAAI,IAAI,GAAG,YAAY,EACvD,SAAS,yBAAyB,KAChC,IAoDF,CAAC"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Backend-initiated broadcast notification plumbing — generic across consumers.
3
+ *
4
+ * Builds a typed `{method_name: (input) => Promise<void>}` object from a list
5
+ * of action specs. Each call validates input against the spec, wraps it in a
6
+ * JSON-RPC notification, and either broadcasts to every connection or
7
+ * fans out with a per-connection ACL predicate.
8
+ *
9
+ * Counterpart to `register_action_ws`: that handles request-scoped dispatch
10
+ * (frontend-initiated), this handles broadcast (backend-initiated). Together
11
+ * they cover the two primitives fuz_app consumers share. Request-scoped
12
+ * streaming (`completion_progress`, `tx_apply` events) stays on
13
+ * `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
14
+ *
15
+ * Extracted from zzz's `backend_actions_api.ts` as part of the websockets
16
+ * quest (Phase 3) to stop the pattern from drifting across zzz, tx, and
17
+ * undying.
18
+ *
19
+ * @module
20
+ */
21
+ import { Logger } from '@fuzdev/fuz_util/log.js';
22
+ import { create_jsonrpc_notification, to_jsonrpc_params } from '../http/jsonrpc_helpers.js';
23
+ import { is_filterable_broadcast_transport, } from './transports_ws_backend.js';
24
+ /**
25
+ * Builds a typed broadcast API from a set of action specs.
26
+ *
27
+ * For each spec, adds a method keyed by `spec.method` that:
28
+ * - Validates `input` against the spec's Zod schema (logs and returns on failure)
29
+ * - Creates a JSON-RPC notification from the validated input
30
+ * - Broadcasts via the peer (filtered by `should_deliver` when supplied)
31
+ *
32
+ * Silently returns when no transport is ready (e.g. before any clients
33
+ * connect). Errors during send are logged but never thrown — broadcasts are
34
+ * fire-and-forget from the handler's perspective.
35
+ *
36
+ * ## Typed consumer surface
37
+ *
38
+ * Consumers declare an explicit interface and pin it via the type parameter:
39
+ *
40
+ * ```ts
41
+ * export interface BackendActionsApi {
42
+ * filer_change: (input: ActionInputs['filer_change']) => Promise<void>;
43
+ * workspace_changed: (input: ActionInputs['workspace_changed']) => Promise<void>;
44
+ * }
45
+ *
46
+ * const api = create_broadcast_api<BackendActionsApi>({
47
+ * peer: backend.peer,
48
+ * specs: [filer_change_action_spec, workspace_changed_action_spec],
49
+ * });
50
+ * ```
51
+ *
52
+ * The cast is unchecked — callers must keep the interface and the `specs`
53
+ * array in sync. Codegen (`action_collections.gen.ts`) is a natural fit
54
+ * if the consumer already generates per-method type maps.
55
+ */
56
+ export const create_broadcast_api = (options) => {
57
+ const { peer, specs, should_deliver } = options;
58
+ const log = options.log === undefined ? new Logger('[broadcast]') : options.log;
59
+ const api = {};
60
+ for (const spec of specs) {
61
+ const { method } = spec;
62
+ api[method] = async (input) => {
63
+ const parsed = spec.input.safeParse(input);
64
+ if (!parsed.success) {
65
+ log?.error(`[${method}] input validation failed:`, parsed.error.issues);
66
+ return;
67
+ }
68
+ // Resolve the broadcast target deterministically — no fallback.
69
+ // Broadcast is 1→N over a specific primary transport; falling through
70
+ // to "any ready transport" would send to an unexpected audience.
71
+ // Silent skip when no ready transport (e.g. before any clients connect).
72
+ const transport_name = peer.default_send_options.transport_name;
73
+ const transport = transport_name
74
+ ? peer.transports.get_transport_by_name(transport_name)
75
+ : peer.transports.get_current_transport();
76
+ if (!transport?.is_ready())
77
+ return;
78
+ const notification = create_jsonrpc_notification(method, to_jsonrpc_params(parsed.data));
79
+ try {
80
+ if (should_deliver) {
81
+ if (!is_filterable_broadcast_transport(transport)) {
82
+ log?.error(`[${method}] should_deliver set but transport ${transport.transport_name} does not support per-connection filtering`);
83
+ return;
84
+ }
85
+ transport.broadcast_filtered(notification, (identity) => should_deliver(identity, method, parsed.data));
86
+ return;
87
+ }
88
+ const result = await transport.send(notification);
89
+ if (result !== null) {
90
+ log?.error(`[${method}] failed to send notification:`, result.error);
91
+ }
92
+ }
93
+ catch (error) {
94
+ log?.error(`[${method}] unexpected error:`, error);
95
+ }
96
+ };
97
+ }
98
+ return api;
99
+ };
@@ -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
@@ -5,7 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import type { WSContext } from 'hono/ws';
8
- import type { JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
8
+ import type { JsonrpcMessageFromServerToClient, JsonrpcNotification, JsonrpcRequest, JsonrpcResponseOrError, JsonrpcErrorResponse } from '../http/jsonrpc.js';
9
9
  import { type Uuid } from '../uuid.js';
10
10
  import { type Transport } from './transports.js';
11
11
  /**
@@ -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. */
@@ -24,7 +24,21 @@ export interface ConnectionIdentity {
24
24
  /** `api_token.id` for bearer-authenticated connections, else null. */
25
25
  api_token_id: string | null;
26
26
  }
27
- export declare class BackendWebsocketTransport implements Transport {
27
+ /**
28
+ * Structural capability for transports that can broadcast with a
29
+ * per-connection ACL predicate. Named separately from `Transport` so the
30
+ * broadcast API can feature-detect without importing a concrete class.
31
+ *
32
+ * `ConnectionIdentity` is the auth-gated identity shape used today. When a
33
+ * second implementation (e.g. SSE backend transport) lands with a
34
+ * different identity, consider parameterizing on `TIdentity`.
35
+ */
36
+ export interface FilterableBroadcastTransport extends Transport {
37
+ broadcast_filtered: (message: JsonrpcMessageFromServerToClient, predicate: (identity: ConnectionIdentity) => boolean) => number;
38
+ }
39
+ /** Type guard for `FilterableBroadcastTransport`. */
40
+ export declare const is_filterable_broadcast_transport: (transport: Transport) => transport is FilterableBroadcastTransport;
41
+ export declare class BackendWebsocketTransport implements FilterableBroadcastTransport {
28
42
  #private;
29
43
  readonly transport_name: "backend_websocket_rpc";
30
44
  /**
@@ -34,7 +48,7 @@ export declare class BackendWebsocketTransport implements Transport {
34
48
  * socket can be closed when that specific token is revoked without
35
49
  * tearing down the account's other sockets. Daemon-token connections
36
50
  * pass `null` for both — they're only reachable via
37
- * {@link close_sockets_for_account}.
51
+ * `close_sockets_for_account`.
38
52
  */
39
53
  add_connection(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null): Uuid;
40
54
  /**
@@ -66,6 +80,17 @@ export declare class BackendWebsocketTransport implements Transport {
66
80
  close_sockets_for_token(api_token_id: string): number;
67
81
  send(message: JsonrpcRequest): Promise<JsonrpcResponseOrError>;
68
82
  send(message: JsonrpcNotification): Promise<JsonrpcErrorResponse | null>;
83
+ /**
84
+ * Broadcast to connections whose identity satisfies a predicate.
85
+ *
86
+ * Used by the broadcast API when a consumer supplies a subscription ACL hook
87
+ * (e.g. tx's `tx_run_created` only reaches the account that owns the run).
88
+ * When no ACL is needed, callers should prefer `send(message)` / `#broadcast`
89
+ * to skip the per-connection predicate overhead.
90
+ *
91
+ * @returns the number of sockets the message was sent to
92
+ */
93
+ broadcast_filtered(message: JsonrpcMessageFromServerToClient, predicate: (identity: ConnectionIdentity) => boolean): number;
69
94
  is_ready(): boolean;
70
95
  }
71
96
  //# sourceMappingURL=transports_ws_backend.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAGX,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAO5B,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,YAAY,CAAC;AAClD,OAAO,EAA2B,KAAK,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAIzE;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IAClC,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,4CAA4C;IAC5C,UAAU,EAAE,IAAI,CAAC;IACjB,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,qBAAa,yBAA0B,YAAW,SAAS;;IAC1D,QAAQ,CAAC,cAAc,EAAG,uBAAuB,CAAU;IAY3D;;;;;;;;OAQG;IACH,cAAc,CACb,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,IAAI,EAChB,YAAY,GAAE,MAAM,GAAG,IAAW,GAChC,IAAI;IAQP;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,SAAS,GAAG,IAAI;IA0BtC;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAIrD;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM;IAInD;;;;;;;;OAQG;IACH,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAsB/C,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA4C9E,QAAQ,IAAI,OAAO;CAGnB"}
1
+ {"version":3,"file":"transports_ws_backend.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,SAAS,CAAC;AAEvC,OAAO,KAAK,EAEX,gCAAgC,EAChC,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,oBAAoB,EACpB,MAAM,oBAAoB,CAAC;AAO5B,OAAO,EAAc,KAAK,IAAI,EAAC,MAAM,YAAY,CAAC;AAClD,OAAO,EAA2B,KAAK,SAAS,EAAC,MAAM,iBAAiB,CAAC;AAIzE;;;;;;;GAOG;AACH,MAAM,WAAW,kBAAkB;IAClC,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,4CAA4C;IAC5C,UAAU,EAAE,IAAI,CAAC;IACjB,sEAAsE;IACtE,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,4BAA6B,SAAQ,SAAS;IAC9D,kBAAkB,EAAE,CACnB,OAAO,EAAE,gCAAgC,EACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,KAChD,MAAM,CAAC;CACZ;AAED,qDAAqD;AACrD,eAAO,MAAM,iCAAiC,GAC7C,WAAW,SAAS,KAClB,SAAS,IAAI,4BAEqE,CAAC;AAEtF,qBAAa,yBAA0B,YAAW,4BAA4B;;IAC7E,QAAQ,CAAC,cAAc,EAAG,uBAAuB,CAAU;IAY3D;;;;;;;;OAQG;IACH,cAAc,CACb,EAAE,EAAE,SAAS,EACb,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,UAAU,EAAE,IAAI,EAChB,YAAY,GAAE,MAAM,GAAG,IAAW,GAChC,IAAI;IAQP;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,SAAS,GAAG,IAAI;IA0BtC;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAIrD;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM;IAInD;;;;;;;;OAQG;IACH,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM;IAsB/C,IAAI,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,sBAAsB,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IA4C9E;;;;;;;;;OASG;IACH,kBAAkB,CACjB,OAAO,EAAE,gCAAgC,EACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,kBAAkB,KAAK,OAAO,GAClD,MAAM;IAoBT,QAAQ,IAAI,OAAO;CAGnB"}
@@ -8,6 +8,9 @@ import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
8
8
  import { create_jsonrpc_error_response, to_jsonrpc_message_id, is_jsonrpc_request, } from '../http/jsonrpc_helpers.js';
9
9
  import { create_uuid } from '../uuid.js';
10
10
  import { WS_CLOSE_SESSION_REVOKED } from './transports.js';
11
+ /** Type guard for `FilterableBroadcastTransport`. */
12
+ export const is_filterable_broadcast_transport = (transport) => 'broadcast_filtered' in transport &&
13
+ typeof transport.broadcast_filtered === 'function';
11
14
  export class BackendWebsocketTransport {
12
15
  transport_name = 'backend_websocket_rpc';
13
16
  // Map connection IDs to WebSocket contexts
@@ -24,7 +27,7 @@ export class BackendWebsocketTransport {
24
27
  * socket can be closed when that specific token is revoked without
25
28
  * tearing down the account's other sockets. Daemon-token connections
26
29
  * pass `null` for both — they're only reachable via
27
- * {@link close_sockets_for_account}.
30
+ * `close_sockets_for_account`.
28
31
  */
29
32
  add_connection(ws, token_hash, account_id, api_token_id = null) {
30
33
  const connection_id = create_uuid();
@@ -135,6 +138,35 @@ export class BackendWebsocketTransport {
135
138
  // TODO hack - remove if not ever needed, I assume this will need to be async so let's hold that assumption
136
139
  return Promise.resolve();
137
140
  }
141
+ /**
142
+ * Broadcast to connections whose identity satisfies a predicate.
143
+ *
144
+ * Used by the broadcast API when a consumer supplies a subscription ACL hook
145
+ * (e.g. tx's `tx_run_created` only reaches the account that owns the run).
146
+ * When no ACL is needed, callers should prefer `send(message)` / `#broadcast`
147
+ * to skip the per-connection predicate overhead.
148
+ *
149
+ * @returns the number of sockets the message was sent to
150
+ */
151
+ broadcast_filtered(message, predicate) {
152
+ const serialized = JSON.stringify(message);
153
+ let count = 0;
154
+ for (const [connection_id, identity] of this.#connection_identities) {
155
+ if (!predicate(identity))
156
+ continue;
157
+ const ws = this.#connections.get(connection_id);
158
+ if (!ws)
159
+ continue;
160
+ try {
161
+ ws.send(serialized);
162
+ count++;
163
+ }
164
+ catch (error) {
165
+ console.error('[backend websocket transport] Error broadcasting filtered to client:', error);
166
+ }
167
+ }
168
+ return count;
169
+ }
138
170
  is_ready() {
139
171
  return this.#connections.size > 0;
140
172
  }
@@ -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.17.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",