@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.
Files changed (181) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +3 -3
  3. package/dist/app-audit-binding.d.ts +15 -0
  4. package/dist/app-audit-binding.d.ts.map +1 -0
  5. package/dist/app-audit-binding.js +136 -0
  6. package/dist/app-audit-binding.js.map +1 -0
  7. package/dist/app-daemons.d.ts +29 -0
  8. package/dist/app-daemons.d.ts.map +1 -0
  9. package/dist/app-daemons.js +27 -0
  10. package/dist/app-daemons.js.map +1 -0
  11. package/dist/app-lifecycle.js +272 -0
  12. package/dist/app-lifecycle.js.map +1 -0
  13. package/dist/app-metrics.js +79 -0
  14. package/dist/app-metrics.js.map +1 -0
  15. package/dist/app-middleware.js +92 -0
  16. package/dist/app-middleware.js.map +1 -0
  17. package/dist/app-routes.js +131 -0
  18. package/dist/app-routes.js.map +1 -0
  19. package/dist/app-ws.js +65 -0
  20. package/dist/app-ws.js.map +1 -0
  21. package/dist/app.d.ts +16 -16
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +35 -607
  24. package/dist/app.js.map +1 -1
  25. package/dist/commentary/built-in-patterns.d.ts +1 -1
  26. package/dist/commentary/built-in-patterns.js +1 -1
  27. package/dist/commentary/built-in-patterns.js.map +1 -1
  28. package/dist/config.d.ts +100 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +30 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/health/checks.d.ts +15 -1
  33. package/dist/health/checks.d.ts.map +1 -1
  34. package/dist/health/checks.js +21 -0
  35. package/dist/health/checks.js.map +1 -1
  36. package/dist/health/routes.js +1 -1
  37. package/dist/index.d.ts +8 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +19 -24
  40. package/dist/index.js.map +1 -1
  41. package/dist/internal/context.d.ts +2 -2
  42. package/dist/internal/wire-error.js +20 -0
  43. package/dist/internal/wire-error.js.map +1 -0
  44. package/dist/lifecycle/pre-bind.d.ts +1 -1
  45. package/dist/metrics/catalog.d.ts.map +1 -1
  46. package/dist/metrics/catalog.js.map +1 -1
  47. package/dist/metrics/tools-bridge.d.ts +15 -0
  48. package/dist/metrics/tools-bridge.d.ts.map +1 -0
  49. package/dist/metrics/tools-bridge.js +103 -0
  50. package/dist/metrics/tools-bridge.js.map +1 -0
  51. package/dist/middleware/audit.d.ts +1 -1
  52. package/dist/middleware/auth.js +1 -1
  53. package/dist/middleware/csrf.js +1 -1
  54. package/dist/middleware/index.js +1 -1
  55. package/dist/middleware/scope.d.ts.map +1 -1
  56. package/dist/middleware/scope.js +44 -4
  57. package/dist/middleware/scope.js.map +1 -1
  58. package/dist/package.js +1 -1
  59. package/dist/package.js.map +1 -1
  60. package/dist/registry/index.d.ts +33 -0
  61. package/dist/registry/index.d.ts.map +1 -1
  62. package/dist/registry/index.js.map +1 -1
  63. package/dist/replay/routes.d.ts +1 -1
  64. package/dist/replay/routes.js +2 -2
  65. package/dist/routes/agents.d.ts.map +1 -1
  66. package/dist/routes/agents.js +64 -18
  67. package/dist/routes/agents.js.map +1 -1
  68. package/dist/routes/auth.js +2 -2
  69. package/dist/routes/auth.js.map +1 -1
  70. package/dist/routes/sessions.js +5 -5
  71. package/dist/routes/sessions.js.map +1 -1
  72. package/dist/routes/tokens.d.ts +1 -1
  73. package/dist/routes/tokens.d.ts.map +1 -1
  74. package/dist/routes/tokens.js +21 -1
  75. package/dist/routes/tokens.js.map +1 -1
  76. package/dist/routes/workflows.d.ts.map +1 -1
  77. package/dist/routes/workflows.js +165 -19
  78. package/dist/routes/workflows.js.map +1 -1
  79. package/dist/runtime/retention.d.ts +105 -0
  80. package/dist/runtime/retention.d.ts.map +1 -0
  81. package/dist/runtime/retention.js +154 -0
  82. package/dist/runtime/retention.js.map +1 -0
  83. package/dist/runtime/run-state.d.ts +2 -0
  84. package/dist/runtime/run-state.d.ts.map +1 -1
  85. package/dist/runtime/run-state.js +34 -2
  86. package/dist/runtime/run-state.js.map +1 -1
  87. package/dist/sse/events.js +3 -4
  88. package/dist/sse/events.js.map +1 -1
  89. package/dist/tools-audit-bridge.d.ts +29 -0
  90. package/dist/tools-audit-bridge.d.ts.map +1 -0
  91. package/dist/tools-audit-bridge.js +103 -0
  92. package/dist/tools-audit-bridge.js.map +1 -0
  93. package/dist/workflows/timer-daemon.d.ts +69 -0
  94. package/dist/workflows/timer-daemon.d.ts.map +1 -0
  95. package/dist/workflows/timer-daemon.js +37 -0
  96. package/dist/workflows/timer-daemon.js.map +1 -0
  97. package/dist/ws/dispatcher.d.ts +1 -1
  98. package/dist/ws/dispatcher.js +19 -12
  99. package/dist/ws/dispatcher.js.map +1 -1
  100. package/dist/ws/index.d.ts +2 -2
  101. package/dist/ws/index.js +3 -3
  102. package/dist/ws/replay-buffer.d.ts +35 -1
  103. package/dist/ws/replay-buffer.d.ts.map +1 -1
  104. package/dist/ws/replay-buffer.js +35 -3
  105. package/dist/ws/replay-buffer.js.map +1 -1
  106. package/dist/ws/upgrade.d.ts.map +1 -1
  107. package/dist/ws/upgrade.js +24 -18
  108. package/dist/ws/upgrade.js.map +1 -1
  109. package/package.json +30 -29
  110. package/src/app-audit-binding.ts +227 -0
  111. package/src/app-daemons.ts +73 -0
  112. package/src/app-lifecycle.ts +476 -0
  113. package/src/app-metrics.ts +144 -0
  114. package/src/app-middleware.ts +149 -0
  115. package/src/app-routes.ts +305 -0
  116. package/src/app-ws.ts +111 -0
  117. package/src/app.ts +317 -0
  118. package/src/commentary/audit-bridge.ts +135 -0
  119. package/src/commentary/built-in-patterns.ts +79 -0
  120. package/src/commentary/index.ts +32 -0
  121. package/src/commentary/sanitizer.ts +238 -0
  122. package/src/commentary/types.ts +130 -0
  123. package/src/config.ts +472 -0
  124. package/src/consolidator/daemon.ts +141 -0
  125. package/src/consolidator/index.ts +14 -0
  126. package/src/errors/index.ts +247 -0
  127. package/src/health/checks.ts +322 -0
  128. package/src/health/index.ts +34 -0
  129. package/src/health/routes.ts +154 -0
  130. package/src/index.ts +243 -0
  131. package/src/internal/context.ts +77 -0
  132. package/src/internal/ids.ts +17 -0
  133. package/src/internal/json.ts +47 -0
  134. package/src/internal/wire-error.ts +44 -0
  135. package/src/lifecycle/hooks.ts +66 -0
  136. package/src/lifecycle/index.ts +16 -0
  137. package/src/lifecycle/pre-bind.ts +138 -0
  138. package/src/metrics/catalog.ts +112 -0
  139. package/src/metrics/index.ts +12 -0
  140. package/src/metrics/registry.ts +337 -0
  141. package/src/metrics/tools-bridge.ts +124 -0
  142. package/src/middleware/audit.ts +114 -0
  143. package/src/middleware/auth.ts +195 -0
  144. package/src/middleware/cors.ts +72 -0
  145. package/src/middleware/csrf.ts +98 -0
  146. package/src/middleware/idempotency.ts +336 -0
  147. package/src/middleware/index.ts +38 -0
  148. package/src/middleware/rate-limit.ts +71 -0
  149. package/src/middleware/request-state.ts +79 -0
  150. package/src/middleware/scope.ts +161 -0
  151. package/src/registry/index.ts +278 -0
  152. package/src/replay/index.ts +14 -0
  153. package/src/replay/routes.ts +255 -0
  154. package/src/routes/agents.ts +363 -0
  155. package/src/routes/audit.ts +207 -0
  156. package/src/routes/auth.ts +80 -0
  157. package/src/routes/health.ts +42 -0
  158. package/src/routes/index.ts +16 -0
  159. package/src/routes/mcp.ts +97 -0
  160. package/src/routes/memory.ts +152 -0
  161. package/src/routes/sessions.ts +250 -0
  162. package/src/routes/skills.ts +91 -0
  163. package/src/routes/tokens.ts +166 -0
  164. package/src/routes/workflows.ts +616 -0
  165. package/src/runtime/retention.ts +284 -0
  166. package/src/runtime/run-state.ts +422 -0
  167. package/src/sse/events.ts +303 -0
  168. package/src/sse/index.ts +7 -0
  169. package/src/tools-audit-bridge.ts +120 -0
  170. package/src/triggers/daemon.ts +198 -0
  171. package/src/triggers/index.ts +16 -0
  172. package/src/triggers/routes.ts +139 -0
  173. package/src/workflows/timer-daemon.ts +102 -0
  174. package/src/ws/dispatcher.ts +537 -0
  175. package/src/ws/index.ts +45 -0
  176. package/src/ws/replay-buffer.ts +209 -0
  177. package/src/ws/subjects.ts +162 -0
  178. package/src/ws/ticket.ts +174 -0
  179. package/src/ws/upgrade.ts +507 -0
  180. package/dist/lifecycle/index.js +0 -3
  181. 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;
@@ -1,3 +0,0 @@
1
- import { runPreBind } from "./pre-bind.js";
2
-
3
- export { };
@@ -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 { };