@fuzdev/fuz_app 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/action_registry.js +1 -1
- package/dist/actions/action_spec.d.ts +1 -1
- package/dist/actions/action_spec.js +1 -1
- package/dist/actions/broadcast_api.d.ts +2 -3
- package/dist/actions/broadcast_api.d.ts.map +1 -1
- package/dist/actions/broadcast_api.js +2 -3
- package/dist/actions/register_action_ws.d.ts +56 -2
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +59 -6
- package/dist/testing/ws_round_trip.d.ts +169 -0
- package/dist/testing/ws_round_trip.d.ts.map +1 -0
- package/dist/testing/ws_round_trip.js +348 -0
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// `request_response_specs`, `remote_notification_specs`, `local_call_specs`,
|
|
8
8
|
// `frontend_methods`, `backend_methods`, and `methods` are used by consumers (codegen).
|
|
9
9
|
// The rest are pre-built for future use. Revisit which getters to keep when the action
|
|
10
|
-
// system matures
|
|
10
|
+
// system matures. Also consider lazy memoization (`??=` or derived).
|
|
11
11
|
/**
|
|
12
12
|
* Utility class to manage and query action specifications.
|
|
13
13
|
* Provides helper methods to get actions by various criteria.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `action_bridge.ts` derive `RouteSpec` and `EventSpec` from them.
|
|
7
7
|
*
|
|
8
8
|
* TODO @action-system-review The action system (action_spec, action_registry,
|
|
9
|
-
* action_codegen, action_bridge) will evolve significantly
|
|
9
|
+
* action_codegen, action_bridge) will evolve significantly as RPC patterns settle.
|
|
10
10
|
* Current state: bridge is stable, registry and codegen are partially stub API.
|
|
11
11
|
* Search for `@action-system-review` across the actions/ and routes/ modules.
|
|
12
12
|
*
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `action_bridge.ts` derive `RouteSpec` and `EventSpec` from them.
|
|
7
7
|
*
|
|
8
8
|
* TODO @action-system-review The action system (action_spec, action_registry,
|
|
9
|
-
* action_codegen, action_bridge) will evolve significantly
|
|
9
|
+
* action_codegen, action_bridge) will evolve significantly as RPC patterns settle.
|
|
10
10
|
* Current state: bridge is stable, registry and codegen are partially stub API.
|
|
11
11
|
* Search for `@action-system-review` across the actions/ and routes/ modules.
|
|
12
12
|
*
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* streaming (`completion_progress`, `tx_apply` events) stays on
|
|
13
13
|
* `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
|
|
14
14
|
*
|
|
15
|
-
* Extracted from zzz's `backend_actions_api.ts`
|
|
16
|
-
*
|
|
17
|
-
* undying.
|
|
15
|
+
* Extracted from zzz's `backend_actions_api.ts` to stop the pattern from
|
|
16
|
+
* drifting across zzz, tx, and undying.
|
|
18
17
|
*
|
|
19
18
|
* @module
|
|
20
19
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"broadcast_api.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/broadcast_api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;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"}
|
|
@@ -12,9 +12,8 @@
|
|
|
12
12
|
* streaming (`completion_progress`, `tx_apply` events) stays on
|
|
13
13
|
* `ctx.notify` inside a handler — it's socket-scoped, not broadcast.
|
|
14
14
|
*
|
|
15
|
-
* Extracted from zzz's `backend_actions_api.ts`
|
|
16
|
-
*
|
|
17
|
-
* undying.
|
|
15
|
+
* Extracted from zzz's `backend_actions_api.ts` to stop the pattern from
|
|
16
|
+
* drifting across zzz, tx, and undying.
|
|
18
17
|
*
|
|
19
18
|
* @module
|
|
20
19
|
*/
|
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
* @module
|
|
23
23
|
*/
|
|
24
24
|
import type { Context, Hono } from 'hono';
|
|
25
|
-
import type { UpgradeWebSocket } from 'hono/ws';
|
|
25
|
+
import type { UpgradeWebSocket, WSContext } from 'hono/ws';
|
|
26
26
|
import { type Logger as LoggerType } from '@fuzdev/fuz_util/log.js';
|
|
27
27
|
import { type JsonrpcRequestId } from '../http/jsonrpc.js';
|
|
28
|
+
import type { Uuid } from '../uuid.js';
|
|
28
29
|
import type { ActionSpecUnion } from './action_spec.js';
|
|
29
|
-
import { BackendWebsocketTransport } from './transports_ws_backend.js';
|
|
30
|
+
import { BackendWebsocketTransport, type ConnectionIdentity } from './transports_ws_backend.js';
|
|
30
31
|
/**
|
|
31
32
|
* Minimum per-request context every handler receives.
|
|
32
33
|
*
|
|
@@ -51,6 +52,45 @@ export interface BaseHandlerContext {
|
|
|
51
52
|
}
|
|
52
53
|
/** Handler signature — receives validated input and per-request context. */
|
|
53
54
|
export type WsActionHandler<TCtx extends BaseHandlerContext> = (input: unknown, ctx: TCtx) => unknown;
|
|
55
|
+
/**
|
|
56
|
+
* Context passed to the `on_socket_open` hook.
|
|
57
|
+
*
|
|
58
|
+
* Fires after the transport has registered the new connection (so
|
|
59
|
+
* `connection_id` is valid) but before any client message can dispatch.
|
|
60
|
+
* Consumers use this to bootstrap per-socket domain state — e.g. undying
|
|
61
|
+
* spawns the per-account spirit unit and pushes an initial state snapshot.
|
|
62
|
+
*/
|
|
63
|
+
export interface SocketOpenContext {
|
|
64
|
+
/** The raw WebSocket context — exposed for edge cases; prefer `notify` for sends. */
|
|
65
|
+
ws: WSContext;
|
|
66
|
+
/** Connection id assigned by `BackendWebsocketTransport.add_connection`. */
|
|
67
|
+
connection_id: Uuid;
|
|
68
|
+
/** Auth identity registered for this connection. */
|
|
69
|
+
identity: ConnectionIdentity;
|
|
70
|
+
/**
|
|
71
|
+
* Send a JSON-RPC notification to just this socket. Mirrors `ctx.notify`
|
|
72
|
+
* on per-message handler contexts — same socket-scoped semantics.
|
|
73
|
+
*/
|
|
74
|
+
notify: (method: string, params: unknown) => void;
|
|
75
|
+
/** Fires when this socket closes — threaded through to every handler's `ctx.signal`. */
|
|
76
|
+
signal: AbortSignal;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Context passed to the `on_socket_close` hook.
|
|
80
|
+
*
|
|
81
|
+
* Fires before `transport.remove_connection` runs, so consumer cleanup can
|
|
82
|
+
* still read identity before it's torn down. Fires for both client-initiated
|
|
83
|
+
* closes (Hono onClose) and server-initiated closes via audit revocation
|
|
84
|
+
* (the audit guard calls `ws.close()`, which triggers Hono's onClose).
|
|
85
|
+
*/
|
|
86
|
+
export interface SocketCloseContext {
|
|
87
|
+
/** The raw WebSocket context at close time. */
|
|
88
|
+
ws: WSContext;
|
|
89
|
+
/** Connection id captured at open time. */
|
|
90
|
+
connection_id: Uuid;
|
|
91
|
+
/** Auth identity captured at open time — still valid even if the transport already cleaned up. */
|
|
92
|
+
identity: ConnectionIdentity;
|
|
93
|
+
}
|
|
54
94
|
/** Options for `register_action_ws`. */
|
|
55
95
|
export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
|
|
56
96
|
/** Mount path (e.g., `/api/ws`). */
|
|
@@ -80,6 +120,20 @@ export interface RegisterActionWsOptions<TCtx extends BaseHandlerContext> {
|
|
|
80
120
|
artificial_delay?: number;
|
|
81
121
|
/** Optional logger; defaults to `[ws]` namespace. */
|
|
82
122
|
log?: LoggerType;
|
|
123
|
+
/**
|
|
124
|
+
* Called once per socket, after the transport registers the connection.
|
|
125
|
+
* Awaited before any message is dispatched. Throwing logs an error and
|
|
126
|
+
* closes the socket with an `internal_error` frame — a failing bootstrap
|
|
127
|
+
* should not leave a partially-initialized socket alive.
|
|
128
|
+
*/
|
|
129
|
+
on_socket_open?: (ctx: SocketOpenContext) => void | Promise<void>;
|
|
130
|
+
/**
|
|
131
|
+
* Called once per socket on close, *before* the transport removes the
|
|
132
|
+
* connection. Receives `connection_id` and `identity` captured at open
|
|
133
|
+
* time, so it is safe to read even when the audit guard has already torn
|
|
134
|
+
* down the transport's internal state. Errors are logged and swallowed.
|
|
135
|
+
*/
|
|
136
|
+
on_socket_close?: (ctx: SocketCloseContext) => void | Promise<void>;
|
|
83
137
|
}
|
|
84
138
|
/** Result of `register_action_ws`. */
|
|
85
139
|
export interface RegisterActionWsResult {
|
|
@@ -1 +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;
|
|
1
|
+
{"version":3,"file":"register_action_ws.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_action_ws.ts"],"names":[],"mappings":"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"}
|
|
@@ -51,7 +51,7 @@ import { BackendWebsocketTransport } from './transports_ws_backend.js';
|
|
|
51
51
|
* `create_ws_auth_guard` or broadcast on audit events.
|
|
52
52
|
*/
|
|
53
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;
|
|
54
|
+
const { path, app, upgradeWebSocket, specs, handlers, extend_context, transport = new BackendWebsocketTransport(), artificial_delay = 0, log = new Logger('[ws]'), on_socket_open, on_socket_close, } = options;
|
|
55
55
|
// Build spec lookup for per-action auth and input validation.
|
|
56
56
|
const spec_by_method = new Map(specs.map((spec) => [spec.method, spec]));
|
|
57
57
|
app.get(path, upgradeWebSocket((c) => {
|
|
@@ -75,10 +75,51 @@ export const register_action_ws = (options) => {
|
|
|
75
75
|
// a single socket-scoped signal is sufficient today since cancel
|
|
76
76
|
// granularity tracks connection lifetime, not individual requests.
|
|
77
77
|
const socket_abort_controller = new AbortController();
|
|
78
|
+
// Identity is assembled at upgrade time so `on_socket_close` can
|
|
79
|
+
// still read it after the audit guard tears the transport record
|
|
80
|
+
// down; `BackendWebsocketTransport.#revoke_connection` clears the
|
|
81
|
+
// identity map before Hono fires onClose.
|
|
82
|
+
const identity = { token_hash, account_id, api_token_id };
|
|
83
|
+
// Captured on open, consumed on close. Null before onOpen fires or
|
|
84
|
+
// when a consumer never opens (e.g. immediate disconnect).
|
|
85
|
+
let captured_connection_id = null;
|
|
86
|
+
// Socket-scoped notification helper — routes to this socket only,
|
|
87
|
+
// matches the `ctx.notify` semantics exposed to per-message handlers.
|
|
88
|
+
const notify_socket = (ws) => (notify_method, notify_params) => {
|
|
89
|
+
try {
|
|
90
|
+
const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
|
|
91
|
+
ws.send(JSON.stringify(notification));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
log.error('notify send failed:', notify_method, error);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
78
97
|
return {
|
|
79
|
-
onOpen: (event, ws) => {
|
|
98
|
+
onOpen: async (event, ws) => {
|
|
80
99
|
const connection_id = transport.add_connection(ws, token_hash, account_id, api_token_id);
|
|
100
|
+
captured_connection_id = connection_id;
|
|
81
101
|
log.debug('ws opened', connection_id, event);
|
|
102
|
+
if (on_socket_open) {
|
|
103
|
+
try {
|
|
104
|
+
await on_socket_open({
|
|
105
|
+
ws,
|
|
106
|
+
connection_id,
|
|
107
|
+
identity,
|
|
108
|
+
notify: notify_socket(ws),
|
|
109
|
+
signal: socket_abort_controller.signal,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
log.error('on_socket_open failed — closing socket:', error);
|
|
114
|
+
try {
|
|
115
|
+
ws.send(JSON.stringify(create_jsonrpc_error_response(null, jsonrpc_error_messages.internal_error())));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// ignore — socket may already be dead
|
|
119
|
+
}
|
|
120
|
+
ws.close(1011, 'socket bootstrap failed');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
82
123
|
},
|
|
83
124
|
onMessage: async (event, ws) => {
|
|
84
125
|
let json;
|
|
@@ -143,9 +184,9 @@ export const register_action_ws = (options) => {
|
|
|
143
184
|
await wait(artificial_delay);
|
|
144
185
|
}
|
|
145
186
|
// Socket-scoped notification — routes to originator only, not
|
|
146
|
-
// broadcast. Future work
|
|
147
|
-
//
|
|
148
|
-
//
|
|
187
|
+
// broadcast. Future work: other audiences — account-scoped,
|
|
188
|
+
// ACL-filtered, broadcast — likely via a transport-level
|
|
189
|
+
// policy hook.
|
|
149
190
|
const notify = (notify_method, notify_params) => {
|
|
150
191
|
try {
|
|
151
192
|
const notification = create_jsonrpc_notification(notify_method, to_jsonrpc_params(notify_params));
|
|
@@ -178,8 +219,20 @@ export const register_action_ws = (options) => {
|
|
|
178
219
|
ws.send(JSON.stringify(create_jsonrpc_error_response_from_thrown(id, error)));
|
|
179
220
|
}
|
|
180
221
|
},
|
|
181
|
-
onClose: (event, ws) => {
|
|
222
|
+
onClose: async (event, ws) => {
|
|
182
223
|
socket_abort_controller.abort();
|
|
224
|
+
if (on_socket_close && captured_connection_id) {
|
|
225
|
+
try {
|
|
226
|
+
await on_socket_close({
|
|
227
|
+
ws,
|
|
228
|
+
connection_id: captured_connection_id,
|
|
229
|
+
identity,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
log.error('on_socket_close failed:', error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
183
236
|
transport.remove_connection(ws);
|
|
184
237
|
log.debug('ws closed', event);
|
|
185
238
|
},
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process test helpers for WebSocket JSON-RPC round-trips.
|
|
3
|
+
*
|
|
4
|
+
* Drives `register_action_ws` without an HTTP server. Consumers supply
|
|
5
|
+
* specs + handlers; the harness constructs real `WSContext` instances
|
|
6
|
+
* backed by test-owned `send`/`close` pairs, fakes the authenticated
|
|
7
|
+
* Hono context (`request_context`, credential type, session id, api
|
|
8
|
+
* token id), and exposes a `connect()` factory returning a
|
|
9
|
+
* `MockWsClient` per connection.
|
|
10
|
+
*
|
|
11
|
+
* Two layers are exported:
|
|
12
|
+
*
|
|
13
|
+
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
+
* `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
|
|
15
|
+
* fuz_app's own dispatcher tests and by consumers wiring tight
|
|
16
|
+
* one-off tests.
|
|
17
|
+
* - **Harness** (`create_ws_test_harness`, `MockWsClient`,
|
|
18
|
+
* `keeper_identity`) — the high-level driver. Give it specs +
|
|
19
|
+
* handlers, get back `{transport, connect()}`. Use this unless you
|
|
20
|
+
* need bare primitives.
|
|
21
|
+
*
|
|
22
|
+
* Hono's wire upgrade is skipped — the Node test runtime has no
|
|
23
|
+
* `@hono/node-ws` adapter — but the full dispatch path is exercised
|
|
24
|
+
* (per-action auth, input validation, `ctx.notify` back to the
|
|
25
|
+
* originating socket, broadcast via `BackendWebsocketTransport`, and
|
|
26
|
+
* close-on-revoke).
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import type { Context } from 'hono';
|
|
31
|
+
import { WSContext, type UpgradeWebSocket, type WSEvents } from 'hono/ws';
|
|
32
|
+
import { Logger } from '@fuzdev/fuz_util/log.js';
|
|
33
|
+
import type { ActionSpecUnion } from '../actions/action_spec.js';
|
|
34
|
+
import type { ActionEventEnvironment } from '../actions/action_event_types.js';
|
|
35
|
+
import { type BaseHandlerContext, type RegisterActionWsOptions, type WsActionHandler } from '../actions/register_action_ws.js';
|
|
36
|
+
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
37
|
+
import { type RequestContext } from '../auth/request_context.js';
|
|
38
|
+
import { type CredentialType } from '../hono_context.js';
|
|
39
|
+
import { type Uuid } from '../uuid.js';
|
|
40
|
+
/**
|
|
41
|
+
* A `WSContext` paired with capture arrays. Use `sends` to assert on
|
|
42
|
+
* outgoing frames; use `closes` to assert on revocation / close.
|
|
43
|
+
*/
|
|
44
|
+
export interface FakeWs {
|
|
45
|
+
ws: WSContext;
|
|
46
|
+
sends: Array<string>;
|
|
47
|
+
closes: Array<{
|
|
48
|
+
code?: number;
|
|
49
|
+
reason?: string;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build a real `WSContext` backed by in-memory `send`/`close` capture.
|
|
54
|
+
* Parsing of outgoing frames is left to the caller — `sends` holds the
|
|
55
|
+
* raw strings as the dispatcher wrote them.
|
|
56
|
+
*/
|
|
57
|
+
export declare const create_fake_ws: () => FakeWs;
|
|
58
|
+
/** Options for `create_fake_hono_context`. */
|
|
59
|
+
export interface FakeHonoContextOptions {
|
|
60
|
+
credential_type: CredentialType;
|
|
61
|
+
/** A single role to grant via `create_test_request_context`. */
|
|
62
|
+
role?: string;
|
|
63
|
+
auth_session_id?: string | null;
|
|
64
|
+
api_token_id?: string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Override the `RequestContext` outright (for multi-role or custom
|
|
67
|
+
* account/actor fixtures). Takes precedence over `role`.
|
|
68
|
+
*/
|
|
69
|
+
request_context?: RequestContext;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a fake Hono `Context` exposing the auth keys the dispatcher
|
|
73
|
+
* reads via `c.get(...)`. Only `.get()` is populated — no other Hono
|
|
74
|
+
* context surface is simulated.
|
|
75
|
+
*/
|
|
76
|
+
export declare const create_fake_hono_context: (opts: FakeHonoContextOptions) => Context;
|
|
77
|
+
/** The return of `create_stub_upgrade` — fake `upgradeWebSocket` + factory capture. */
|
|
78
|
+
export interface StubUpgrade {
|
|
79
|
+
upgradeWebSocket: UpgradeWebSocket;
|
|
80
|
+
get_create_events: () => (c: Context) => WSEvents | Promise<WSEvents>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a fake `upgradeWebSocket` that captures the `createEvents`
|
|
84
|
+
* callback. The returned middleware is inert — tests drive
|
|
85
|
+
* `createEvents` directly.
|
|
86
|
+
*/
|
|
87
|
+
export declare const create_stub_upgrade: () => StubUpgrade;
|
|
88
|
+
/**
|
|
89
|
+
* Minimal `ActionEventEnvironment` for tests that instantiate an
|
|
90
|
+
* `ActionPeer` without pulling in the full runtime. Pre-loads a
|
|
91
|
+
* spec map from the supplied list.
|
|
92
|
+
*/
|
|
93
|
+
export declare class MinimalActionEnvironment implements ActionEventEnvironment {
|
|
94
|
+
#private;
|
|
95
|
+
executor: 'frontend' | 'backend';
|
|
96
|
+
constructor(specs: ReadonlyArray<ActionSpecUnion>);
|
|
97
|
+
lookup_action_handler(): undefined;
|
|
98
|
+
lookup_action_spec(method: string): ActionSpecUnion | undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
|
|
102
|
+
* Awaits only the Promise branch so tests observe full dispatch
|
|
103
|
+
* (auth, validation, handler, send).
|
|
104
|
+
*/
|
|
105
|
+
export declare const dispatch_ws_message: (on_message: NonNullable<WSEvents["onMessage"]>, event: MessageEvent, ws: WSContext) => Promise<void>;
|
|
106
|
+
/** Auth identity for a mock connection. */
|
|
107
|
+
export interface WsConnectIdentity {
|
|
108
|
+
/** Account id for the connection. Defaults to a fresh uuid per call. */
|
|
109
|
+
account_id?: Uuid;
|
|
110
|
+
/** Credential type. Defaults to `'session'`. Keeper actions require `'daemon_token'`. */
|
|
111
|
+
credential_type?: CredentialType;
|
|
112
|
+
/** Session id (any string). Defaults to a fresh uuid. Hashed by the dispatcher. */
|
|
113
|
+
session_id?: string;
|
|
114
|
+
/** Api token id; set for bearer connections, null otherwise. */
|
|
115
|
+
api_token_id?: string | null;
|
|
116
|
+
/** Roles to grant via active permits. Pass `[ROLE_KEEPER]` for keeper actions. */
|
|
117
|
+
roles?: Array<string>;
|
|
118
|
+
}
|
|
119
|
+
/** A mock WS client: send requests, inspect/await incoming messages. */
|
|
120
|
+
export interface MockWsClient {
|
|
121
|
+
/** Send a JSON-RPC message (request or notification) to the server. */
|
|
122
|
+
send: (message: unknown) => Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Close the connection, firing `onClose`. Returns a promise that
|
|
125
|
+
* resolves once `on_socket_close` (and the transport's own cleanup)
|
|
126
|
+
* have completed — tests that assert on post-close state should await.
|
|
127
|
+
*/
|
|
128
|
+
close: (code?: number, reason?: string) => Promise<void>;
|
|
129
|
+
/** Every message the server has sent, in arrival order. */
|
|
130
|
+
readonly messages: ReadonlyArray<unknown>;
|
|
131
|
+
/**
|
|
132
|
+
* Wait until a message satisfies `predicate`. Matches are checked
|
|
133
|
+
* against already-received messages first, then new arrivals until
|
|
134
|
+
* the timeout (defaults to 1000ms).
|
|
135
|
+
*/
|
|
136
|
+
wait_for: <T = unknown>(predicate: (msg: unknown) => boolean, timeout_ms?: number) => Promise<T>;
|
|
137
|
+
}
|
|
138
|
+
/** Options for `create_ws_test_harness`. */
|
|
139
|
+
export interface CreateWsTestHarnessOptions<TCtx extends BaseHandlerContext> {
|
|
140
|
+
specs: ReadonlyArray<ActionSpecUnion>;
|
|
141
|
+
handlers: Record<string, WsActionHandler<TCtx>>;
|
|
142
|
+
extend_context?: RegisterActionWsOptions<TCtx>['extend_context'];
|
|
143
|
+
/** Pass a pre-created transport to share with a broadcast API. */
|
|
144
|
+
transport?: BackendWebsocketTransport;
|
|
145
|
+
/** Optional logger. Defaults to a silent `[ws-test]` logger. */
|
|
146
|
+
log?: Logger;
|
|
147
|
+
/** Threaded straight through to `register_action_ws`. */
|
|
148
|
+
on_socket_open?: RegisterActionWsOptions<TCtx>['on_socket_open'];
|
|
149
|
+
/** Threaded straight through to `register_action_ws`. */
|
|
150
|
+
on_socket_close?: RegisterActionWsOptions<TCtx>['on_socket_close'];
|
|
151
|
+
}
|
|
152
|
+
/** A harness instance — transport handle + connection factory. */
|
|
153
|
+
export interface WsTestHarness {
|
|
154
|
+
transport: BackendWebsocketTransport;
|
|
155
|
+
connect: (identity?: WsConnectIdentity) => MockWsClient;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Create a WebSocket test harness for the given specs + handlers.
|
|
159
|
+
*
|
|
160
|
+
* Registers against a throwaway Hono app with a fake
|
|
161
|
+
* `upgradeWebSocket`; the captured events factory is invoked per
|
|
162
|
+
* `connect()` with a synthesized Hono context carrying the requested
|
|
163
|
+
* auth identity. Returned clients drive the real
|
|
164
|
+
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
165
|
+
*/
|
|
166
|
+
export declare const create_ws_test_harness: <TCtx extends BaseHandlerContext>(options: CreateWsTestHarnessOptions<TCtx>) => WsTestHarness;
|
|
167
|
+
/** Convenience: default identity for keeper-authenticated connections. */
|
|
168
|
+
export declare const keeper_identity: () => WsConnectIdentity;
|
|
169
|
+
//# sourceMappingURL=ws_round_trip.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws_round_trip.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/ws_round_trip.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process test helpers for WebSocket JSON-RPC round-trips.
|
|
3
|
+
*
|
|
4
|
+
* Drives `register_action_ws` without an HTTP server. Consumers supply
|
|
5
|
+
* specs + handlers; the harness constructs real `WSContext` instances
|
|
6
|
+
* backed by test-owned `send`/`close` pairs, fakes the authenticated
|
|
7
|
+
* Hono context (`request_context`, credential type, session id, api
|
|
8
|
+
* token id), and exposes a `connect()` factory returning a
|
|
9
|
+
* `MockWsClient` per connection.
|
|
10
|
+
*
|
|
11
|
+
* Two layers are exported:
|
|
12
|
+
*
|
|
13
|
+
* - **Primitives** (`create_fake_ws`, `create_fake_hono_context`,
|
|
14
|
+
* `create_stub_upgrade`, `MinimalActionEnvironment`) — used by
|
|
15
|
+
* fuz_app's own dispatcher tests and by consumers wiring tight
|
|
16
|
+
* one-off tests.
|
|
17
|
+
* - **Harness** (`create_ws_test_harness`, `MockWsClient`,
|
|
18
|
+
* `keeper_identity`) — the high-level driver. Give it specs +
|
|
19
|
+
* handlers, get back `{transport, connect()}`. Use this unless you
|
|
20
|
+
* need bare primitives.
|
|
21
|
+
*
|
|
22
|
+
* Hono's wire upgrade is skipped — the Node test runtime has no
|
|
23
|
+
* `@hono/node-ws` adapter — but the full dispatch path is exercised
|
|
24
|
+
* (per-action auth, input validation, `ctx.notify` back to the
|
|
25
|
+
* originating socket, broadcast via `BackendWebsocketTransport`, and
|
|
26
|
+
* close-on-revoke).
|
|
27
|
+
*
|
|
28
|
+
* @module
|
|
29
|
+
*/
|
|
30
|
+
import { WSContext, createWSMessageEvent, } from 'hono/ws';
|
|
31
|
+
import { Logger } from '@fuzdev/fuz_util/log.js';
|
|
32
|
+
import { register_action_ws, } from '../actions/register_action_ws.js';
|
|
33
|
+
import { BackendWebsocketTransport } from '../actions/transports_ws_backend.js';
|
|
34
|
+
import { REQUEST_CONTEXT_KEY } from '../auth/request_context.js';
|
|
35
|
+
import { ROLE_KEEPER } from '../auth/role_schema.js';
|
|
36
|
+
import { AUTH_API_TOKEN_ID_KEY, CREDENTIAL_TYPE_KEY } from '../hono_context.js';
|
|
37
|
+
import { create_uuid } from '../uuid.js';
|
|
38
|
+
/**
|
|
39
|
+
* Build a real `WSContext` backed by in-memory `send`/`close` capture.
|
|
40
|
+
* Parsing of outgoing frames is left to the caller — `sends` holds the
|
|
41
|
+
* raw strings as the dispatcher wrote them.
|
|
42
|
+
*/
|
|
43
|
+
export const create_fake_ws = () => {
|
|
44
|
+
const sends = [];
|
|
45
|
+
const closes = [];
|
|
46
|
+
const init = {
|
|
47
|
+
send: (data) => {
|
|
48
|
+
sends.push(typeof data === 'string' ? data : '<binary>');
|
|
49
|
+
},
|
|
50
|
+
close: (code, reason) => {
|
|
51
|
+
closes.push({ code, reason });
|
|
52
|
+
},
|
|
53
|
+
readyState: 1,
|
|
54
|
+
};
|
|
55
|
+
return { ws: new WSContext(init), sends, closes };
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Build a fake Hono `Context` exposing the auth keys the dispatcher
|
|
59
|
+
* reads via `c.get(...)`. Only `.get()` is populated — no other Hono
|
|
60
|
+
* context surface is simulated.
|
|
61
|
+
*/
|
|
62
|
+
export const create_fake_hono_context = (opts) => {
|
|
63
|
+
const request_context = opts.request_context ?? build_simple_request_context(opts.role);
|
|
64
|
+
const vars = {
|
|
65
|
+
[REQUEST_CONTEXT_KEY]: request_context,
|
|
66
|
+
[CREDENTIAL_TYPE_KEY]: opts.credential_type,
|
|
67
|
+
auth_session_id: opts.auth_session_id ?? (opts.credential_type === 'session' ? 's1' : null),
|
|
68
|
+
[AUTH_API_TOKEN_ID_KEY]: opts.api_token_id ?? null,
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
get: (key) => vars[key],
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Build a fake `upgradeWebSocket` that captures the `createEvents`
|
|
76
|
+
* callback. The returned middleware is inert — tests drive
|
|
77
|
+
* `createEvents` directly.
|
|
78
|
+
*/
|
|
79
|
+
export const create_stub_upgrade = () => {
|
|
80
|
+
let captured = null;
|
|
81
|
+
const upgradeWebSocket = ((createEvents) => {
|
|
82
|
+
captured = createEvents;
|
|
83
|
+
return async (_c, next) => next();
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
upgradeWebSocket,
|
|
87
|
+
get_create_events: () => {
|
|
88
|
+
if (!captured)
|
|
89
|
+
throw new Error('upgradeWebSocket was not called');
|
|
90
|
+
return captured;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Minimal `ActionEventEnvironment` for tests that instantiate an
|
|
96
|
+
* `ActionPeer` without pulling in the full runtime. Pre-loads a
|
|
97
|
+
* spec map from the supplied list.
|
|
98
|
+
*/
|
|
99
|
+
export class MinimalActionEnvironment {
|
|
100
|
+
executor = 'backend';
|
|
101
|
+
#specs = new Map();
|
|
102
|
+
constructor(specs) {
|
|
103
|
+
for (const spec of specs)
|
|
104
|
+
this.#specs.set(spec.method, spec);
|
|
105
|
+
}
|
|
106
|
+
lookup_action_handler() {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
lookup_action_spec(method) {
|
|
110
|
+
return this.#specs.get(method);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Hono types `WSEvents.onMessage` as `() => void | Promise<void>`.
|
|
115
|
+
* Awaits only the Promise branch so tests observe full dispatch
|
|
116
|
+
* (auth, validation, handler, send).
|
|
117
|
+
*/
|
|
118
|
+
export const dispatch_ws_message = async (on_message, event, ws) => {
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
|
|
120
|
+
const result = on_message(event, ws);
|
|
121
|
+
if (result instanceof Promise)
|
|
122
|
+
await result;
|
|
123
|
+
};
|
|
124
|
+
const DEFAULT_TIMEOUT_MS = 1000;
|
|
125
|
+
/**
|
|
126
|
+
* Build a `RequestContext` with a fresh UUID account/actor and permits
|
|
127
|
+
* for the supplied roles. Used by the high-level harness so callers can
|
|
128
|
+
* pass `roles: [ROLE_KEEPER, 'admin']`.
|
|
129
|
+
*/
|
|
130
|
+
const build_multi_role_request_context = (account_id, roles) => {
|
|
131
|
+
const actor_id = create_uuid();
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
return {
|
|
134
|
+
account: {
|
|
135
|
+
id: account_id,
|
|
136
|
+
username: 'ws-test',
|
|
137
|
+
email: null,
|
|
138
|
+
email_verified: false,
|
|
139
|
+
password_hash: '',
|
|
140
|
+
created_at: now,
|
|
141
|
+
created_by: null,
|
|
142
|
+
updated_at: now,
|
|
143
|
+
updated_by: null,
|
|
144
|
+
},
|
|
145
|
+
actor: {
|
|
146
|
+
id: actor_id,
|
|
147
|
+
account_id,
|
|
148
|
+
name: 'ws-test',
|
|
149
|
+
created_at: now,
|
|
150
|
+
updated_at: null,
|
|
151
|
+
updated_by: null,
|
|
152
|
+
},
|
|
153
|
+
permits: roles.map((role) => ({
|
|
154
|
+
id: create_uuid(),
|
|
155
|
+
actor_id,
|
|
156
|
+
role,
|
|
157
|
+
created_at: now,
|
|
158
|
+
expires_at: null,
|
|
159
|
+
revoked_at: null,
|
|
160
|
+
revoked_by: null,
|
|
161
|
+
granted_by: null,
|
|
162
|
+
})),
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Stub `RequestContext` for single-role or public fakes. Hardcoded
|
|
167
|
+
* ids (`acc_1` / `act_1`) mirror `create_test_request_context` in
|
|
168
|
+
* `auth_apps.ts`.
|
|
169
|
+
*/
|
|
170
|
+
const build_simple_request_context = (role) => {
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
return {
|
|
173
|
+
account: {
|
|
174
|
+
id: 'acc_1',
|
|
175
|
+
username: 'testuser',
|
|
176
|
+
password_hash: 'hash',
|
|
177
|
+
created_at: now,
|
|
178
|
+
updated_at: now,
|
|
179
|
+
created_by: null,
|
|
180
|
+
updated_by: null,
|
|
181
|
+
email: null,
|
|
182
|
+
email_verified: false,
|
|
183
|
+
},
|
|
184
|
+
actor: {
|
|
185
|
+
id: 'act_1',
|
|
186
|
+
account_id: 'acc_1',
|
|
187
|
+
name: 'testuser',
|
|
188
|
+
created_at: now,
|
|
189
|
+
updated_at: null,
|
|
190
|
+
updated_by: null,
|
|
191
|
+
},
|
|
192
|
+
permits: role
|
|
193
|
+
? [
|
|
194
|
+
{
|
|
195
|
+
id: 'perm_1',
|
|
196
|
+
actor_id: 'act_1',
|
|
197
|
+
role,
|
|
198
|
+
created_at: now,
|
|
199
|
+
expires_at: null,
|
|
200
|
+
revoked_at: null,
|
|
201
|
+
revoked_by: null,
|
|
202
|
+
granted_by: null,
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
: [],
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Create a WebSocket test harness for the given specs + handlers.
|
|
210
|
+
*
|
|
211
|
+
* Registers against a throwaway Hono app with a fake
|
|
212
|
+
* `upgradeWebSocket`; the captured events factory is invoked per
|
|
213
|
+
* `connect()` with a synthesized Hono context carrying the requested
|
|
214
|
+
* auth identity. Returned clients drive the real
|
|
215
|
+
* `onOpen`/`onMessage`/`onClose` path against a real `WSContext`.
|
|
216
|
+
*/
|
|
217
|
+
export const create_ws_test_harness = (options) => {
|
|
218
|
+
const { specs, handlers, extend_context = (base) => base, transport = new BackendWebsocketTransport(), log = new Logger('[ws-test]', { level: 'off' }), on_socket_open, on_socket_close, } = options;
|
|
219
|
+
const stub = create_stub_upgrade();
|
|
220
|
+
// Minimal Hono stub — `register_action_ws` only needs `.get(path, handler)`.
|
|
221
|
+
const stub_app = { get: () => stub_app };
|
|
222
|
+
register_action_ws({
|
|
223
|
+
path: '/test/ws',
|
|
224
|
+
app: stub_app,
|
|
225
|
+
upgradeWebSocket: stub.upgradeWebSocket,
|
|
226
|
+
specs,
|
|
227
|
+
handlers,
|
|
228
|
+
extend_context,
|
|
229
|
+
transport,
|
|
230
|
+
log,
|
|
231
|
+
on_socket_open,
|
|
232
|
+
on_socket_close,
|
|
233
|
+
});
|
|
234
|
+
const events_factory = stub.get_create_events();
|
|
235
|
+
const connect = (identity = {}) => {
|
|
236
|
+
const account_id = identity.account_id ?? create_uuid();
|
|
237
|
+
const credential_type = identity.credential_type ?? 'session';
|
|
238
|
+
const session_id = identity.session_id ?? create_uuid();
|
|
239
|
+
const api_token_id = identity.api_token_id ?? null;
|
|
240
|
+
const roles = identity.roles ?? [];
|
|
241
|
+
const ctx_store = new Map([
|
|
242
|
+
[REQUEST_CONTEXT_KEY, build_multi_role_request_context(account_id, roles)],
|
|
243
|
+
[CREDENTIAL_TYPE_KEY, credential_type],
|
|
244
|
+
['auth_session_id', session_id],
|
|
245
|
+
[AUTH_API_TOKEN_ID_KEY, api_token_id],
|
|
246
|
+
]);
|
|
247
|
+
const fake_c = {
|
|
248
|
+
get: (key) => ctx_store.get(key),
|
|
249
|
+
};
|
|
250
|
+
const received = [];
|
|
251
|
+
const waiters = [];
|
|
252
|
+
let is_closed = false;
|
|
253
|
+
// Captured in `ws.close` below; `client.close(...)` returns it so
|
|
254
|
+
// tests can await async `on_socket_close` cleanup.
|
|
255
|
+
let close_pending = null;
|
|
256
|
+
// Real WSContext backed by test-owned send/close. Parsing is done
|
|
257
|
+
// on receive so tests can assert against structured messages.
|
|
258
|
+
const ws = new WSContext({
|
|
259
|
+
readyState: 1,
|
|
260
|
+
send: (data) => {
|
|
261
|
+
if (is_closed)
|
|
262
|
+
return;
|
|
263
|
+
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
264
|
+
received.push(parsed);
|
|
265
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
266
|
+
const waiter = waiters[i];
|
|
267
|
+
if (waiter.predicate(parsed)) {
|
|
268
|
+
waiter.resolve(parsed);
|
|
269
|
+
waiters.splice(i, 1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
close: (code, reason) => {
|
|
274
|
+
if (is_closed)
|
|
275
|
+
return;
|
|
276
|
+
is_closed = true;
|
|
277
|
+
const close_event = new Event('close');
|
|
278
|
+
Object.defineProperties(close_event, {
|
|
279
|
+
code: { value: code ?? 1000, writable: false },
|
|
280
|
+
reason: { value: reason ?? '', writable: false },
|
|
281
|
+
wasClean: { value: true, writable: false },
|
|
282
|
+
});
|
|
283
|
+
close_pending = Promise.resolve(events).then(async (e) => {
|
|
284
|
+
// onClose is typed as `void` by Hono but `register_action_ws`
|
|
285
|
+
// returns a promise when `on_socket_close` does async cleanup.
|
|
286
|
+
await e.onClose?.(close_event, ws);
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
// Resolve the (possibly async) events factory synchronously via
|
|
291
|
+
// a microtask chain. Tests always await `connect().send(...)`
|
|
292
|
+
// which sequences after.
|
|
293
|
+
let events = events_factory(fake_c);
|
|
294
|
+
const events_ready = Promise.resolve(events).then(async (resolved) => {
|
|
295
|
+
events = resolved;
|
|
296
|
+
// onOpen is typed as `void` by Hono but `register_action_ws`
|
|
297
|
+
// returns a promise when `on_socket_open` does async bootstrap.
|
|
298
|
+
// Tests have to see the fully-bootstrapped socket before
|
|
299
|
+
// `send()`, so wait on the hook chain here.
|
|
300
|
+
await resolved.onOpen?.(new Event('open'), ws);
|
|
301
|
+
return resolved;
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
get messages() {
|
|
305
|
+
return received;
|
|
306
|
+
},
|
|
307
|
+
async send(message) {
|
|
308
|
+
const resolved = await events_ready;
|
|
309
|
+
if (is_closed)
|
|
310
|
+
throw new Error('send after close');
|
|
311
|
+
const message_event = createWSMessageEvent(JSON.stringify(message));
|
|
312
|
+
// `onMessage` is typed as returning void by Hono, but
|
|
313
|
+
// `register_action_ws` implements it as async — cast so
|
|
314
|
+
// tests await the full dispatch (auth, validation,
|
|
315
|
+
// handler, send).
|
|
316
|
+
await resolved.onMessage?.(message_event, ws);
|
|
317
|
+
},
|
|
318
|
+
async close(code, reason) {
|
|
319
|
+
if (is_closed)
|
|
320
|
+
return close_pending ?? undefined;
|
|
321
|
+
ws.close(code, reason);
|
|
322
|
+
return close_pending ?? undefined;
|
|
323
|
+
},
|
|
324
|
+
wait_for(predicate, timeout_ms = DEFAULT_TIMEOUT_MS) {
|
|
325
|
+
for (const msg of received) {
|
|
326
|
+
if (predicate(msg))
|
|
327
|
+
return Promise.resolve(msg);
|
|
328
|
+
}
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const timer = setTimeout(() => reject(new Error(`wait_for timed out after ${timeout_ms}ms`)), timeout_ms);
|
|
331
|
+
waiters.push({
|
|
332
|
+
predicate,
|
|
333
|
+
resolve: (msg) => {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
resolve(msg);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
return { transport, connect };
|
|
343
|
+
};
|
|
344
|
+
/** Convenience: default identity for keeper-authenticated connections. */
|
|
345
|
+
export const keeper_identity = () => ({
|
|
346
|
+
credential_type: 'daemon_token',
|
|
347
|
+
roles: [ROLE_KEEPER],
|
|
348
|
+
});
|