@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.
- package/dist/actions/broadcast_api.d.ts +99 -0
- package/dist/actions/broadcast_api.d.ts.map +1 -0
- package/dist/actions/broadcast_api.js +99 -0
- package/dist/actions/register_action_ws.d.ts +108 -0
- package/dist/actions/register_action_ws.d.ts.map +1 -0
- package/dist/actions/register_action_ws.js +187 -0
- package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
- package/dist/actions/transports_ws_auth_guard.js +1 -1
- package/dist/actions/transports_ws_backend.d.ts +29 -4
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +33 -1
- package/dist/auth/keyring.d.ts +1 -1
- package/dist/auth/keyring.js +1 -1
- package/dist/realtime/sse_auth_guard.d.ts +7 -3
- package/dist/realtime/sse_auth_guard.d.ts.map +1 -1
- package/dist/realtime/sse_auth_guard.js +10 -3
- package/dist/ui/form_state.svelte.d.ts +1 -1
- package/dist/ui/form_state.svelte.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
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
|
}
|
package/dist/auth/keyring.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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)
|
package/dist/auth/keyring.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|