@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,363 @@
1
+ /**
2
+ * Agent REST routes.
3
+ *
4
+ * GET /agents (scope `agents:read`)
5
+ * GET /agents/:id (scope `agents:read`)
6
+ * POST /agents/:id/run (idempotent; scope `agents:invoke[:id]`)
7
+ * POST /agents/:id/stream (scope `agents:invoke[:id]`)
8
+ * GET /runs/:runId/state (scope `agents:read`)
9
+ * POST /runs/:runId/abort (scope `agents:invoke`)
10
+ * POST /runs/:runId/resume (idempotent; scope `agents:invoke`)
11
+ *
12
+ * Agents are looked up via {@link AgentRegistry}; missing entries
13
+ * surface a 404 with a typed error body.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { toWireAgentEvent } from '@graphorin/core';
19
+ import type { Context } from 'hono';
20
+ import { Hono } from 'hono';
21
+ import { z } from 'zod';
22
+ import { AgentNotFoundError } from '../errors/index.js';
23
+ import type { ServerVariables } from '../internal/context.js';
24
+ import { newRequestId } from '../internal/ids.js';
25
+ import {
26
+ checkScope,
27
+ createAuthenticatedMiddleware,
28
+ createScopeMiddleware,
29
+ } from '../middleware/scope.js';
30
+ import type { AgentRegistry } from '../registry/index.js';
31
+ import { type RunStateTracker, requiredRunScope } from '../runtime/run-state.js';
32
+
33
+ /**
34
+ * @stable
35
+ */
36
+ export interface AgentRoutesDeps {
37
+ readonly agents: AgentRegistry;
38
+ readonly runs: RunStateTracker;
39
+ readonly newRunId?: () => string;
40
+ /**
41
+ * Streaming dispatcher (IP-2). When present, `POST /:id/stream`
42
+ * actually runs the agent and emits every event onto the
43
+ * `agent:<id>/runs/<runId>/events` subject.
44
+ */
45
+ readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;
46
+ }
47
+
48
+ const RunBodySchema = z
49
+ .object({
50
+ input: z.unknown().optional(),
51
+ sessionId: z.string().min(1).optional(),
52
+ userId: z.string().min(1).optional(),
53
+ })
54
+ .strict()
55
+ .default({});
56
+
57
+ /**
58
+ * @stable
59
+ */
60
+ export function createAgentRoutes(deps: AgentRoutesDeps): Hono<{ Variables: ServerVariables }> {
61
+ const app = new Hono<{ Variables: ServerVariables }>();
62
+ const newRunId = deps.newRunId ?? newRequestId;
63
+
64
+ app.get('/', createScopeMiddleware('agents:read'), (c) => c.json({ agents: deps.agents.list() }));
65
+
66
+ app.get(
67
+ '/:id',
68
+ createScopeMiddleware((_path, params) => `agents:read:${params.id}`),
69
+ (c) => {
70
+ const id = c.req.param('id');
71
+ const summary = deps.agents.describe(id);
72
+ if (summary === undefined) {
73
+ return c.json(
74
+ { error: 'agent-not-found', message: `Agent '${id}' is not registered.` },
75
+ 404,
76
+ );
77
+ }
78
+ return c.json(summary);
79
+ },
80
+ );
81
+
82
+ app.post(
83
+ '/:id/run',
84
+ createScopeMiddleware((_path, params) => `agents:invoke:${params.id}`),
85
+ async (c) => {
86
+ const id = c.req.param('id');
87
+ const agent = deps.agents.get(id);
88
+ if (agent === undefined) {
89
+ const err = new AgentNotFoundError(id);
90
+ return c.json({ error: err.kind, message: err.message }, 404);
91
+ }
92
+ const parsed = RunBodySchema.safeParse(await safelyParseJson(c));
93
+ if (!parsed.success) {
94
+ return invalidBodyResponse(c, parsed.error);
95
+ }
96
+ const runId = newRunId();
97
+ const sessionId = parsed.data.sessionId;
98
+ const userId = parsed.data.userId;
99
+ const tracker = deps.runs.start(runId, {
100
+ kind: 'agent',
101
+ agentId: id,
102
+ ...(sessionId !== undefined ? { sessionId } : {}),
103
+ ...(userId !== undefined ? { userId } : {}),
104
+ });
105
+ try {
106
+ const callOptions: { signal?: AbortSignal; sessionId?: string; userId?: string } = {
107
+ signal: tracker.signal,
108
+ };
109
+ if (sessionId !== undefined) callOptions.sessionId = sessionId;
110
+ if (userId !== undefined) callOptions.userId = userId;
111
+ const result = await agent.run(parsed.data.input ?? '', callOptions);
112
+ deps.runs.complete(runId, 'completed');
113
+ return c.json({ runId, status: 'completed', result }, 200);
114
+ } catch (err) {
115
+ const aborted = tracker.signal.aborted;
116
+ deps.runs.complete(runId, aborted ? 'aborted' : 'failed', err);
117
+ return c.json(
118
+ {
119
+ runId,
120
+ status: aborted ? 'aborted' : 'failed',
121
+ error: aborted ? 'run-aborted' : 'run-failed',
122
+ message: err instanceof Error ? err.message : String(err),
123
+ },
124
+ aborted ? 408 : 500,
125
+ );
126
+ }
127
+ },
128
+ );
129
+
130
+ app.post(
131
+ '/:id/stream',
132
+ createScopeMiddleware((_path, params) => `agents:invoke:${params.id}`),
133
+ async (c) => {
134
+ const id = c.req.param('id');
135
+ if (!deps.agents.has(id)) {
136
+ const err = new AgentNotFoundError(id);
137
+ return c.json({ error: err.kind, message: err.message }, 404);
138
+ }
139
+ // W-151: validate the body BEFORE any run is registered - the old
140
+ // handler swallowed a failed parse and launched the agent on an
141
+ // empty prompt, burning provider tokens behind a 202 that looked
142
+ // successful. Identical validation to the sibling /run route
143
+ // (empty/absent bodies stay valid via the schema default).
144
+ const parsed = RunBodySchema.safeParse(await safelyParseJson(c));
145
+ if (!parsed.success) {
146
+ return invalidBodyResponse(c, parsed.error);
147
+ }
148
+ const runId = newRunId();
149
+ const sessionId = parsed.data.sessionId;
150
+ const userId = parsed.data.userId;
151
+ const input = parsed.data.input ?? '';
152
+ // IP-2: actually run the agent. The old handler parsed the input
153
+ // and threw it away - the run sat 'pending' forever while the 202
154
+ // advertised subjects nothing would ever emit on.
155
+ const tracker = deps.runs.start(runId, {
156
+ kind: 'agent',
157
+ agentId: id,
158
+ ...(sessionId !== undefined ? { sessionId } : {}),
159
+ ...(userId !== undefined ? { userId } : {}),
160
+ });
161
+ const subject = `agent:${id}/runs/${runId}/events`;
162
+ const agent = deps.agents.get(id);
163
+ if (agent !== undefined) {
164
+ backgroundStreamAgent(agent, input, {
165
+ signal: tracker.signal,
166
+ ...(sessionId !== undefined ? { sessionId } : {}),
167
+ ...(userId !== undefined ? { userId } : {}),
168
+ runs: deps.runs,
169
+ runId,
170
+ subject,
171
+ ...(deps.dispatcher !== undefined ? { dispatcher: deps.dispatcher } : {}),
172
+ });
173
+ }
174
+ return c.json(
175
+ {
176
+ runId,
177
+ status: 'running',
178
+ subscribe: {
179
+ // The SSE URL previously advertised here pointed at a path
180
+ // that was never mounted - the WebSocket subject is the
181
+ // delivery channel (IP-2).
182
+ websocket: subject,
183
+ },
184
+ },
185
+ 202,
186
+ );
187
+ },
188
+ );
189
+
190
+ return app;
191
+ }
192
+
193
+ /**
194
+ * IP-2: consume the agent's event stream in the background and emit
195
+ * every event onto the run subject. Falls back to `run(...)` (a single
196
+ * terminal frame) for registry entries without a `stream` surface.
197
+ */
198
+ function backgroundStreamAgent(
199
+ agent: ReturnType<AgentRegistry['get']> & object,
200
+ input: unknown,
201
+ opts: {
202
+ readonly signal: AbortSignal;
203
+ readonly sessionId?: string;
204
+ readonly userId?: string;
205
+ readonly runs: RunStateTracker;
206
+ readonly runId: string;
207
+ readonly subject: string;
208
+ readonly dispatcher?: import('../ws/dispatcher.js').WsDispatcher;
209
+ },
210
+ ): void {
211
+ const emit = (type: string, payload: unknown): void => {
212
+ opts.dispatcher?.emit(opts.subject, { type, payload });
213
+ };
214
+ void (async () => {
215
+ try {
216
+ const callOpts = {
217
+ signal: opts.signal,
218
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
219
+ ...(opts.userId !== undefined ? { userId: opts.userId } : {}),
220
+ };
221
+ if (typeof agent.stream === 'function') {
222
+ for await (const ev of agent.stream(input, callOpts)) {
223
+ if (opts.signal.aborted) break;
224
+ const type =
225
+ typeof (ev as { type?: unknown }).type === 'string'
226
+ ? (ev as { type: string }).type
227
+ : 'agent.event';
228
+ // W-046: binary-bearing variants (file.generated,
229
+ // tool.execute.partial, agent.end with its full RunState) must
230
+ // be projected to their JSON-safe wire twins before the
231
+ // dispatcher stringifies the frame. Unknown types pass through.
232
+ emit(type, toWireAgentEvent(ev as Parameters<typeof toWireAgentEvent>[0]));
233
+ }
234
+ } else {
235
+ const output = await agent.run(input, callOpts);
236
+ emit('agent.end', { runId: opts.runId, output });
237
+ }
238
+ opts.runs.complete(opts.runId, opts.signal.aborted ? 'aborted' : 'completed');
239
+ } catch (err) {
240
+ emit('agent.error', {
241
+ runId: opts.runId,
242
+ message: err instanceof Error ? err.message : String(err),
243
+ });
244
+ opts.runs.complete(opts.runId, opts.signal.aborted ? 'aborted' : 'failed', err);
245
+ }
246
+ })();
247
+ }
248
+
249
+ /**
250
+ * Companion router for the `/runs/...` surface. Kept separate so the
251
+ * `createServer` factory can mount it under the top-level base path
252
+ * rather than under `/agents`.
253
+ *
254
+ * @stable
255
+ */
256
+ export function createRunRoutes(deps: AgentRoutesDeps): Hono<{ Variables: ServerVariables }> {
257
+ const app = new Hono<{ Variables: ServerVariables }>();
258
+
259
+ // W-107: run control binds to the run's OWNING resource. The
260
+ // requirement is only known after the snapshot resolves the
261
+ // descriptor, so the outer gate is authentication-only and the
262
+ // handlers authorize imperatively: 404 for unknown runs first
263
+ // (runIds are unguessable newRequestId values and ephemeral - the
264
+ // ordering is deliberate and matches WS run.cancel), then 403 when
265
+ // the grant does not cover the owning agent/workflow. Bare
266
+ // `agents:read`/`agents:invoke` grants keep covering their
267
+ // per-resource requirements.
268
+ app.get('/:runId/state', createAuthenticatedMiddleware(), (c) => {
269
+ const runId = c.req.param('runId');
270
+ const state = deps.runs.snapshot(runId);
271
+ if (state === undefined) {
272
+ return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);
273
+ }
274
+ const required = requiredRunScope(state, 'read');
275
+ if (!checkScope(c.get('state').auth, required)) {
276
+ return c.json(
277
+ { error: 'scope-denied', message: `Token lacks required scope '${required}'.` },
278
+ 403,
279
+ );
280
+ }
281
+ return c.json(state);
282
+ });
283
+
284
+ app.post('/:runId/abort', createAuthenticatedMiddleware(), (c) => {
285
+ const runId = c.req.param('runId');
286
+ const state = deps.runs.snapshot(runId);
287
+ if (state === undefined) {
288
+ return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);
289
+ }
290
+ const required = requiredRunScope(state, 'control');
291
+ if (!checkScope(c.get('state').auth, required)) {
292
+ return c.json(
293
+ { error: 'scope-denied', message: `Token lacks required scope '${required}'.` },
294
+ 403,
295
+ );
296
+ }
297
+ const aborted = deps.runs.abort(runId);
298
+ if (!aborted) {
299
+ return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);
300
+ }
301
+ return c.json({ runId, status: 'aborted' });
302
+ });
303
+
304
+ app.post('/:runId/resume', createAuthenticatedMiddleware(), async (c) => {
305
+ const runId = c.req.param('runId');
306
+ const state = deps.runs.snapshot(runId);
307
+ if (state === undefined) {
308
+ return c.json({ error: 'run-not-found', message: `Run '${runId}' is not registered.` }, 404);
309
+ }
310
+ const requiredResume = requiredRunScope(state, 'control');
311
+ if (!checkScope(c.get('state').auth, requiredResume)) {
312
+ return c.json(
313
+ { error: 'scope-denied', message: `Token lacks required scope '${requiredResume}'.` },
314
+ 403,
315
+ );
316
+ }
317
+ // IP-14: a 202 that persists nothing and resumes nothing is a lie
318
+ // the client SDK was built on. Until the server can rehydrate the
319
+ // RunState and re-enter the agent loop (the persisted-approvals
320
+ // work - Wave 3), the honest answer is 501 with the working
321
+ // library-side path: `agent.run(result.state, { directive })`
322
+ // executes approved tools for real (AG-1/AG-9).
323
+ return c.json(
324
+ {
325
+ error: 'resume-not-implemented',
326
+ runId,
327
+ status: state.status,
328
+ message:
329
+ 'Server-side HITL resume is not implemented yet. Resume library-side: ' +
330
+ 'agent.run(result.state, { directive }) - the suspended RunState is on the AgentResult.',
331
+ },
332
+ 501,
333
+ );
334
+ });
335
+
336
+ return app;
337
+ }
338
+
339
+ /**
340
+ * W-151: byte-identical 400 for an invalid run/stream body - both
341
+ * sibling routes validate through this one helper.
342
+ */
343
+ function invalidBodyResponse(
344
+ c: Context<{ Variables: ServerVariables }>,
345
+ error: { issues: ReadonlyArray<{ path: ReadonlyArray<PropertyKey>; message: string }> },
346
+ ) {
347
+ return c.json(
348
+ {
349
+ error: 'config-invalid',
350
+ message: 'Invalid run body.',
351
+ issues: error.issues.map((i) => ({ path: i.path, message: i.message })),
352
+ },
353
+ 400,
354
+ );
355
+ }
356
+
357
+ async function safelyParseJson(c: Context<{ Variables: ServerVariables }>): Promise<unknown> {
358
+ try {
359
+ return await c.req.json();
360
+ } catch {
361
+ return {};
362
+ }
363
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Audit-log REST routes.
3
+ *
4
+ * GET /audit (scope `audit:read`)
5
+ * POST /audit/export (scope `audit:export`)
6
+ *
7
+ * The handler delegates to a structurally-typed `AuditApi` so the
8
+ * server code stays decoupled from any single audit backend.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+ import type { Context } from 'hono';
14
+ import { Hono } from 'hono';
15
+ import { z } from 'zod';
16
+
17
+ import type { ServerVariables } from '../internal/context.js';
18
+ import { createScopeMiddleware } from '../middleware/scope.js';
19
+
20
+ /**
21
+ * @stable
22
+ */
23
+ export interface AuditApi {
24
+ list(opts: {
25
+ readonly limit?: number;
26
+ readonly fromSeq?: number;
27
+ /** Inclusive lower bound on the entry timestamp (epoch ms). */
28
+ readonly fromTs?: number;
29
+ /** Inclusive upper bound on the entry timestamp (epoch ms). */
30
+ readonly toTs?: number;
31
+ /** Restrict to entries whose `action` matches the supplied id. */
32
+ readonly action?: string;
33
+ }): Promise<ReadonlyArray<unknown>>;
34
+ export(opts: {
35
+ readonly fromSeq?: number;
36
+ readonly toSeq?: number;
37
+ readonly format?: 'jsonl' | 'csv';
38
+ }): Promise<{ readonly bytes: number; readonly format?: 'jsonl' | 'csv' }>;
39
+ /**
40
+ * Verify the chain integrity of every audit row in the inclusive
41
+ * range. Phase 14c surfaces this through `POST /v1/audit/verify`.
42
+ * Optional - operators that opt out of the audit chain should
43
+ * leave this method off.
44
+ */
45
+ verify?(opts: {
46
+ readonly fromSeq?: number;
47
+ readonly toSeq?: number;
48
+ }): Promise<{ readonly ok: boolean; readonly count?: number; readonly brokenAt?: number }>;
49
+ }
50
+
51
+ const ISO_OR_EPOCH = z.union([z.coerce.number().int().nonnegative(), z.string().min(1)]);
52
+
53
+ const ListQuerySchema = z
54
+ .object({
55
+ limit: z.coerce.number().int().positive().max(1000).optional(),
56
+ fromSeq: z.coerce.number().int().nonnegative().optional(),
57
+ from: ISO_OR_EPOCH.optional(),
58
+ to: ISO_OR_EPOCH.optional(),
59
+ action: z.string().min(1).max(256).optional(),
60
+ })
61
+ .strict();
62
+
63
+ function parseTimestamp(input: unknown): number | undefined {
64
+ if (input === undefined) return undefined;
65
+ if (typeof input === 'number') return input;
66
+ const parsed = Date.parse(String(input));
67
+ return Number.isNaN(parsed) ? undefined : parsed;
68
+ }
69
+
70
+ const ExportBodySchema = z
71
+ .object({
72
+ fromSeq: z.number().int().nonnegative().optional(),
73
+ toSeq: z.number().int().nonnegative().optional(),
74
+ format: z.enum(['jsonl', 'csv']).optional(),
75
+ })
76
+ .strict()
77
+ .default({});
78
+
79
+ /**
80
+ * @stable
81
+ */
82
+ export interface AuditRoutesDeps {
83
+ readonly audit: AuditApi;
84
+ }
85
+
86
+ /**
87
+ * @stable
88
+ */
89
+ export function createAuditRoutes(deps: AuditRoutesDeps): Hono<{ Variables: ServerVariables }> {
90
+ const app = new Hono<{ Variables: ServerVariables }>();
91
+
92
+ app.get('/', createScopeMiddleware('audit:read'), async (c) => {
93
+ const parsed = ListQuerySchema.safeParse({
94
+ limit: c.req.query('limit'),
95
+ fromSeq: c.req.query('fromSeq'),
96
+ from: c.req.query('from'),
97
+ to: c.req.query('to'),
98
+ action: c.req.query('action'),
99
+ });
100
+ if (!parsed.success) {
101
+ return c.json(
102
+ {
103
+ error: 'config-invalid',
104
+ message: 'Invalid audit query.',
105
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
106
+ },
107
+ 400,
108
+ );
109
+ }
110
+ const fromTs = parseTimestamp(parsed.data.from);
111
+ const toTs = parseTimestamp(parsed.data.to);
112
+ if (parsed.data.from !== undefined && fromTs === undefined) {
113
+ return c.json(
114
+ {
115
+ error: 'config-invalid',
116
+ message: `Invalid 'from' timestamp: '${String(parsed.data.from)}' is not parseable.`,
117
+ },
118
+ 400,
119
+ );
120
+ }
121
+ if (parsed.data.to !== undefined && toTs === undefined) {
122
+ return c.json(
123
+ {
124
+ error: 'config-invalid',
125
+ message: `Invalid 'to' timestamp: '${String(parsed.data.to)}' is not parseable.`,
126
+ },
127
+ 400,
128
+ );
129
+ }
130
+ const filters: {
131
+ limit?: number;
132
+ fromSeq?: number;
133
+ fromTs?: number;
134
+ toTs?: number;
135
+ action?: string;
136
+ } = {};
137
+ if (parsed.data.limit !== undefined) filters.limit = parsed.data.limit;
138
+ if (parsed.data.fromSeq !== undefined) filters.fromSeq = parsed.data.fromSeq;
139
+ if (fromTs !== undefined) filters.fromTs = fromTs;
140
+ if (toTs !== undefined) filters.toTs = toTs;
141
+ if (parsed.data.action !== undefined) filters.action = parsed.data.action;
142
+ const entries = await deps.audit.list(filters);
143
+ return c.json({ entries });
144
+ });
145
+
146
+ app.post('/verify', createScopeMiddleware('audit:verify'), async (c) => {
147
+ if (deps.audit.verify === undefined) {
148
+ return c.json(
149
+ {
150
+ error: 'not-implemented',
151
+ message: 'Audit chain verification is not enabled on this deployment.',
152
+ },
153
+ 501,
154
+ );
155
+ }
156
+ const parsed = ExportBodySchema.safeParse(await safelyParseJson(c));
157
+ if (!parsed.success) {
158
+ return c.json(
159
+ {
160
+ error: 'config-invalid',
161
+ message: 'Invalid verify body.',
162
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
163
+ },
164
+ 400,
165
+ );
166
+ }
167
+ const opts: { fromSeq?: number; toSeq?: number } = {};
168
+ if (parsed.data.fromSeq !== undefined) opts.fromSeq = parsed.data.fromSeq;
169
+ if (parsed.data.toSeq !== undefined) opts.toSeq = parsed.data.toSeq;
170
+ const result = await deps.audit.verify(opts);
171
+ return c.json({ verify: result });
172
+ });
173
+
174
+ app.post('/export', createScopeMiddleware('audit:export'), async (c) => {
175
+ const parsed = ExportBodySchema.safeParse(await safelyParseJson(c));
176
+ if (!parsed.success) {
177
+ return c.json(
178
+ {
179
+ error: 'config-invalid',
180
+ message: 'Invalid export body.',
181
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
182
+ },
183
+ 400,
184
+ );
185
+ }
186
+ const queryFormat = c.req.query('format');
187
+ const requestedFormat =
188
+ parsed.data.format ??
189
+ (queryFormat === 'csv' ? 'csv' : queryFormat === 'jsonl' ? 'jsonl' : undefined);
190
+ const opts: { fromSeq?: number; toSeq?: number; format?: 'jsonl' | 'csv' } = {};
191
+ if (parsed.data.fromSeq !== undefined) opts.fromSeq = parsed.data.fromSeq;
192
+ if (parsed.data.toSeq !== undefined) opts.toSeq = parsed.data.toSeq;
193
+ if (requestedFormat !== undefined) opts.format = requestedFormat;
194
+ const result = await deps.audit.export(opts);
195
+ return c.json({ export: result });
196
+ });
197
+
198
+ return app;
199
+ }
200
+
201
+ async function safelyParseJson(c: Context<{ Variables: ServerVariables }>): Promise<unknown> {
202
+ try {
203
+ return await c.req.json();
204
+ } catch {
205
+ return {};
206
+ }
207
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Auth-related REST routes that exist purely to support the WebSocket
3
+ * surface in `@graphorin/server/ws`.
4
+ *
5
+ * POST /session/ws-ticket (scope `agents:invoke`)
6
+ *
7
+ * Browser clients call the endpoint with a regular HTTP `Authorization:
8
+ * Bearer <token>` header to mint a single-use, short-lived ticket; the
9
+ * ticket value is then attached to the WebSocket upgrade request as a
10
+ * second `Sec-WebSocket-Protocol` token (`ticket.<value>`). The
11
+ * server-side WS upgrade handler (see `../ws/upgrade.ts`) consumes the
12
+ * ticket against the same in-memory store so the round-trip is
13
+ * race-free.
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import { Hono } from 'hono';
19
+
20
+ import type { ServerVariables } from '../internal/context.js';
21
+ import { createAuthenticatedMiddleware } from '../middleware/scope.js';
22
+ import type { WsTicketStore } from '../ws/ticket.js';
23
+
24
+ /**
25
+ * Stable shape consumed by {@link createAuthRoutes}.
26
+ *
27
+ * @stable
28
+ */
29
+ export interface AuthRoutesDeps {
30
+ readonly tickets: WsTicketStore;
31
+ }
32
+
33
+ /**
34
+ * Build the auth router. The router is mounted at the same base path
35
+ * as the rest of the REST surface (defaults to `/v1`).
36
+ *
37
+ * @stable
38
+ */
39
+ export function createAuthRoutes(deps: AuthRoutesDeps): Hono<{ Variables: ServerVariables }> {
40
+ const app = new Hono<{ Variables: ServerVariables }>();
41
+
42
+ // W-107: the ticket ADDS no rights - it carries the principal's own
43
+ // scopes and every subscribe is per-subject gated on the dispatcher -
44
+ // so possession of a valid principal is the whole requirement. The
45
+ // old `agents:invoke` gate locked read-only tokens out of the
46
+ // browser WS path entirely.
47
+ app.post('/session/ws-ticket', createAuthenticatedMiddleware(), (c) => {
48
+ const auth = c.get('state').auth;
49
+ // IP-13: in the no-auth loopback mode (`auth.kind='none'`) there is no
50
+ // bearer token, but the anonymous principal is fully authorized - mint a
51
+ // ticket bound to a synthetic `anonymous` id so a browser client written
52
+ // for token mode still completes the round-trip.
53
+ const principal =
54
+ auth.kind === 'token'
55
+ ? { tokenId: auth.token.tokenId, scopes: auth.grantedScopes }
56
+ : auth.kind === 'anonymous'
57
+ ? { tokenId: 'anonymous', scopes: auth.grantedScopes }
58
+ : undefined;
59
+ if (principal === undefined) {
60
+ return c.json(
61
+ {
62
+ error: 'auth-required',
63
+ message: 'Bearer token required to mint a WS ticket.',
64
+ },
65
+ 401,
66
+ );
67
+ }
68
+ const ticket = deps.tickets.issue(principal);
69
+ return c.json(
70
+ {
71
+ ticket: ticket.value,
72
+ expiresAt: ticket.expiresAt,
73
+ ttlMs: deps.tickets.ttlMs,
74
+ },
75
+ 201,
76
+ );
77
+ });
78
+
79
+ return app;
80
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `GET /v1/health` - minimal health endpoint kept for backward
3
+ * compatibility with consumers that wired the Phase 14a route
4
+ * factory directly. The active server now mounts the extended
5
+ * Phase 14c routes from `@graphorin/server/health`. New code should
6
+ * use {@link import('../health/routes.js').createExtendedHealthRoutes}.
7
+ *
8
+ * @deprecated Phase 14c. Use `createExtendedHealthRoutes` from
9
+ * `@graphorin/server/health` instead. This export
10
+ * stays available so existing consumers do not break.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import { Hono } from 'hono';
16
+
17
+ import type { ServerVariables } from '../internal/context.js';
18
+
19
+ /**
20
+ * @stable
21
+ */
22
+ export interface HealthRoutesDeps {
23
+ readonly version: string;
24
+ readonly startedAt: number;
25
+ readonly now?: () => number;
26
+ }
27
+
28
+ /**
29
+ * @stable
30
+ */
31
+ export function createHealthRoutes(deps: HealthRoutesDeps): Hono<{ Variables: ServerVariables }> {
32
+ const app = new Hono<{ Variables: ServerVariables }>();
33
+ const now = deps.now ?? Date.now;
34
+ app.get('/', (c) =>
35
+ c.json({
36
+ status: 'ok' as const,
37
+ version: deps.version,
38
+ uptimeSeconds: Math.max(0, Math.floor((now() - deps.startedAt) / 1000)),
39
+ }),
40
+ );
41
+ return app;
42
+ }