@fuzdev/fuz_app 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * WebSocket JSON-RPC dispatch — the canonical WS transport binding.
3
+ *
4
+ * Symmetric to `create_rpc_endpoint` (from `actions/action_rpc.ts`):
5
+ * consumer supplies action specs + a handler map, the dispatcher parses the
6
+ * envelope, checks per-action auth, validates input, invokes the handler with
7
+ * a per-request context, and writes the response.
8
+ *
9
+ * Extracted from zzz's `register_websocket_actions` to converge pattern drift
10
+ * across consumers (zzz, tx, undying). Broadcast-style notifications remain
11
+ * domain-shaped today — this module only covers per-request dispatch + the
12
+ * socket-scoped `ctx.notify` + per-socket `ctx.signal`. See
13
+ * `BackendWebsocketTransport.send` for broadcast.
14
+ *
15
+ * ## Auth expectations
16
+ *
17
+ * The consumer is responsible for rejecting unauthenticated upgrades *before*
18
+ * routing to this handler (fuz_app's `require_auth` middleware). Inside the
19
+ * dispatcher, `get_request_context(c)` is treated as guaranteed non-null and
20
+ * per-action auth is enforced on each message.
21
+ *
22
+ * @module
23
+ */
24
+ import type { Context, Hono } from 'hono';
25
+ import type { UpgradeWebSocket } from 'hono/ws';
26
+ import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
27
+ import { type JsonrpcRequestId } from '../http/jsonrpc.js';
28
+ import type { ActionSpecUnion } from './action_spec.js';
29
+ import { BackendWebsocketTransport } from './transports_ws_backend.js';
30
+ /**
31
+ * Minimum per-request context every handler receives.
32
+ *
33
+ * Consumers extend this with domain-specific fields via
34
+ * `RegisterActionWsOptions.extend_context` (e.g., a `backend` singleton
35
+ * or the authenticated `RequestContext`). Keeping the base minimal matches
36
+ * the HTTP-side `ActionContext` (from `actions/action_rpc.ts`) and mirrors
37
+ * Rust's `Ctx<'a>` shape (`request_id` + `NotifyFn` + `CancellationToken`).
38
+ */
39
+ export interface BaseHandlerContext {
40
+ /** JSON-RPC envelope request id — echoed back on the response. */
41
+ request_id: JsonrpcRequestId;
42
+ /**
43
+ * Send a request-scoped JSON-RPC notification to the originating socket.
44
+ * Not a broadcast — the message only reaches the client whose request
45
+ * triggered this handler. Streaming handlers (e.g. `completion_progress`)
46
+ * route chunks through this.
47
+ */
48
+ notify: (method: string, params: unknown) => void;
49
+ /** Fires on socket close; streaming handlers poll for early termination. */
50
+ signal: AbortSignal;
51
+ }
52
+ /** Handler signature — receives validated input and per-request context. */
53
+ export type WsActionHandler<TCtx extends BaseHandlerContext> = (input: unknown, ctx: TCtx) => unknown;
54
+ /** Options for `register_action_ws`. */
55
+ export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
56
+ /** Mount path (e.g., `/api/ws`). */
57
+ path: string;
58
+ /** The Hono app to mount on. */
59
+ app: Hono;
60
+ /** Hono's `upgradeWebSocket` helper from the runtime adapter. */
61
+ upgradeWebSocket: UpgradeWebSocket;
62
+ /** Action specs — drives method lookup, per-action auth, and input/output validation. */
63
+ specs: ReadonlyArray<ActionSpecUnion>;
64
+ /** Handler map keyed by `spec.method`. */
65
+ handlers: Record<string, WsActionHandler<TCtx>>;
66
+ /**
67
+ * Build the per-request context from the base and the upgrade-time Hono
68
+ * context. Called once per incoming message. Consumers use this to attach
69
+ * domain singletons (`backend`) or per-socket auth (`auth`,
70
+ * `credential_type`) without re-reading them from `c` inside every handler.
71
+ */
72
+ extend_context: (base: BaseHandlerContext, c: Context) => TCtx;
73
+ /**
74
+ * Existing transport to register connections with. When omitted, a fresh
75
+ * one is created and returned in the result. Pass your own to keep a
76
+ * handle for `create_ws_auth_guard` and `send_to`/`broadcast`.
77
+ */
78
+ transport?: BackendWebsocketTransport;
79
+ /** Optional per-message delay for testing loading states. Ignored when `0`. */
80
+ artificial_delay?: number;
81
+ /** Optional logger; defaults to `[ws]` namespace. */
82
+ log?: LoggerType;
83
+ }
84
+ /** Result of `register_action_ws`. */
85
+ export interface RegisterActionWsResult {
86
+ /** The transport bound to the endpoint — supplied or freshly created. */
87
+ transport: BackendWebsocketTransport;
88
+ }
89
+ /**
90
+ * Mount a JSON-RPC WebSocket endpoint that dispatches to the supplied handler
91
+ * map. Per-request context is built from the base + consumer-provided
92
+ * `RegisterActionWsOptions.extend_context`.
93
+ *
94
+ * Wire behavior:
95
+ * - Batch JSON-RPC is rejected (single-message only).
96
+ * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
97
+ * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
98
+ * already verified identity); `keeper` requires `daemon_token` credential
99
+ * type *and* the keeper role; role-based `{role}` is currently rejected as
100
+ * not-yet-supported.
101
+ * - DEV mode validates handler output against the spec's `output` schema and
102
+ * warns on mismatches.
103
+ *
104
+ * @returns the transport (supplied or freshly created) — retain it to wire
105
+ * `create_ws_auth_guard` or broadcast on audit events.
106
+ */
107
+ export declare const register_action_ws: <TCtx extends BaseHandlerContext>(options: RegisterActionWsOptions<TCtx>) => RegisterActionWsResult;
108
+ //# sourceMappingURL=register_action_ws.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAC,OAAO,EAAE,IAAI,EAAC,MAAM,MAAM,CAAC;AACxC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,SAAS,CAAC;AAE9C,OAAO,EAAS,KAAK,MAAM,IAAI,UAAU,EAAC,MAAM,yBAAyB,CAAC;AAK1E,OAAO,EAAkB,KAAK,gBAAgB,EAAC,MAAM,oBAAoB,CAAC;AAY1E,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAC,yBAAyB,EAAC,MAAM,4BAA4B,CAAC;AAErE;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IAClC,kEAAkE;IAClE,UAAU,EAAE,gBAAgB,CAAC;IAC7B;;;;;OAKG;IACH,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAClD,4EAA4E;IAC5E,MAAM,EAAE,WAAW,CAAC;CACpB;AAED,4EAA4E;AAC5E,MAAM,MAAM,eAAe,CAAC,IAAI,SAAS,kBAAkB,IAAI,CAC9D,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,IAAI,KACL,OAAO,CAAC;AAEb,wCAAwC;AACxC,MAAM,WAAW,uBAAuB,CAAC,IAAI,SAAS,kBAAkB;IACvE,oCAAoC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,gCAAgC;IAChC,GAAG,EAAE,IAAI,CAAC;IACV,iEAAiE;IACjE,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,yFAAyF;IACzF,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IACtC,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC;IAChD;;;;;OAKG;IACH,cAAc,EAAE,CAAC,IAAI,EAAE,kBAAkB,EAAE,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;IAC/D;;;;OAIG;IACH,SAAS,CAAC,EAAE,yBAAyB,CAAC;IACtC,+EAA+E;IAC/E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,qDAAqD;IACrD,GAAG,CAAC,EAAE,UAAU,CAAC;CACjB;AAED,sCAAsC;AACtC,MAAM,WAAW,sBAAsB;IACtC,yEAAyE;IACzE,SAAS,EAAE,yBAAyB,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,kBAAkB,GAAI,IAAI,SAAS,kBAAkB,EACjE,SAAS,uBAAuB,CAAC,IAAI,CAAC,KACpC,sBA0NF,CAAC"}
@@ -0,0 +1,187 @@
1
+ /**
2
+ * WebSocket JSON-RPC dispatch — the canonical WS transport binding.
3
+ *
4
+ * Symmetric to `create_rpc_endpoint` (from `actions/action_rpc.ts`):
5
+ * consumer supplies action specs + a handler map, the dispatcher parses the
6
+ * envelope, checks per-action auth, validates input, invokes the handler with
7
+ * a per-request context, and writes the response.
8
+ *
9
+ * Extracted from zzz's `register_websocket_actions` to converge pattern drift
10
+ * across consumers (zzz, tx, undying). Broadcast-style notifications remain
11
+ * domain-shaped today — this module only covers per-request dispatch + the
12
+ * socket-scoped `ctx.notify` + per-socket `ctx.signal`. See
13
+ * `BackendWebsocketTransport.send` for broadcast.
14
+ *
15
+ * ## Auth expectations
16
+ *
17
+ * The consumer is responsible for rejecting unauthenticated upgrades *before*
18
+ * routing to this handler (fuz_app's `require_auth` middleware). Inside the
19
+ * dispatcher, `get_request_context(c)` is treated as guaranteed non-null and
20
+ * per-action auth is enforced on each message.
21
+ *
22
+ * @module
23
+ */
24
+ import { DEV } from 'esm-env';
25
+ import { wait } from '@fuzdev/fuz_util/async.js';
26
+ import { Logger } from '@fuzdev/fuz_util/log.js';
27
+ import { get_request_context, has_role } from '../auth/request_context.js';
28
+ import { hash_session_token } from '../auth/session_queries.js';
29
+ import { ROLE_KEEPER } from '../auth/role_schema.js';
30
+ import { JSONRPC_VERSION } from '../http/jsonrpc.js';
31
+ import { jsonrpc_error_messages } from '../http/jsonrpc_errors.js';
32
+ import { create_jsonrpc_error_response, create_jsonrpc_error_response_from_thrown, create_jsonrpc_notification, to_jsonrpc_message_id, to_jsonrpc_params, is_jsonrpc_request, } from '../http/jsonrpc_helpers.js';
33
+ import { CREDENTIAL_TYPE_KEY, AUTH_API_TOKEN_ID_KEY } from '../hono_context.js';
34
+ import { BackendWebsocketTransport } from './transports_ws_backend.js';
35
+ /**
36
+ * Mount a JSON-RPC WebSocket endpoint that dispatches to the supplied handler
37
+ * map. Per-request context is built from the base + consumer-provided
38
+ * `RegisterActionWsOptions.extend_context`.
39
+ *
40
+ * Wire behavior:
41
+ * - Batch JSON-RPC is rejected (single-message only).
42
+ * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
43
+ * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
44
+ * already verified identity); `keeper` requires `daemon_token` credential
45
+ * type *and* the keeper role; role-based `{role}` is currently rejected as
46
+ * not-yet-supported.
47
+ * - DEV mode validates handler output against the spec's `output` schema and
48
+ * warns on mismatches.
49
+ *
50
+ * @returns the transport (supplied or freshly created) — retain it to wire
51
+ * `create_ws_auth_guard` or broadcast on audit events.
52
+ */
53
+ export const register_action_ws = (options) => {
54
+ const { path, app, upgradeWebSocket, specs, handlers, extend_context, transport = new BackendWebsocketTransport(), artificial_delay = 0, log = new Logger('[ws]'), } = options;
55
+ // Build spec lookup for per-action auth and input validation.
56
+ const spec_by_method = new Map(specs.map((spec) => [spec.method, spec]));
57
+ app.get(path, upgradeWebSocket((c) => {
58
+ // Upgrade-time auth extraction — `require_auth` middleware has already
59
+ // rejected unauthenticated requests, so request_context is guaranteed
60
+ // non-null by the time we get here.
61
+ const request_context = get_request_context(c);
62
+ const account_id = request_context.account.id;
63
+ const credential_type = c.get(CREDENTIAL_TYPE_KEY);
64
+ // Session-based connections have a token hash for targeted revocation.
65
+ // Bearer/daemon connections pass null — still reachable via
66
+ // `close_sockets_for_account` / `close_sockets_for_token`.
67
+ const token_hash = credential_type === 'session' ? hash_session_token(c.get('auth_session_id')) : null;
68
+ // `api_token.id` — set only for bearer connections; enables
69
+ // `close_sockets_for_token` to tear down just this socket on
70
+ // `token_revoke` without affecting the account's other sockets.
71
+ const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY);
72
+ // Per-socket abort controller — fires on socket close, threaded into
73
+ // every in-flight handler's ctx.signal on this connection. A
74
+ // dedicated per-request controller linked to this is future work;
75
+ // a single socket-scoped signal is sufficient today since cancel
76
+ // granularity tracks connection lifetime, not individual requests.
77
+ const socket_abort_controller = new AbortController();
78
+ return {
79
+ onOpen: (event, ws) => {
80
+ const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
81
+ log.debug('ws opened', connection_id, event);
82
+ },
83
+ onMessage: async (event, ws) => {
84
+ let json;
85
+ try {
86
+ json = JSON.parse(String(event.data)); // eslint-disable-line @typescript-eslint/no-base-to-string
87
+ }
88
+ catch (error) {
89
+ log.error('JSON parse error:', error);
90
+ ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.parse_error())));
91
+ return;
92
+ }
93
+ // Batch JSON-RPC is not supported on the WebSocket path.
94
+ if (Array.isArray(json)) {
95
+ ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.invalid_request('batch JSON-RPC requests are not supported on WebSocket'))));
96
+ return;
97
+ }
98
+ // Only handle requests (method + id). Notifications (no id) are silenced per JSON-RPC spec.
99
+ if (!is_jsonrpc_request(json)) {
100
+ if (typeof json === 'object' && json !== null && 'method' in json && !('id' in json)) {
101
+ return;
102
+ }
103
+ ws.send(JSON.stringify(create_jsonrpc_error_response(to_jsonrpc_message_id(json), jsonrpc_error_messages.invalid_request())));
104
+ return;
105
+ }
106
+ const { method, id, params } = json;
107
+ // Per-action auth check — enforce auth level from spec.
108
+ const spec = spec_by_method.get(method);
109
+ if (!spec) {
110
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.method_not_found(method))));
111
+ return;
112
+ }
113
+ const { auth } = spec;
114
+ if (auth === 'keeper') {
115
+ if (credential_type !== 'daemon_token' || !has_role(request_context, ROLE_KEEPER)) {
116
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.forbidden('keeper actions require daemon_token credential with keeper role'))));
117
+ return;
118
+ }
119
+ }
120
+ else if (typeof auth === 'object' && auth !== null) {
121
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.internal_error('role-based action auth is not yet supported on WebSocket'))));
122
+ return;
123
+ }
124
+ // Look up handler — method is validated against spec_by_method above.
125
+ const handler = handlers[method];
126
+ if (!handler) {
127
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.method_not_found(method))));
128
+ return;
129
+ }
130
+ // Validate input against spec schema.
131
+ const parsed = spec.input.safeParse(params);
132
+ if (!parsed.success) {
133
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.invalid_params(`invalid params for ${method}`, {
134
+ issues: parsed.error.issues,
135
+ }))));
136
+ return;
137
+ }
138
+ const validated_input = parsed.data;
139
+ if (artificial_delay > 0) {
140
+ log.debug(`throttling ${artificial_delay}ms`);
141
+ await wait(artificial_delay);
142
+ }
143
+ // Socket-scoped notification — routes to originator only, not
144
+ // broadcast. Future work (websockets quest Phase 3/4): other
145
+ // audiences — account-scoped, ACL-filtered, broadcast —
146
+ // likely via a transport-level policy hook.
147
+ const notify = (notify_method, notify_params) => {
148
+ try {
149
+ const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
150
+ ws.send(JSON.stringify(notification));
151
+ }
152
+ catch (error) {
153
+ log.error('notify send failed:', notify_method, error);
154
+ }
155
+ };
156
+ const base = {
157
+ request_id: id,
158
+ notify,
159
+ signal: socket_abort_controller.signal,
160
+ };
161
+ const ctx = extend_context(base, c);
162
+ try {
163
+ const output = await handler(validated_input, ctx);
164
+ // DEV-only output validation — catches handler bugs during development.
165
+ if (DEV) {
166
+ const output_parsed = spec.output.safeParse(output);
167
+ if (!output_parsed.success) {
168
+ log.error(`output validation failed for ${method}:`, output_parsed.error.issues);
169
+ }
170
+ }
171
+ // Send result directly — null stays null, matching the HTTP RPC path.
172
+ ws.send(JSON.stringify({ jsonrpc: JSONRPC_VERSION, id, result: output }));
173
+ }
174
+ catch (error) {
175
+ log.error('handler error:', method, error);
176
+ ws.send(JSON.stringify(create_jsonrpc_error_response_from_thrown(id, error)));
177
+ }
178
+ },
179
+ onClose: (event, ws) => {
180
+ socket_abort_controller.abort();
181
+ transport.remove_connection(ws);
182
+ log.debug('ws closed', event);
183
+ },
184
+ };
185
+ }));
186
+ return { transport };
187
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WebSocket auth guard — bridges audit events to `BackendWebsocketTransport`.
3
+ *
4
+ * Mirror of `realtime/sse_auth_guard.ts` for the backend WebSocket transport.
5
+ * Dispatches audit events to the right `close_sockets_for_*` method so
6
+ * consumers do not re-implement the switch themselves.
7
+ *
8
+ * Consumers wire it as `on_audit_event` on their `AppBackend` (or compose
9
+ * it with other callbacks via `create_app_server`'s `audit_log_sse` path).
10
+ *
11
+ * @module
12
+ */
13
+ import type { Logger } from '@fuzdev/fuz_util/log.js';
14
+ import type { AuditLogEvent } from '../auth/audit_log_schema.js';
15
+ import type { BackendWebsocketTransport } from './transports_ws_backend.js';
16
+ /**
17
+ * Audit event types that trigger WebSocket socket closure.
18
+ *
19
+ * - `session_revoke` — close only the socket tied to the revoked session hash.
20
+ * - `token_revoke` — close only the socket(s) authenticated with the revoked `api_token.id`.
21
+ * - `session_revoke_all` / `token_revoke_all` / `password_change` — close every socket
22
+ * for the affected account (all credentials invalidated).
23
+ *
24
+ * `permit_revoke` is intentionally omitted: the WS transport does not track
25
+ * per-connection role requirements, so role-scoped disconnection would
26
+ * require either closing all sockets (too aggressive) or new tracking
27
+ * (out of scope). Consumers that need it compose their own callback.
28
+ */
29
+ export declare const WS_DISCONNECT_EVENT_TYPES: ReadonlySet<string>;
30
+ /**
31
+ * Create an audit event handler that closes WebSocket connections on auth changes.
32
+ *
33
+ * Ignores `outcome === 'failure'` events — they carry attacker-controlled
34
+ * identifiers (e.g. a `session_revoke` that the DB rejected still records
35
+ * the submitted session_id), so reacting to them would let any authenticated
36
+ * user close another user's socket by guessing a session hash or token id.
37
+ *
38
+ * @param transport - the backend WebSocket transport to guard
39
+ * @param log - logger for disconnect events (info level on non-zero closures)
40
+ * @returns an `on_audit_event` callback suitable for `CreateAppBackendOptions`
41
+ */
42
+ export declare const create_ws_auth_guard: (transport: BackendWebsocketTransport, log: Logger) => ((event: AuditLogEvent) => void);
43
+ //# sourceMappingURL=transports_ws_auth_guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transports_ws_auth_guard.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/transports_ws_auth_guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,6BAA6B,CAAC;AAE/D,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,4BAA4B,CAAC;AAE1E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,yBAAyB,EAAE,WAAW,CAAC,MAAM,CAMxD,CAAC;AAEH;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,GAChC,WAAW,yBAAyB,EACpC,KAAK,MAAM,KACT,CAAC,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CA6CjC,CAAC"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * WebSocket auth guard — bridges audit events to `BackendWebsocketTransport`.
3
+ *
4
+ * Mirror of `realtime/sse_auth_guard.ts` for the backend WebSocket transport.
5
+ * Dispatches audit events to the right `close_sockets_for_*` method so
6
+ * consumers do not re-implement the switch themselves.
7
+ *
8
+ * Consumers wire it as `on_audit_event` on their `AppBackend` (or compose
9
+ * it with other callbacks via `create_app_server`'s `audit_log_sse` path).
10
+ *
11
+ * @module
12
+ */
13
+ /**
14
+ * Audit event types that trigger WebSocket socket closure.
15
+ *
16
+ * - `session_revoke` — close only the socket tied to the revoked session hash.
17
+ * - `token_revoke` — close only the socket(s) authenticated with the revoked `api_token.id`.
18
+ * - `session_revoke_all` / `token_revoke_all` / `password_change` — close every socket
19
+ * for the affected account (all credentials invalidated).
20
+ *
21
+ * `permit_revoke` is intentionally omitted: the WS transport does not track
22
+ * per-connection role requirements, so role-scoped disconnection would
23
+ * require either closing all sockets (too aggressive) or new tracking
24
+ * (out of scope). Consumers that need it compose their own callback.
25
+ */
26
+ export const WS_DISCONNECT_EVENT_TYPES = new Set([
27
+ 'session_revoke',
28
+ 'token_revoke',
29
+ 'session_revoke_all',
30
+ 'token_revoke_all',
31
+ 'password_change',
32
+ ]);
33
+ /**
34
+ * Create an audit event handler that closes WebSocket connections on auth changes.
35
+ *
36
+ * Ignores `outcome === 'failure'` events — they carry attacker-controlled
37
+ * identifiers (e.g. a `session_revoke` that the DB rejected still records
38
+ * the submitted session_id), so reacting to them would let any authenticated
39
+ * user close another user's socket by guessing a session hash or token id.
40
+ *
41
+ * @param transport - the backend WebSocket transport to guard
42
+ * @param log - logger for disconnect events (info level on non-zero closures)
43
+ * @returns an `on_audit_event` callback suitable for `CreateAppBackendOptions`
44
+ */
45
+ export const create_ws_auth_guard = (transport, log) => {
46
+ return (event) => {
47
+ if (!WS_DISCONNECT_EVENT_TYPES.has(event.event_type))
48
+ return;
49
+ // Failed mutations carry attacker-controlled metadata — never act on them.
50
+ if (event.outcome === 'failure')
51
+ return;
52
+ if (event.event_type === 'session_revoke') {
53
+ const session_id = event.metadata?.session_id;
54
+ if (typeof session_id !== 'string' || session_id.length === 0)
55
+ return;
56
+ const closed = transport.close_sockets_for_session(session_id);
57
+ if (closed > 0) {
58
+ log.info(`WS auth guard: closed ${closed} socket(s) for session ${session_id} (session_revoke)`);
59
+ }
60
+ return;
61
+ }
62
+ if (event.event_type === 'token_revoke') {
63
+ const token_id = event.metadata?.token_id;
64
+ if (typeof token_id !== 'string' || token_id.length === 0)
65
+ return;
66
+ const closed = transport.close_sockets_for_token(token_id);
67
+ if (closed > 0) {
68
+ log.info(`WS auth guard: closed ${closed} socket(s) for token ${token_id} (token_revoke)`);
69
+ }
70
+ return;
71
+ }
72
+ // session_revoke_all / token_revoke_all / password_change — all of the
73
+ // account's credentials are invalidated; close every socket on the account.
74
+ // Admin actions set `target_account_id`; self-service actions only set `account_id`.
75
+ const target = event.target_account_id ?? event.account_id;
76
+ if (!target)
77
+ return;
78
+ // `target` is a DB account id (string); the transport's account map is
79
+ // keyed by the branded `Uuid` used elsewhere in fuz_app. Same value,
80
+ // differing type disciplines across the audit-log and transport layers.
81
+ const closed = transport.close_sockets_for_account(target);
82
+ if (closed > 0) {
83
+ log.info(`WS auth guard: closed ${closed} socket(s) for account ${target} (${event.event_type})`);
84
+ }
85
+ };
86
+ };
@@ -8,16 +8,35 @@ import type { WSContext } from 'hono/ws';
8
8
  import type { 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
+ /**
12
+ * Auth identity attached to a single WebSocket connection.
13
+ *
14
+ * One record per connection. `token_hash` is set for cookie-session
15
+ * connections, `api_token_id` for bearer (`api_token`) connections, and
16
+ * both are null for daemon-token connections (reachable only via
17
+ * `BackendWebsocketTransport.close_sockets_for_account`).
18
+ */
19
+ export interface ConnectionIdentity {
20
+ /** Blake3 session token hash, or null for non-session credentials. */
21
+ token_hash: string | null;
22
+ /** Authenticated account id. Always set. */
23
+ account_id: Uuid;
24
+ /** `api_token.id` for bearer-authenticated connections, else null. */
25
+ api_token_id: string | null;
26
+ }
11
27
  export declare class BackendWebsocketTransport implements Transport {
12
28
  #private;
13
29
  readonly transport_name: "backend_websocket_rpc";
14
30
  /**
15
31
  * Add a new WebSocket connection with auth info.
16
32
  * Session connections pass a token hash for targeted revocation.
17
- * Bearer token connections (api_token, daemon_token) pass null
18
- * they're still reachable via {@link close_sockets_for_account}.
33
+ * Bearer token connections (api_token) pass the `api_token.id` so the
34
+ * socket can be closed when that specific token is revoked without
35
+ * tearing down the account's other sockets. Daemon-token connections
36
+ * pass `null` for both — they're only reachable via
37
+ * `close_sockets_for_account`.
19
38
  */
20
- add_connection(ws: WSContext, token_hash: string | null, account_id: Uuid): Uuid;
39
+ add_connection(ws: WSContext, token_hash: string | null, account_id: Uuid, api_token_id?: string | null): Uuid;
21
40
  /**
22
41
  * Remove a WebSocket connection and its auth tracking data.
23
42
  * Idempotent — safe to call after revocation has already cleaned up.
@@ -35,6 +54,16 @@ export declare class BackendWebsocketTransport implements Transport {
35
54
  * @returns the number of sockets closed
36
55
  */
37
56
  close_sockets_for_account(account_id: Uuid): number;
57
+ /**
58
+ * Close all sockets associated with a specific API token.
59
+ *
60
+ * Used on `token_revoke` audit events so revoking one token doesn't
61
+ * tear down the account's session-authenticated sockets or other
62
+ * tokens' sockets.
63
+ *
64
+ * @returns the number of sockets closed
65
+ */
66
+ close_sockets_for_token(api_token_id: string): number;
38
67
  send(message: JsonrpcRequest): Promise<JsonrpcResponseOrError>;
39
68
  send(message: JsonrpcNotification): Promise<JsonrpcErrorResponse | null>;
40
69
  is_ready(): boolean;
@@ -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,qBAAa,yBAA0B,YAAW,SAAS;;IAC1D,QAAQ,CAAC,cAAc,EAAG,uBAAuB,CAAU;IAY3D;;;;;OAKG;IACH,cAAc,CAAC,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,IAAI,GAAG,IAAI;IAWhF;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,SAAS,GAAG,IAAI;IAOtC;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM;IAcrD;;;;OAIG;IACH,yBAAyB,CAAC,UAAU,EAAE,IAAI,GAAG,MAAM;IAiC7C,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,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"}
@@ -8,30 +8,29 @@ 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
- // TODO support a SSE backend transport
12
11
  export class BackendWebsocketTransport {
13
12
  transport_name = 'backend_websocket_rpc';
14
13
  // Map connection IDs to WebSocket contexts
15
14
  #connections = new Map();
16
15
  // Reverse map to find connection ID by socket
17
16
  #connection_ids = new WeakMap();
18
- // Session auth tracking parallel maps keyed by connection ID
19
- #connection_token_hashes = new Map();
20
- #connection_account_ids = new Map();
17
+ // Auth identity per connection. Adding a new identity scope (e.g.
18
+ // `device_id`) means adding a field here, not a new parallel map.
19
+ #connection_identities = new Map();
21
20
  /**
22
21
  * Add a new WebSocket connection with auth info.
23
22
  * Session connections pass a token hash for targeted revocation.
24
- * Bearer token connections (api_token, daemon_token) pass null
25
- * they're still reachable via {@link close_sockets_for_account}.
23
+ * Bearer token connections (api_token) pass the `api_token.id` so the
24
+ * socket can be closed when that specific token is revoked without
25
+ * tearing down the account's other sockets. Daemon-token connections
26
+ * pass `null` for both — they're only reachable via
27
+ * `close_sockets_for_account`.
26
28
  */
27
- add_connection(ws, token_hash, account_id) {
29
+ add_connection(ws, token_hash, account_id, api_token_id = null) {
28
30
  const connection_id = create_uuid();
29
31
  this.#connections.set(connection_id, ws);
30
32
  this.#connection_ids.set(ws, connection_id);
31
- if (token_hash !== null) {
32
- this.#connection_token_hashes.set(connection_id, token_hash);
33
- }
34
- this.#connection_account_ids.set(connection_id, account_id);
33
+ this.#connection_identities.set(connection_id, { token_hash, account_id, api_token_id });
35
34
  return connection_id;
36
35
  }
37
36
  /**
@@ -45,14 +44,14 @@ export class BackendWebsocketTransport {
45
44
  }
46
45
  }
47
46
  /**
48
- * Close all sockets associated with a specific session token hash.
47
+ * Close every connection whose identity matches the predicate.
49
48
  *
50
49
  * @returns the number of sockets closed
51
50
  */
52
- close_sockets_for_session(token_hash) {
51
+ #close_where(predicate) {
53
52
  let count = 0;
54
- for (const [connection_id, hash] of this.#connection_token_hashes) {
55
- if (hash === token_hash) {
53
+ for (const [connection_id, identity] of this.#connection_identities) {
54
+ if (predicate(identity)) {
56
55
  const ws = this.#connections.get(connection_id);
57
56
  if (ws) {
58
57
  this.#revoke_connection(connection_id, ws);
@@ -62,23 +61,33 @@ export class BackendWebsocketTransport {
62
61
  }
63
62
  return count;
64
63
  }
64
+ /**
65
+ * Close all sockets associated with a specific session token hash.
66
+ *
67
+ * @returns the number of sockets closed
68
+ */
69
+ close_sockets_for_session(token_hash) {
70
+ return this.#close_where((id) => id.token_hash === token_hash);
71
+ }
65
72
  /**
66
73
  * Close all sockets associated with a specific account.
67
74
  *
68
75
  * @returns the number of sockets closed
69
76
  */
70
77
  close_sockets_for_account(account_id) {
71
- let count = 0;
72
- for (const [connection_id, id] of this.#connection_account_ids) {
73
- if (id === account_id) {
74
- const ws = this.#connections.get(connection_id);
75
- if (ws) {
76
- this.#revoke_connection(connection_id, ws);
77
- count++;
78
- }
79
- }
80
- }
81
- return count;
78
+ return this.#close_where((id) => id.account_id === account_id);
79
+ }
80
+ /**
81
+ * Close all sockets associated with a specific API token.
82
+ *
83
+ * Used on `token_revoke` audit events so revoking one token doesn't
84
+ * tear down the account's session-authenticated sockets or other
85
+ * tokens' sockets.
86
+ *
87
+ * @returns the number of sockets closed
88
+ */
89
+ close_sockets_for_token(api_token_id) {
90
+ return this.#close_where((id) => id.api_token_id === api_token_id);
82
91
  }
83
92
  /**
84
93
  * Remove all tracking state for a connection.
@@ -86,8 +95,7 @@ export class BackendWebsocketTransport {
86
95
  #cleanup_connection(connection_id, ws) {
87
96
  this.#connections.delete(connection_id);
88
97
  this.#connection_ids.delete(ws);
89
- this.#connection_token_hashes.delete(connection_id);
90
- this.#connection_account_ids.delete(connection_id);
98
+ this.#connection_identities.delete(connection_id);
91
99
  }
92
100
  /**
93
101
  * Clean up a connection and close its socket with a revocation code.
@@ -1 +1 @@
1
- {"version":3,"file":"bearer_auth.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bearer_auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAC5C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAKpD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAElF;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,iBAAiB,WAAW,GAAG,IAAI,EACnC,KAAK,MAAM,KACT,iBAqFF,CAAC"}
1
+ {"version":3,"file":"bearer_auth.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/bearer_auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAC5C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAKpD,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,EAA+B,KAAK,WAAW,EAAC,MAAM,oBAAoB,CAAC;AAElF;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,6BAA6B,GACzC,MAAM,SAAS,EACf,iBAAiB,WAAW,GAAG,IAAI,EACnC,KAAK,MAAM,KACT,iBAsFF,CAAC"}
@@ -11,7 +11,7 @@
11
11
  * @module
12
12
  */
13
13
  import { REQUEST_CONTEXT_KEY, build_request_context } from './request_context.js';
14
- import { CREDENTIAL_TYPE_KEY } from '../hono_context.js';
14
+ import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
15
15
  import { query_validate_api_token } from './api_token_queries.js';
16
16
  import { get_client_ip } from '../http/proxy.js';
17
17
  import { rate_limit_exceeded_response } from '../rate_limiter.js';
@@ -107,6 +107,7 @@ export const create_bearer_auth_middleware = (deps, ip_rate_limiter, log) => {
107
107
  }
108
108
  c.set(REQUEST_CONTEXT_KEY, ctx);
109
109
  c.set(CREDENTIAL_TYPE_KEY, 'api_token');
110
+ c.set(AUTH_API_TOKEN_ID_KEY, api_token.id);
110
111
  await next();
111
112
  };
112
113
  };
@@ -1 +1 @@
1
- {"version":3,"file":"daemon_token_middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/daemon_token_middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAC5C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAWrF,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,EAKN,KAAK,gBAAgB,EACrB,MAAM,mBAAmB,CAAC;AAE3B,8DAA8D;AAC9D,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,iDAAiD;AACjD,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,GAC1D,IAAI,CAAC,WAAW,EAAE,OAAO,GAAG,iBAAiB,GAAG,QAAQ,CAAC,GAAG;IAC3D,6FAA6F;IAC7F,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACtD,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,MAAM,MAAM,KACV,MAAM,GAAG,IAGX,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,SAAS,oBAAoB,EAC7B,YAAY,MAAM,EAClB,OAAO,MAAM,KACX,OAAO,CAAC,IAAI,CAKd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAEtF,CAAC;AAEF,yCAAyC;AACzC,MAAM,WAAW,0BAA0B;IAC1C,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,gDAAgD;AAChD,MAAM,WAAW,mBAAmB;IACnC,2EAA2E;IAC3E,KAAK,EAAE,gBAAgB,CAAC;IACxB,kGAAkG;IAClG,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,oBAAoB,GAAG,YAAY,EAC5C,MAAM,SAAS,EACf,SAAS,0BAA0B,EACnC,KAAK,MAAM,KACT,OAAO,CAAC,mBAAmB,CAwD7B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,8BAA8B,GAC1C,OAAO,gBAAgB,EACvB,MAAM,SAAS,KACb,iBAoCF,CAAC"}
1
+ {"version":3,"file":"daemon_token_middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/daemon_token_middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,MAAM,CAAC;AAC5C,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,OAAO,EAAC,MAAM,oBAAoB,CAAC;AAWrF,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAEnD,OAAO,EAKN,KAAK,gBAAgB,EACrB,MAAM,mBAAmB,CAAC;AAE3B,8DAA8D;AAC9D,eAAO,MAAM,4BAA4B,QAAS,CAAC;AAEnD,iDAAiD;AACjD,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,GAC1D,IAAI,CAAC,WAAW,EAAE,OAAO,GAAG,iBAAiB,GAAG,QAAQ,CAAC,GAAG;IAC3D,6FAA6F;IAC7F,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACtD,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,GACjC,SAAS,IAAI,CAAC,OAAO,EAAE,SAAS,CAAC,EACjC,MAAM,MAAM,KACV,MAAM,GAAG,IAGX,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAC9B,SAAS,oBAAoB,EAC7B,YAAY,MAAM,EAClB,OAAO,MAAM,KACX,OAAO,CAAC,IAAI,CAKd,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,GAAU,MAAM,SAAS,KAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAEtF,CAAC;AAEF,yCAAyC;AACzC,MAAM,WAAW,0BAA0B;IAC1C,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,gDAAgD;AAChD,MAAM,WAAW,mBAAmB;IACnC,2EAA2E;IAC3E,KAAK,EAAE,gBAAgB,CAAC;IACxB,kGAAkG;IAClG,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,oBAAoB,GAAG,YAAY,EAC5C,MAAM,SAAS,EACf,SAAS,0BAA0B,EACnC,KAAK,MAAM,KACT,OAAO,CAAC,mBAAmB,CAwD7B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,8BAA8B,GAC1C,OAAO,gBAAgB,EACvB,MAAM,SAAS,KACb,iBAqCF,CAAC"}
@@ -13,7 +13,7 @@ import {} from '../runtime/deps.js';
13
13
  import { write_file_atomic } from '../runtime/fs.js';
14
14
  import { get_app_dir } from '../cli/config.js';
15
15
  import { REQUEST_CONTEXT_KEY, build_request_context } from './request_context.js';
16
- import { CREDENTIAL_TYPE_KEY } from '../hono_context.js';
16
+ import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
17
17
  import { ERROR_INVALID_DAEMON_TOKEN, ERROR_KEEPER_ACCOUNT_NOT_CONFIGURED, ERROR_KEEPER_ACCOUNT_NOT_FOUND, } from '../http/error_schemas.js';
18
18
  import { query_permit_find_account_id_for_role } from './permit_queries.js';
19
19
  import { ROLE_KEEPER } from './role_schema.js';
@@ -162,6 +162,7 @@ export const create_daemon_token_middleware = (state, deps) => {
162
162
  }
163
163
  c.set(REQUEST_CONTEXT_KEY, ctx);
164
164
  c.set(CREDENTIAL_TYPE_KEY, 'daemon_token');
165
+ c.set(AUTH_API_TOKEN_ID_KEY, null);
165
166
  await next();
166
167
  };
167
168
  };
@@ -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)
@@ -1 +1 @@
1
- {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,cAAc,EAAE,MAAM,MAAM,EAAE,MAAK,IAAiB,KAAG,OAChB,CAAC;AAEtE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBAyCF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
1
+ {"version":3,"file":"request_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/request_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAC,OAAO,EAAE,iBAAiB,EAAC,MAAM,MAAM,CAAC;AACrD,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAC,KAAK,OAAO,EAAE,KAAK,KAAK,EAAoB,KAAK,MAAM,EAAC,MAAM,qBAAqB,CAAC;AAQ5F,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,qBAAqB,CAAC;AAOnD,kEAAkE;AAClE,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACvB;AAED,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,4BAA4B,CAAC;AAErE;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,cAAc,GAAG,IAEjE,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,GAAI,GAAG,OAAO,KAAG,cAMpD,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,GAAI,KAAK,cAAc,EAAE,MAAM,MAAM,EAAE,MAAK,IAAiB,KAAG,OAChB,CAAC;AAEtE;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iCAAiC,GAC7C,MAAM,SAAS,EACf,KAAK,MAAM,EACX,4BAAuC,KACrC,iBA6CF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,iBAM1B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,iBAW3C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC3B,KAAK,cAAc,EACnB,MAAM,SAAS,KACb,OAAO,CAAC,cAAc,CAGxB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,GACjC,MAAM,SAAS,EACf,YAAY,MAAM,KAChB,OAAO,CAAC,cAAc,GAAG,IAAI,CAS/B,CAAC"}
@@ -15,7 +15,7 @@ import { is_permit_active } from './account_schema.js';
15
15
  import { hash_session_token, session_touch_fire_and_forget, query_session_get_valid, } from './session_queries.js';
16
16
  import { query_actor_by_account, query_account_by_id } from './account_queries.js';
17
17
  import { query_permit_find_active_for_actor } from './permit_queries.js';
18
- import { CREDENTIAL_TYPE_KEY } from '../hono_context.js';
18
+ import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
19
19
  import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, } from '../http/error_schemas.js';
20
20
  /** Hono context variable name for the request context. */
21
21
  export const REQUEST_CONTEXT_KEY = 'request_context';
@@ -89,6 +89,7 @@ export const create_request_context_middleware = (deps, log, session_context_key
89
89
  c.set(REQUEST_CONTEXT_KEY, null);
90
90
  c.set(CREDENTIAL_TYPE_KEY, null);
91
91
  c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
92
+ c.set(AUTH_API_TOKEN_ID_KEY, null);
92
93
  await next();
93
94
  return;
94
95
  }
@@ -98,6 +99,7 @@ export const create_request_context_middleware = (deps, log, session_context_key
98
99
  c.set(REQUEST_CONTEXT_KEY, null);
99
100
  c.set(CREDENTIAL_TYPE_KEY, null);
100
101
  c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
102
+ c.set(AUTH_API_TOKEN_ID_KEY, null);
101
103
  await next();
102
104
  return;
103
105
  }
@@ -106,12 +108,14 @@ export const create_request_context_middleware = (deps, log, session_context_key
106
108
  c.set(REQUEST_CONTEXT_KEY, null);
107
109
  c.set(CREDENTIAL_TYPE_KEY, null);
108
110
  c.set(AUTH_SESSION_TOKEN_HASH_KEY, null);
111
+ c.set(AUTH_API_TOKEN_ID_KEY, null);
109
112
  await next();
110
113
  return;
111
114
  }
112
115
  c.set(REQUEST_CONTEXT_KEY, ctx);
113
116
  c.set(CREDENTIAL_TYPE_KEY, 'session');
114
117
  c.set(AUTH_SESSION_TOKEN_HASH_KEY, token_hash);
118
+ c.set(AUTH_API_TOKEN_ID_KEY, null);
115
119
  // Touch session (fire-and-forget, don't block the request)
116
120
  void session_touch_fire_and_forget(deps, token_hash, c.var.pending_effects, log);
117
121
  await next();
@@ -26,6 +26,8 @@ export declare const CredentialType: z.ZodEnum<{
26
26
  export type CredentialType = z.infer<typeof CredentialType>;
27
27
  /** Hono context variable name for the credential type. */
28
28
  export declare const CREDENTIAL_TYPE_KEY = "credential_type";
29
+ /** Hono context variable name for the authenticated API token id. */
30
+ export declare const AUTH_API_TOKEN_ID_KEY = "auth_api_token_id";
29
31
  declare module 'hono' {
30
32
  interface ContextVariableMap {
31
33
  /** Resolved client IP, set by the trusted proxy middleware. */
@@ -44,6 +46,14 @@ declare module 'hono' {
44
46
  * disconnection) without re-hashing the cookie in every handler.
45
47
  */
46
48
  auth_session_token_hash: string | null;
49
+ /**
50
+ * `api_token.id` when the request authenticated via `Authorization: Bearer`,
51
+ * or `null` for session/daemon-token/unauthenticated requests. Set by
52
+ * `create_bearer_auth_middleware`. Used to scope per-token resources
53
+ * (e.g., WS connection revocation on `token_revoke`) without re-looking
54
+ * up the token.
55
+ */
56
+ auth_api_token_id: string | null;
47
57
  /**
48
58
  * Pending fire-and-forget effects for this request (audit logs, usage tracking, etc.).
49
59
  * Initialized by `create_app_server`. In test mode (`await_pending_effects: true`),
@@ -1 +1 @@
1
- {"version":3,"file":"hono_context.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/hono_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAE9D,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,mDAAoD,CAAC;AAElF,yDAAyD;AACzD,eAAO,MAAM,cAAc;;;;EAA2B,CAAC;AACvD,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD,OAAO,QAAQ,MAAM,CAAC;IACrB,UAAU,kBAAkB;QAC3B,+DAA+D;QAC/D,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC,eAAe,EAAE,OAAO,CAAC;QACzB,gBAAgB,EAAE,OAAO,CAAC;QAC1B,eAAe,EAAE,OAAO,CAAC;QACzB,2FAA2F;QAC3F,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC;;;;;WAKG;QACH,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAC;QACvC;;;;WAIG;QACH,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;KACtC;CACD"}
1
+ {"version":3,"file":"hono_context.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/hono_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAC,CAAC,EAAC,MAAM,KAAK,CAAC;AAEtB,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAE9D,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,mDAAoD,CAAC;AAElF,yDAAyD;AACzD,eAAO,MAAM,cAAc;;;;EAA2B,CAAC;AACvD,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,0DAA0D;AAC1D,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD,qEAAqE;AACrE,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAEzD,OAAO,QAAQ,MAAM,CAAC;IACrB,UAAU,kBAAkB;QAC3B,+DAA+D;QAC/D,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;QAC/B,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC,eAAe,EAAE,OAAO,CAAC;QACzB,gBAAgB,EAAE,OAAO,CAAC;QAC1B,eAAe,EAAE,OAAO,CAAC;QACzB,2FAA2F;QAC3F,eAAe,EAAE,cAAc,GAAG,IAAI,CAAC;QACvC;;;;;WAKG;QACH,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAC;QACvC;;;;;;WAMG;QACH,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;QACjC;;;;WAIG;QACH,eAAe,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;KACtC;CACD"}
@@ -20,3 +20,5 @@ export const CREDENTIAL_TYPES = ['session', 'api_token', 'daemon_token'];
20
20
  export const CredentialType = z.enum(CREDENTIAL_TYPES);
21
21
  /** Hono context variable name for the credential type. */
22
22
  export const CREDENTIAL_TYPE_KEY = 'credential_type';
23
+ /** Hono context variable name for the authenticated API token id. */
24
+ export const AUTH_API_TOKEN_ID_KEY = 'auth_api_token_id';
@@ -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
  }
@@ -42,6 +42,8 @@ export interface BearerAuthTestCase extends BearerAuthTestOptions {
42
42
  validate_expectation: 'called' | 'not_called';
43
43
  /** If true, assert `REQUEST_CONTEXT_KEY` and `CREDENTIAL_TYPE_KEY` were set to api_token values. */
44
44
  assert_context_set?: boolean;
45
+ /** If set, assert `AUTH_API_TOKEN_ID_KEY` was set to this value after a successful bearer auth. */
46
+ expected_api_token_id?: string;
45
47
  /** If true, assert the pre-existing session context and credential type are preserved. */
46
48
  assert_context_preserved?: boolean;
47
49
  /** Optional callback for custom spy assertions on the mocks bundle. */
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/middleware.ts"],"names":[],"mappings":"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"}
@@ -18,7 +18,7 @@ import { query_permit_find_active_for_actor } from '../auth/permit_queries.js';
18
18
  import { create_proxy_middleware, get_client_ip } from '../http/proxy.js';
19
19
  import { verify_request_source, parse_allowed_origins } from '../http/origin.js';
20
20
  import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
21
- import { CREDENTIAL_TYPE_KEY } from '../hono_context.js';
21
+ import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
22
22
  import { ApiError } from '../http/error_schemas.js';
23
23
  // Mock the query modules so test cases can control return values.
24
24
  // vi.mock() is hoisted by vitest, so these run before any imports resolve.
@@ -97,6 +97,7 @@ export const create_bearer_auth_test_app = (tc, ip_rate_limiter = null) => {
97
97
  app.get('/api/test', (c) => {
98
98
  const ctx = c.get(REQUEST_CONTEXT_KEY);
99
99
  const cred = c.get(CREDENTIAL_TYPE_KEY);
100
+ const api_token_id = c.get(AUTH_API_TOKEN_ID_KEY);
100
101
  return c.json({
101
102
  ok: true,
102
103
  has_context: ctx != null,
@@ -104,6 +105,7 @@ export const create_bearer_auth_test_app = (tc, ip_rate_limiter = null) => {
104
105
  account_id: ctx?.account.id ?? null,
105
106
  actor_id: ctx?.actor.id ?? null,
106
107
  permit_count: ctx?.permits.length ?? 0,
108
+ api_token_id: api_token_id ?? null,
107
109
  });
108
110
  });
109
111
  return { app, mocks };
@@ -149,6 +151,9 @@ export const describe_bearer_auth_cases = (suite_name, cases, ip_rate_limiter =
149
151
  assert.strictEqual(body.has_context, true, 'REQUEST_CONTEXT_KEY should be set');
150
152
  assert.strictEqual(body.credential_type, 'api_token', 'CREDENTIAL_TYPE_KEY should be api_token');
151
153
  }
154
+ if (tc.expected_api_token_id !== undefined) {
155
+ assert.strictEqual(body.api_token_id, tc.expected_api_token_id, 'AUTH_API_TOKEN_ID_KEY should match the validated api_token.id');
156
+ }
152
157
  if (tc.assert_context_preserved) {
153
158
  assert.strictEqual(body.has_context, true, 'original context should be preserved');
154
159
  assert.strictEqual(body.credential_type, 'session', 'credential type should remain session');
@@ -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.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",