@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,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
|
+
}
|