@graphorin/server 0.6.1 → 0.7.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/CHANGELOG.md +71 -0
- package/README.md +3 -3
- package/dist/app-audit-binding.d.ts +15 -0
- package/dist/app-audit-binding.d.ts.map +1 -0
- package/dist/app-audit-binding.js +136 -0
- package/dist/app-audit-binding.js.map +1 -0
- package/dist/app-daemons.d.ts +29 -0
- package/dist/app-daemons.d.ts.map +1 -0
- package/dist/app-daemons.js +27 -0
- package/dist/app-daemons.js.map +1 -0
- package/dist/app-lifecycle.js +272 -0
- package/dist/app-lifecycle.js.map +1 -0
- package/dist/app-metrics.js +79 -0
- package/dist/app-metrics.js.map +1 -0
- package/dist/app-middleware.js +92 -0
- package/dist/app-middleware.js.map +1 -0
- package/dist/app-routes.js +131 -0
- package/dist/app-routes.js.map +1 -0
- package/dist/app-ws.js +65 -0
- package/dist/app-ws.js.map +1 -0
- package/dist/app.d.ts +16 -16
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +35 -607
- package/dist/app.js.map +1 -1
- package/dist/commentary/built-in-patterns.d.ts +1 -1
- package/dist/commentary/built-in-patterns.js +1 -1
- package/dist/commentary/built-in-patterns.js.map +1 -1
- package/dist/config.d.ts +100 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -2
- package/dist/config.js.map +1 -1
- package/dist/health/checks.d.ts +15 -1
- package/dist/health/checks.d.ts.map +1 -1
- package/dist/health/checks.js +21 -0
- package/dist/health/checks.js.map +1 -1
- package/dist/health/routes.js +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -24
- package/dist/index.js.map +1 -1
- package/dist/internal/context.d.ts +2 -2
- package/dist/internal/wire-error.js +20 -0
- package/dist/internal/wire-error.js.map +1 -0
- package/dist/lifecycle/pre-bind.d.ts +1 -1
- package/dist/metrics/catalog.d.ts.map +1 -1
- package/dist/metrics/catalog.js.map +1 -1
- package/dist/metrics/tools-bridge.d.ts +15 -0
- package/dist/metrics/tools-bridge.d.ts.map +1 -0
- package/dist/metrics/tools-bridge.js +103 -0
- package/dist/metrics/tools-bridge.js.map +1 -0
- package/dist/middleware/audit.d.ts +1 -1
- package/dist/middleware/auth.js +1 -1
- package/dist/middleware/csrf.js +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/scope.d.ts.map +1 -1
- package/dist/middleware/scope.js +44 -4
- package/dist/middleware/scope.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/registry/index.d.ts +33 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js.map +1 -1
- package/dist/replay/routes.d.ts +1 -1
- package/dist/replay/routes.js +2 -2
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +64 -18
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/auth.js +2 -2
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.js +5 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/routes/tokens.d.ts +1 -1
- package/dist/routes/tokens.d.ts.map +1 -1
- package/dist/routes/tokens.js +21 -1
- package/dist/routes/tokens.js.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/routes/workflows.js +165 -19
- package/dist/routes/workflows.js.map +1 -1
- package/dist/runtime/retention.d.ts +105 -0
- package/dist/runtime/retention.d.ts.map +1 -0
- package/dist/runtime/retention.js +154 -0
- package/dist/runtime/retention.js.map +1 -0
- package/dist/runtime/run-state.d.ts +2 -0
- package/dist/runtime/run-state.d.ts.map +1 -1
- package/dist/runtime/run-state.js +34 -2
- package/dist/runtime/run-state.js.map +1 -1
- package/dist/sse/events.js +3 -4
- package/dist/sse/events.js.map +1 -1
- package/dist/tools-audit-bridge.d.ts +29 -0
- package/dist/tools-audit-bridge.d.ts.map +1 -0
- package/dist/tools-audit-bridge.js +103 -0
- package/dist/tools-audit-bridge.js.map +1 -0
- package/dist/workflows/timer-daemon.d.ts +69 -0
- package/dist/workflows/timer-daemon.d.ts.map +1 -0
- package/dist/workflows/timer-daemon.js +37 -0
- package/dist/workflows/timer-daemon.js.map +1 -0
- package/dist/ws/dispatcher.d.ts +1 -1
- package/dist/ws/dispatcher.js +19 -12
- package/dist/ws/dispatcher.js.map +1 -1
- package/dist/ws/index.d.ts +2 -2
- package/dist/ws/index.js +3 -3
- package/dist/ws/replay-buffer.d.ts +35 -1
- package/dist/ws/replay-buffer.d.ts.map +1 -1
- package/dist/ws/replay-buffer.js +35 -3
- package/dist/ws/replay-buffer.js.map +1 -1
- package/dist/ws/upgrade.d.ts.map +1 -1
- package/dist/ws/upgrade.js +24 -18
- package/dist/ws/upgrade.js.map +1 -1
- package/package.json +30 -29
- package/src/app-audit-binding.ts +227 -0
- package/src/app-daemons.ts +73 -0
- package/src/app-lifecycle.ts +476 -0
- package/src/app-metrics.ts +144 -0
- package/src/app-middleware.ts +149 -0
- package/src/app-routes.ts +305 -0
- package/src/app-ws.ts +111 -0
- package/src/app.ts +317 -0
- package/src/commentary/audit-bridge.ts +135 -0
- package/src/commentary/built-in-patterns.ts +79 -0
- package/src/commentary/index.ts +32 -0
- package/src/commentary/sanitizer.ts +238 -0
- package/src/commentary/types.ts +130 -0
- package/src/config.ts +472 -0
- package/src/consolidator/daemon.ts +141 -0
- package/src/consolidator/index.ts +14 -0
- package/src/errors/index.ts +247 -0
- package/src/health/checks.ts +322 -0
- package/src/health/index.ts +34 -0
- package/src/health/routes.ts +154 -0
- package/src/index.ts +243 -0
- package/src/internal/context.ts +77 -0
- package/src/internal/ids.ts +17 -0
- package/src/internal/json.ts +47 -0
- package/src/internal/wire-error.ts +44 -0
- package/src/lifecycle/hooks.ts +66 -0
- package/src/lifecycle/index.ts +16 -0
- package/src/lifecycle/pre-bind.ts +138 -0
- package/src/metrics/catalog.ts +112 -0
- package/src/metrics/index.ts +12 -0
- package/src/metrics/registry.ts +337 -0
- package/src/metrics/tools-bridge.ts +124 -0
- package/src/middleware/audit.ts +114 -0
- package/src/middleware/auth.ts +195 -0
- package/src/middleware/cors.ts +72 -0
- package/src/middleware/csrf.ts +98 -0
- package/src/middleware/idempotency.ts +336 -0
- package/src/middleware/index.ts +38 -0
- package/src/middleware/rate-limit.ts +71 -0
- package/src/middleware/request-state.ts +79 -0
- package/src/middleware/scope.ts +161 -0
- package/src/registry/index.ts +278 -0
- package/src/replay/index.ts +14 -0
- package/src/replay/routes.ts +255 -0
- package/src/routes/agents.ts +363 -0
- package/src/routes/audit.ts +207 -0
- package/src/routes/auth.ts +80 -0
- package/src/routes/health.ts +42 -0
- package/src/routes/index.ts +16 -0
- package/src/routes/mcp.ts +97 -0
- package/src/routes/memory.ts +152 -0
- package/src/routes/sessions.ts +250 -0
- package/src/routes/skills.ts +91 -0
- package/src/routes/tokens.ts +166 -0
- package/src/routes/workflows.ts +616 -0
- package/src/runtime/retention.ts +284 -0
- package/src/runtime/run-state.ts +422 -0
- package/src/sse/events.ts +303 -0
- package/src/sse/index.ts +7 -0
- package/src/tools-audit-bridge.ts +120 -0
- package/src/triggers/daemon.ts +198 -0
- package/src/triggers/index.ts +16 -0
- package/src/triggers/routes.ts +139 -0
- package/src/workflows/timer-daemon.ts +102 -0
- package/src/ws/dispatcher.ts +537 -0
- package/src/ws/index.ts +45 -0
- package/src/ws/replay-buffer.ts +209 -0
- package/src/ws/subjects.ts +162 -0
- package/src/ws/ticket.ts +174 -0
- package/src/ws/upgrade.ts +507 -0
- package/dist/lifecycle/index.js +0 -3
- package/dist/routes/index.js +0 -12
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
2
|
+
/**
|
|
3
|
+
* Hono WebSocket upgrade handler. Wires the `@hono/node-ws` adapter
|
|
4
|
+
* to the dispatcher: validates the subprotocol, runs auth (bearer
|
|
5
|
+
* via `requireAuth` middleware OR ticket via the in-memory store),
|
|
6
|
+
* and bridges the per-connection `WSContext` callbacks to the
|
|
7
|
+
* dispatcher's RPC handler.
|
|
8
|
+
*
|
|
9
|
+
* ## Subprotocol + browser ticket flow
|
|
10
|
+
*
|
|
11
|
+
* The negotiated subprotocol is always {@link SUBPROTOCOL_NAME}
|
|
12
|
+
* (`graphorin.protocol.v1`) - the canonical wire contract lives in
|
|
13
|
+
* `@graphorin/protocol`'s `subprotocol.ts`. Two auth paths exist:
|
|
14
|
+
*
|
|
15
|
+
* - **Bearer (non-browser):** the client sends `Authorization:
|
|
16
|
+
* Bearer <token>` and a single `Sec-WebSocket-Protocol:
|
|
17
|
+
* graphorin.protocol.v1` token.
|
|
18
|
+
* - **Ticket (browser):** the `WebSocket` constructor cannot set
|
|
19
|
+
* headers, so the browser client offers two subprotocol tokens -
|
|
20
|
+
* `graphorin.protocol.v1` and `ticket.<value>`. The server echoes
|
|
21
|
+
* back only the canonical name (in `app.ts`'s `handleProtocols`),
|
|
22
|
+
* and {@link resolveUpgradeAuth} extracts the ticket via
|
|
23
|
+
* `parseTicketSubprotocol` and redeems it against the single-use
|
|
24
|
+
* {@link WsTicketStore}. Tickets are short-lived and one-shot to
|
|
25
|
+
* bound replay.
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
ClientMessageSchema,
|
|
32
|
+
closeCodeFor,
|
|
33
|
+
isCancelledNotification,
|
|
34
|
+
isInitializeRequest,
|
|
35
|
+
isPingRequest,
|
|
36
|
+
isRunCancelRequest,
|
|
37
|
+
isSubscribeRequest,
|
|
38
|
+
isUnsubscribeRequest,
|
|
39
|
+
negotiateSubprotocol,
|
|
40
|
+
parseTicketSubprotocol,
|
|
41
|
+
RPC_ERROR_CODES,
|
|
42
|
+
type ServerMessage,
|
|
43
|
+
SUBPROTOCOL_NAME,
|
|
44
|
+
} from '@graphorin/protocol';
|
|
45
|
+
import type { ParsedScope, TokenVerifier } from '@graphorin/security/auth';
|
|
46
|
+
import { parseScope } from '@graphorin/security/auth';
|
|
47
|
+
import type { Context } from 'hono';
|
|
48
|
+
import type { WSContext, WSEvents } from 'hono/ws';
|
|
49
|
+
|
|
50
|
+
import type { ServerVariables } from '../internal/context.js';
|
|
51
|
+
import { checkScope } from '../middleware/scope.js';
|
|
52
|
+
import { type RunStateTracker, requiredRunScope } from '../runtime/run-state.js';
|
|
53
|
+
import type { WsDispatcher } from './dispatcher.js';
|
|
54
|
+
import type { WsTicket, WsTicketStore } from './ticket.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Public configuration accepted by {@link createWsUpgradeEvents}.
|
|
58
|
+
*
|
|
59
|
+
* @stable
|
|
60
|
+
*/
|
|
61
|
+
export interface WsUpgradeOptions {
|
|
62
|
+
readonly dispatcher: WsDispatcher;
|
|
63
|
+
readonly tickets: WsTicketStore;
|
|
64
|
+
/**
|
|
65
|
+
* Token verifier for bearer / ticket upgrades. Optional only in the
|
|
66
|
+
* IP-13 no-auth loopback mode (`anonymous: true`), where there is no
|
|
67
|
+
* verifier to construct and every upgrade is accepted.
|
|
68
|
+
*/
|
|
69
|
+
readonly verifier?: TokenVerifier;
|
|
70
|
+
/**
|
|
71
|
+
* IP-13: authentication is disabled server-wide (`auth.kind='none'`).
|
|
72
|
+
* Accept the upgrade unconditionally with a full (`admin:*`) scope grant
|
|
73
|
+
* instead of silently refusing to mount the WS route. Trusted-loopback
|
|
74
|
+
* / single-operator mode only.
|
|
75
|
+
*/
|
|
76
|
+
readonly anonymous?: boolean;
|
|
77
|
+
readonly runs?: RunStateTracker;
|
|
78
|
+
readonly now?: () => number;
|
|
79
|
+
/**
|
|
80
|
+
* Subprotocol the server advertises. Defaults to
|
|
81
|
+
* {@link SUBPROTOCOL_NAME}; tests can override to exercise the
|
|
82
|
+
* mismatch path.
|
|
83
|
+
*/
|
|
84
|
+
readonly serverSubprotocol?: string;
|
|
85
|
+
readonly newSubscriptionId?: () => string;
|
|
86
|
+
readonly newSubscriberId?: () => string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the `WSEvents` callback bag the Hono helper consumes. The
|
|
91
|
+
* function takes the request `Context` so the upgrade can read the
|
|
92
|
+
* `Authorization` header / `Sec-WebSocket-Protocol` ticket directly.
|
|
93
|
+
*
|
|
94
|
+
* Production wiring on `@hono/node-ws`:
|
|
95
|
+
*
|
|
96
|
+
* ```ts
|
|
97
|
+
* const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
|
98
|
+
* app.get('/v1/ws', upgradeWebSocket((c) => createWsUpgradeEvents(c, deps)));
|
|
99
|
+
* injectWebSocket(serve(...));
|
|
100
|
+
* ```
|
|
101
|
+
*
|
|
102
|
+
* @stable
|
|
103
|
+
*/
|
|
104
|
+
export async function createWsUpgradeEvents(
|
|
105
|
+
c: Context<{ Variables: ServerVariables }>,
|
|
106
|
+
options: WsUpgradeOptions,
|
|
107
|
+
): Promise<WSEvents> {
|
|
108
|
+
const negotiated = negotiateSubprotocol(c.req.header('sec-websocket-protocol') ?? '');
|
|
109
|
+
if (negotiated === null) {
|
|
110
|
+
return rejectImmediately(closeCodeFor('protocol.violation'), 'protocol.violation');
|
|
111
|
+
}
|
|
112
|
+
const auth = await resolveUpgradeAuth(c, options);
|
|
113
|
+
if (auth.kind === 'denied') {
|
|
114
|
+
return rejectImmediately(closeCodeFor(auth.reason), auth.reason);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const subscriberId = options.newSubscriberId?.() ?? defaultSubscriberId();
|
|
118
|
+
const newSubscriptionId =
|
|
119
|
+
options.newSubscriptionId ?? (() => defaultSubscriptionId(subscriberId));
|
|
120
|
+
|
|
121
|
+
let unregister: (() => void) | undefined;
|
|
122
|
+
let initialized = false;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
onOpen: (_event, ws) => {
|
|
126
|
+
const handle = {
|
|
127
|
+
id: subscriberId,
|
|
128
|
+
tokenId: auth.tokenId,
|
|
129
|
+
grantedScopes: auth.grantedScopes,
|
|
130
|
+
send: (frame: ServerMessage) => {
|
|
131
|
+
ws.send(JSON.stringify(frame));
|
|
132
|
+
},
|
|
133
|
+
close: (code: number, reason: string) => {
|
|
134
|
+
ws.close(code, reason);
|
|
135
|
+
},
|
|
136
|
+
// IP-9: surface the socket's real backlog so the dispatcher's
|
|
137
|
+
// backpressure threshold measures something - the synchronous
|
|
138
|
+
// outstanding-events counter never accumulates.
|
|
139
|
+
bufferedAmount: () => {
|
|
140
|
+
const raw = (ws as { raw?: { bufferedAmount?: number } }).raw;
|
|
141
|
+
return raw?.bufferedAmount ?? 0;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const reg = options.dispatcher.registerSubscriber(handle);
|
|
145
|
+
unregister = reg.unregister;
|
|
146
|
+
},
|
|
147
|
+
onMessage: (event, ws) => {
|
|
148
|
+
const raw = typeof event.data === 'string' ? event.data : '';
|
|
149
|
+
let payload: unknown;
|
|
150
|
+
try {
|
|
151
|
+
payload = JSON.parse(raw);
|
|
152
|
+
} catch {
|
|
153
|
+
sendRpcError(ws, undefined, RPC_ERROR_CODES.PARSE_ERROR, 'Frame is not valid JSON.');
|
|
154
|
+
ws.close(closeCodeFor('protocol.violation'), 'protocol.violation');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const parsed = ClientMessageSchema.safeParse(payload);
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
sendRpcError(
|
|
160
|
+
ws,
|
|
161
|
+
(payload as { id?: string | number } | null)?.id,
|
|
162
|
+
RPC_ERROR_CODES.INVALID_REQUEST,
|
|
163
|
+
'Frame failed schema validation.',
|
|
164
|
+
);
|
|
165
|
+
ws.close(closeCodeFor('protocol.violation'), 'protocol.violation');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const message = parsed.data;
|
|
169
|
+
if (isCancelledNotification(message)) {
|
|
170
|
+
// IP-8 / W-107: only an initialized connection whose grant covers
|
|
171
|
+
// the run's OWNING resource may cancel it. A notification carries
|
|
172
|
+
// no id, so an unauthorised one is silently ignored (there is no
|
|
173
|
+
// error channel) rather than aborting an arbitrary run.
|
|
174
|
+
if (initialized) {
|
|
175
|
+
const snapshot = options.runs?.snapshot(message.params.requestId);
|
|
176
|
+
if (
|
|
177
|
+
snapshot !== undefined &&
|
|
178
|
+
checkScope(
|
|
179
|
+
{ kind: 'token', grantedScopes: auth.grantedScopes } as never,
|
|
180
|
+
requiredRunScope(snapshot, 'control'),
|
|
181
|
+
)
|
|
182
|
+
) {
|
|
183
|
+
options.runs?.abort(message.params.requestId, 'mcp-cancel');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!initialized && !isInitializeRequest(message) && !isPingRequest(message)) {
|
|
189
|
+
// IP-21: a frame before `initialize` is a protocol-sequencing error,
|
|
190
|
+
// not an auth failure - the connection is already authenticated.
|
|
191
|
+
sendRpcError(
|
|
192
|
+
ws,
|
|
193
|
+
message.id,
|
|
194
|
+
RPC_ERROR_CODES.PROTOCOL_VIOLATION,
|
|
195
|
+
'Send `initialize` before any other RPC.',
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (isInitializeRequest(message)) {
|
|
200
|
+
initialized = true;
|
|
201
|
+
sendRpcSuccess(ws, message.id, {
|
|
202
|
+
serverInfo: {
|
|
203
|
+
name: 'graphorin-server',
|
|
204
|
+
version: pkg.version,
|
|
205
|
+
},
|
|
206
|
+
capabilities: {
|
|
207
|
+
subscriptions: true,
|
|
208
|
+
replayBuffer: true,
|
|
209
|
+
sseFallback: true,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (isPingRequest(message)) {
|
|
215
|
+
const nonce = message.params?.nonce;
|
|
216
|
+
ws.send(
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
v: '1',
|
|
219
|
+
kind: 'pong',
|
|
220
|
+
...(nonce !== undefined ? { nonce } : {}),
|
|
221
|
+
} satisfies ServerMessage),
|
|
222
|
+
);
|
|
223
|
+
sendRpcSuccess(ws, message.id, { ok: true });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (isSubscribeRequest(message)) {
|
|
227
|
+
const subscriptionId = newSubscriptionId();
|
|
228
|
+
const result = options.dispatcher.subscribe({
|
|
229
|
+
subscriberId,
|
|
230
|
+
subject: message.params.subject,
|
|
231
|
+
subscriptionId,
|
|
232
|
+
...(message.params.sinceEventId !== undefined
|
|
233
|
+
? { sinceEventId: message.params.sinceEventId }
|
|
234
|
+
: {}),
|
|
235
|
+
});
|
|
236
|
+
if (!result.ok) {
|
|
237
|
+
sendRpcError(
|
|
238
|
+
ws,
|
|
239
|
+
message.id,
|
|
240
|
+
mapSubscribeErrorCode(result.reason),
|
|
241
|
+
mapSubscribeErrorMessage(result.reason, message.params.subject),
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
sendRpcSuccess(ws, message.id, {
|
|
246
|
+
subscriptionId,
|
|
247
|
+
subject: message.params.subject,
|
|
248
|
+
snapshotEventId: result.snapshotEventId,
|
|
249
|
+
replayedCount: result.replayedCount,
|
|
250
|
+
});
|
|
251
|
+
ws.send(
|
|
252
|
+
JSON.stringify({
|
|
253
|
+
v: '1',
|
|
254
|
+
kind: 'subscribed',
|
|
255
|
+
subscriptionId,
|
|
256
|
+
subject: message.params.subject,
|
|
257
|
+
...(result.snapshotEventId !== undefined
|
|
258
|
+
? { snapshotEventId: result.snapshotEventId }
|
|
259
|
+
: {}),
|
|
260
|
+
} satisfies ServerMessage),
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (isUnsubscribeRequest(message)) {
|
|
265
|
+
const removed = options.dispatcher.unsubscribe(message.params.subscriptionId);
|
|
266
|
+
if (!removed) {
|
|
267
|
+
sendRpcError(
|
|
268
|
+
ws,
|
|
269
|
+
message.id,
|
|
270
|
+
RPC_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
|
|
271
|
+
`Subscription '${message.params.subscriptionId}' not active.`,
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
sendRpcSuccess(ws, message.id, {
|
|
276
|
+
subscriptionId: message.params.subscriptionId,
|
|
277
|
+
unsubscribed: true,
|
|
278
|
+
});
|
|
279
|
+
ws.send(
|
|
280
|
+
JSON.stringify({
|
|
281
|
+
v: '1',
|
|
282
|
+
kind: 'unsubscribed',
|
|
283
|
+
subscriptionId: message.params.subscriptionId,
|
|
284
|
+
} satisfies ServerMessage),
|
|
285
|
+
);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (isRunCancelRequest(message)) {
|
|
289
|
+
// IP-8 / W-107: mirror the REST `POST /runs/:runId/abort` gate at
|
|
290
|
+
// the same per-resource granularity - snapshot first (unknown run
|
|
291
|
+
// answers RUN_NOT_FOUND, matching REST's 404-before-403 order),
|
|
292
|
+
// then require the OWNING agent's/workflow's control scope.
|
|
293
|
+
const runId = message.params.runId;
|
|
294
|
+
const snapshot = options.runs?.snapshot(runId);
|
|
295
|
+
if (snapshot === undefined) {
|
|
296
|
+
sendRpcError(
|
|
297
|
+
ws,
|
|
298
|
+
message.id,
|
|
299
|
+
RPC_ERROR_CODES.RUN_NOT_FOUND,
|
|
300
|
+
`Run '${runId}' is not active.`,
|
|
301
|
+
);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const required = requiredRunScope(snapshot, 'control');
|
|
305
|
+
if (!checkScope({ kind: 'token', grantedScopes: auth.grantedScopes } as never, required)) {
|
|
306
|
+
sendRpcError(
|
|
307
|
+
ws,
|
|
308
|
+
message.id,
|
|
309
|
+
RPC_ERROR_CODES.SCOPE_DENIED,
|
|
310
|
+
`Token lacks required scope '${required}'.`,
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const aborted = options.runs?.abort(runId, message.params.reason ?? 'rpc-cancel') === true;
|
|
315
|
+
if (!aborted) {
|
|
316
|
+
sendRpcError(
|
|
317
|
+
ws,
|
|
318
|
+
message.id,
|
|
319
|
+
RPC_ERROR_CODES.RUN_NOT_FOUND,
|
|
320
|
+
`Run '${runId}' is not active.`,
|
|
321
|
+
);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
sendRpcSuccess(ws, message.id, {
|
|
325
|
+
runId,
|
|
326
|
+
cancelled: true,
|
|
327
|
+
partialStateAvailable: true,
|
|
328
|
+
});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
onClose: () => {
|
|
333
|
+
try {
|
|
334
|
+
unregister?.();
|
|
335
|
+
} finally {
|
|
336
|
+
unregister = undefined;
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
onError: () => {
|
|
340
|
+
try {
|
|
341
|
+
unregister?.();
|
|
342
|
+
} finally {
|
|
343
|
+
unregister = undefined;
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function sendRpcSuccess(ws: WSContext, id: string | number, result: unknown): void {
|
|
350
|
+
const frame: ServerMessage = { v: '1', jsonrpc: '2.0', id, result };
|
|
351
|
+
ws.send(JSON.stringify(frame));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sendRpcError(
|
|
355
|
+
ws: WSContext,
|
|
356
|
+
id: string | number | undefined,
|
|
357
|
+
code: number,
|
|
358
|
+
message: string,
|
|
359
|
+
): void {
|
|
360
|
+
const frame: ServerMessage = {
|
|
361
|
+
v: '1',
|
|
362
|
+
jsonrpc: '2.0',
|
|
363
|
+
id: id ?? 0,
|
|
364
|
+
error: { code, message },
|
|
365
|
+
};
|
|
366
|
+
ws.send(JSON.stringify(frame));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function rejectImmediately(code: number, reason: string): WSEvents {
|
|
370
|
+
return {
|
|
371
|
+
onOpen: (_event, ws) => {
|
|
372
|
+
ws.close(code, reason);
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface ResolvedUpgradeAuthOk {
|
|
378
|
+
readonly kind: 'ok';
|
|
379
|
+
readonly tokenId: string;
|
|
380
|
+
readonly grantedScopes: ReadonlyArray<ParsedScope>;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface ResolvedUpgradeAuthDenied {
|
|
384
|
+
readonly kind: 'denied';
|
|
385
|
+
readonly reason: 'auth.required' | 'auth.invalid' | 'auth.scope_denied';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
type ResolvedUpgradeAuth = ResolvedUpgradeAuthOk | ResolvedUpgradeAuthDenied;
|
|
389
|
+
|
|
390
|
+
async function resolveUpgradeAuth(
|
|
391
|
+
c: Context<{ Variables: ServerVariables }>,
|
|
392
|
+
options: WsUpgradeOptions,
|
|
393
|
+
): Promise<ResolvedUpgradeAuth> {
|
|
394
|
+
// Phase 14a's HTTP middleware may have already verified the
|
|
395
|
+
// bearer token (`requireAuth` runs before the upgrade route on
|
|
396
|
+
// the authenticated subtree); if so, reuse the result.
|
|
397
|
+
const state = c.var.state;
|
|
398
|
+
if (state?.auth?.kind === 'token') {
|
|
399
|
+
return {
|
|
400
|
+
kind: 'ok',
|
|
401
|
+
tokenId: state.auth.token.tokenId,
|
|
402
|
+
grantedScopes: state.auth.grantedScopes,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// IP-13: no-auth loopback mode - accept the upgrade with a full scope grant
|
|
406
|
+
// instead of silently failing to mount. Checked before ticket/bearer so the
|
|
407
|
+
// verifier may legitimately be absent.
|
|
408
|
+
if (options.anonymous === true) {
|
|
409
|
+
return { kind: 'ok', tokenId: 'anonymous', grantedScopes: ANONYMOUS_WS_SCOPES };
|
|
410
|
+
}
|
|
411
|
+
const ticketValue = parseTicketSubprotocol(c.req.header('sec-websocket-protocol') ?? '');
|
|
412
|
+
if (ticketValue !== undefined) {
|
|
413
|
+
const consumed = options.tickets.consume(ticketValue);
|
|
414
|
+
if (!consumed.ok) {
|
|
415
|
+
return { kind: 'denied', reason: 'auth.invalid' };
|
|
416
|
+
}
|
|
417
|
+
return acceptTicket(consumed.ticket);
|
|
418
|
+
}
|
|
419
|
+
const header = c.req.header('authorization') ?? c.req.header('Authorization');
|
|
420
|
+
if (header?.toLowerCase().startsWith('bearer ') === true) {
|
|
421
|
+
if (options.verifier === undefined) return { kind: 'denied', reason: 'auth.required' };
|
|
422
|
+
const token = header.slice(7).trim();
|
|
423
|
+
// P-05: pass the client IP like the HTTP auth middleware does, so
|
|
424
|
+
// the verifier's per-IP failure threshold / lockout engages for
|
|
425
|
+
// upgrade attempts too - without it `GET /v1/ws` was a
|
|
426
|
+
// lockout-free brute-force surface.
|
|
427
|
+
const ip = c.get('state')?.clientIp;
|
|
428
|
+
const verified = await options.verifier.verify(token, ip !== undefined ? { ip } : {});
|
|
429
|
+
if (!verified.ok) return { kind: 'denied', reason: 'auth.invalid' };
|
|
430
|
+
return {
|
|
431
|
+
kind: 'ok',
|
|
432
|
+
tokenId: verified.token.tokenId,
|
|
433
|
+
grantedScopes: verified.token.scopes,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return { kind: 'denied', reason: 'auth.required' };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* IP-13: the anonymous trusted-loopback principal (auth disabled) holds
|
|
441
|
+
* `admin:*` so every per-subject and per-run check passes uniformly.
|
|
442
|
+
*/
|
|
443
|
+
const ANONYMOUS_WS_SCOPES: ReadonlyArray<ParsedScope> = [parseScope('admin:*')];
|
|
444
|
+
|
|
445
|
+
function acceptTicket(ticket: WsTicket): ResolvedUpgradeAuth {
|
|
446
|
+
return {
|
|
447
|
+
kind: 'ok',
|
|
448
|
+
tokenId: ticket.tokenId,
|
|
449
|
+
grantedScopes: ticket.scopes,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function mapSubscribeErrorCode(
|
|
454
|
+
reason: 'subject-malformed' | 'subject-unknown' | 'subject-wildcard' | 'scope-denied',
|
|
455
|
+
): number {
|
|
456
|
+
switch (reason) {
|
|
457
|
+
case 'scope-denied':
|
|
458
|
+
return RPC_ERROR_CODES.SCOPE_DENIED;
|
|
459
|
+
case 'subject-wildcard':
|
|
460
|
+
return RPC_ERROR_CODES.INVALID_PARAMS;
|
|
461
|
+
case 'subject-unknown':
|
|
462
|
+
return RPC_ERROR_CODES.METHOD_NOT_FOUND;
|
|
463
|
+
case 'subject-malformed':
|
|
464
|
+
return RPC_ERROR_CODES.INVALID_PARAMS;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function mapSubscribeErrorMessage(
|
|
469
|
+
reason: 'subject-malformed' | 'subject-unknown' | 'subject-wildcard' | 'scope-denied',
|
|
470
|
+
subject: string,
|
|
471
|
+
): string {
|
|
472
|
+
switch (reason) {
|
|
473
|
+
case 'scope-denied':
|
|
474
|
+
return `Token lacks required scope for subject '${subject}'.`;
|
|
475
|
+
case 'subject-wildcard':
|
|
476
|
+
return `Wildcard subjects are not supported in this version. Subject: '${subject}'.`;
|
|
477
|
+
case 'subject-unknown':
|
|
478
|
+
return `Unknown subject grammar: '${subject}'.`;
|
|
479
|
+
case 'subject-malformed':
|
|
480
|
+
return `Subject '${subject}' is malformed.`;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function defaultSubscriberId(): string {
|
|
485
|
+
return `sub-conn-${randomToken(8)}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function defaultSubscriptionId(subscriberId: string): string {
|
|
489
|
+
return `${subscriberId}-${randomToken(6)}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function randomToken(length: number): string {
|
|
493
|
+
const bytes = new Uint8Array(length);
|
|
494
|
+
if (
|
|
495
|
+
typeof globalThis.crypto !== 'undefined' &&
|
|
496
|
+
typeof globalThis.crypto.getRandomValues === 'function'
|
|
497
|
+
) {
|
|
498
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
499
|
+
} else {
|
|
500
|
+
for (let i = 0; i < length; i += 1) {
|
|
501
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return Buffer.from(bytes).toString('hex');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
void SUBPROTOCOL_NAME;
|
package/dist/lifecycle/index.js
DELETED
package/dist/routes/index.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { createAgentRoutes, createRunRoutes } from "./agents.js";
|
|
2
|
-
import { createAuditRoutes } from "./audit.js";
|
|
3
|
-
import { createAuthRoutes } from "./auth.js";
|
|
4
|
-
import { createHealthRoutes } from "./health.js";
|
|
5
|
-
import { createMcpRoutes } from "./mcp.js";
|
|
6
|
-
import { createMemoryRoutes } from "./memory.js";
|
|
7
|
-
import { createSessionRoutes } from "./sessions.js";
|
|
8
|
-
import { createSkillsRoutes } from "./skills.js";
|
|
9
|
-
import { createTokensRoutes } from "./tokens.js";
|
|
10
|
-
import { createWorkflowRoutes } from "./workflows.js";
|
|
11
|
-
|
|
12
|
-
export { };
|