@victframework/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-remote.d.ts +36 -0
- package/dist/app-remote.js +108 -0
- package/dist/app-remote.js.map +1 -0
- package/dist/auth.d.ts +53 -0
- package/dist/auth.js +44 -0
- package/dist/auth.js.map +1 -0
- package/dist/commands.d.ts +192 -0
- package/dist/commands.js +1084 -0
- package/dist/commands.js.map +1 -0
- package/dist/http.d.ts +71 -0
- package/dist/http.js +715 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/dist/http.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { VictControlError, } from '@victframework/runtime';
|
|
3
|
+
import { AGENT_STREAM_SCHEMA, assertAgentStreamWireEnvelope, } from '@victframework/contracts';
|
|
4
|
+
import { AuthenticationError } from './auth.js';
|
|
5
|
+
/**
|
|
6
|
+
* Stage 06B — the VICT-owned HTTP boundary (AI-015).
|
|
7
|
+
*
|
|
8
|
+
* Versioned commands under `/vict/v1/*` plus resumable SSE under
|
|
9
|
+
* `/vict/v1/streams/:streamId`. Hard transport rules:
|
|
10
|
+
*
|
|
11
|
+
* - closed request schemas and a bounded request body (256 KiB);
|
|
12
|
+
* - EXACT content-type handling (`application/json`, optional charset);
|
|
13
|
+
* - stable status/error mapping — malformed JSON, unknown routes, and
|
|
14
|
+
* unsupported methods fail with structured, non-echoing bodies;
|
|
15
|
+
* - the durable `Idempotency-Key` boundary for state-changing commands is
|
|
16
|
+
* enforced below the transport by the shared command service;
|
|
17
|
+
* - the authenticated server context on EVERY protected operation — no
|
|
18
|
+
* client-supplied identity is ever authoritative;
|
|
19
|
+
* - SSE is ACTOR-SCOPED: a stream without a matching, valid turn ownership
|
|
20
|
+
* record is denied — never treated as public; every emitted frame is a
|
|
21
|
+
* closed `vict.agent-stream@1` wire envelope validated before write;
|
|
22
|
+
* - Node HTTP backpressure is honored: `response.write() === false` means
|
|
23
|
+
* the bytes were ACCEPTED, and the remaining replay is delivered after
|
|
24
|
+
* `drain` — never discarded;
|
|
25
|
+
* - NO privileged Mastra route exists at this boundary (a probe set of
|
|
26
|
+
* Mastra-native paths is covered by permanent tests);
|
|
27
|
+
* - raw exceptions, tokens, and secrets never echo.
|
|
28
|
+
*/
|
|
29
|
+
/** Maximum command body size. */
|
|
30
|
+
export const MAX_BODY_BYTES = 256 * 1024;
|
|
31
|
+
/** Structured transport error (safe body; never echoes raw content). */
|
|
32
|
+
export class HttpError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
status;
|
|
35
|
+
constructor(code, status) {
|
|
36
|
+
super(`HTTP request rejected (${code}).`);
|
|
37
|
+
this.name = 'HttpError';
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.status = status;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Map a command dispatch failure to a stable HTTP status. */
|
|
43
|
+
function statusForError(code) {
|
|
44
|
+
if (code === undefined) {
|
|
45
|
+
return 500;
|
|
46
|
+
}
|
|
47
|
+
switch (code) {
|
|
48
|
+
case 'VICT_AUTH_TOKEN_MISSING':
|
|
49
|
+
case 'VICT_AUTH_TOKEN_UNKNOWN':
|
|
50
|
+
return 401;
|
|
51
|
+
case 'VICT_ACTOR_SCOPE_DENIED':
|
|
52
|
+
return 403;
|
|
53
|
+
case 'VICT_COMMAND_UNKNOWN':
|
|
54
|
+
case 'VICT_CONTROL_CHANGESET_MISSING':
|
|
55
|
+
case 'VICT_TURN_MISSING':
|
|
56
|
+
case 'VICT_APPROVAL_MISSING':
|
|
57
|
+
case 'VICT_CONTROL_RELEASE_MISSING':
|
|
58
|
+
case 'VICT_CONTROL_TURN_MISSING':
|
|
59
|
+
case 'VICT_CONTROL_INVOCATION_MISSING':
|
|
60
|
+
case 'VICT_CONTROL_APPROVAL_MISSING':
|
|
61
|
+
case 'VICT_STORE_ACTIVATION_NOT_FOUND':
|
|
62
|
+
case 'VICT_STORE_RELEASE_NOT_FOUND':
|
|
63
|
+
case 'VICT_STREAM_ACTOR_MISMATCH':
|
|
64
|
+
case 'VICT_TURN_ACTOR_MISMATCH':
|
|
65
|
+
return 404;
|
|
66
|
+
case 'VICT_COMMAND_FIELD_INVALID':
|
|
67
|
+
case 'VICT_COMMAND_PAYLOAD_INVALID':
|
|
68
|
+
case 'VICT_COMMAND_IDEMPOTENCY_KEY_INVALID':
|
|
69
|
+
case 'VICT_CONTROL_ID_INVALID':
|
|
70
|
+
case 'VICT_CONTROL_FIELD_INVALID':
|
|
71
|
+
case 'VICT_CONTROL_TIMESTAMP_INVALID':
|
|
72
|
+
case 'VICT_CONTROL_OPERATION_INVALID':
|
|
73
|
+
case 'VICT_CONTROL_RELEASE_INVALID':
|
|
74
|
+
case 'VICT_CONTROL_CHANGESET_EXISTS':
|
|
75
|
+
case 'VICT_CONTROL_TURN_COLLISION':
|
|
76
|
+
case 'VICT_CONTROL_INVOCATION_COLLISION':
|
|
77
|
+
case 'VICT_CONTROL_INVOCATION_KEY_COLLISION':
|
|
78
|
+
case 'VICT_CONTROL_RELEASE_COLLISION':
|
|
79
|
+
case 'VICT_AGENT_DELETION_INTENT_COLLISION':
|
|
80
|
+
return 400;
|
|
81
|
+
case 'VICT_COMMAND_IDEMPOTENCY_CONFLICT':
|
|
82
|
+
case 'VICT_COMMAND_IDEMPOTENCY_IN_PROGRESS':
|
|
83
|
+
case 'VICT_CONTROL_BASE_STALE':
|
|
84
|
+
case 'VICT_STREAM_CURSOR_FUTURE':
|
|
85
|
+
return 409;
|
|
86
|
+
case 'VICT_CONTROL_APPROVAL_CONFLICT':
|
|
87
|
+
case 'VICT_CONTROL_APPROVALS_INVALIDATED':
|
|
88
|
+
case 'VICT_CONTROL_CHANGESET_NOT_APPROVED':
|
|
89
|
+
case 'VICT_CONTROL_CHANGESET_NOT_DRAFT':
|
|
90
|
+
case 'VICT_CONTROL_CHANGESET_EXPIRED':
|
|
91
|
+
case 'VICT_CONTROL_CHANGESET_STATUS_CONFLICT':
|
|
92
|
+
case 'VICT_CONTROL_EVIDENCE_MISSING':
|
|
93
|
+
case 'VICT_CONTROL_EVIDENCE_FAILED':
|
|
94
|
+
case 'VICT_CONTROL_EVIDENCE_STALE':
|
|
95
|
+
case 'VICT_CONTROL_EVIDENCE_NOT_AUTHORITATIVE':
|
|
96
|
+
case 'VICT_CONTROL_EVIDENCE_SUBJECT_MISMATCH':
|
|
97
|
+
case 'VICT_CONTROL_EVIDENCE_CONTENT_MISMATCH':
|
|
98
|
+
case 'VICT_CONTROL_EVIDENCE_BASE_MISMATCH':
|
|
99
|
+
case 'VICT_CONTROL_EVIDENCE_ACTOR_MISMATCH':
|
|
100
|
+
case 'VICT_CONTROL_TURN_INVALID_TRANSITION':
|
|
101
|
+
case 'VICT_CONTROL_INVOCATION_REGRESSION':
|
|
102
|
+
case 'VICT_CONTROL_INVOCATION_TERMINAL':
|
|
103
|
+
case 'VICT_CONTROL_APPROVAL_EXPIRED':
|
|
104
|
+
case 'VICT_APPROVAL_SELF_DENIED':
|
|
105
|
+
case 'VICT_APPROVAL_EXPIRED':
|
|
106
|
+
case 'VICT_TURN_EXECUTOR_UNAVAILABLE':
|
|
107
|
+
case 'VICT_APPDATA_UNAVAILABLE':
|
|
108
|
+
case 'VICT_RUN_STORE_UNAVAILABLE':
|
|
109
|
+
return 409;
|
|
110
|
+
default:
|
|
111
|
+
if (code.startsWith('VICT_ACTOR_')) {
|
|
112
|
+
return 403;
|
|
113
|
+
}
|
|
114
|
+
return 500;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function outcomeBody(outcome) {
|
|
118
|
+
if (outcome.ok) {
|
|
119
|
+
return { ok: true, data: outcome.data };
|
|
120
|
+
}
|
|
121
|
+
return { ok: false, code: outcome.code };
|
|
122
|
+
}
|
|
123
|
+
/** Read the request body with the bounded limit (fail closed). */
|
|
124
|
+
function readBody(req, res) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let size = 0;
|
|
128
|
+
req.on('data', (chunk) => {
|
|
129
|
+
size += chunk.length;
|
|
130
|
+
if (size > MAX_BODY_BYTES) {
|
|
131
|
+
// Respond safely without destroying the socket (fetch clients must
|
|
132
|
+
// see the structured 413 body).
|
|
133
|
+
try {
|
|
134
|
+
res.writeHead(413, { 'content-type': 'application/json; charset=utf-8' });
|
|
135
|
+
res.end(safeErrorBody('VICT_HTTP_BODY_TOO_LARGE'));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* already gone */
|
|
139
|
+
}
|
|
140
|
+
req.removeAllListeners('data');
|
|
141
|
+
req.resume();
|
|
142
|
+
reject(new HttpError('VICT_HTTP_BODY_TOO_LARGE', 413));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
});
|
|
147
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
148
|
+
req.on('error', () => reject(new HttpError('VICT_HTTP_BODY_MALFORMED', 400)));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
function sendJson(res, status, body) {
|
|
152
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
153
|
+
res.end(JSON.stringify(body));
|
|
154
|
+
}
|
|
155
|
+
function safeErrorBody(code) {
|
|
156
|
+
return JSON.stringify({ ok: false, code });
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* EXACT supported Content-Type parsing: the media type must be exactly
|
|
160
|
+
* `application/json` (case-insensitive) with at most an optional charset
|
|
161
|
+
* parameter (`utf-8`/`us-ascii`). Near-miss types fail closed.
|
|
162
|
+
*/
|
|
163
|
+
export function isSupportedJsonContentType(contentType) {
|
|
164
|
+
const parts = contentType.split(';').map((part) => part.trim());
|
|
165
|
+
const mediaType = parts[0]?.toLowerCase();
|
|
166
|
+
if (mediaType !== 'application/json') {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
for (const parameter of parts.slice(1)) {
|
|
170
|
+
if (parameter.length === 0) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const [name, value] = parameter.split('=', 2);
|
|
174
|
+
if (name?.trim().toLowerCase() !== 'charset') {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const charset = value?.trim().toLowerCase().replace(/^"|"$/g, '');
|
|
178
|
+
if (charset !== 'utf-8' && charset !== 'us-ascii') {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
/** Route the command path to a command name. */
|
|
185
|
+
const ROUTE_COMMANDS = {
|
|
186
|
+
'/vict/v1/health': 'health.inspect',
|
|
187
|
+
'/vict/v1/compatibility': 'compatibility.inspect',
|
|
188
|
+
'/vict/v1/actor/whoami': 'actor.whoami',
|
|
189
|
+
'/vict/v1/changesets': 'changeset.list',
|
|
190
|
+
'/vict/v1/releases/selected': 'release.get-selected',
|
|
191
|
+
'/vict/v1/turns': 'agent.turn.start',
|
|
192
|
+
'/vict/v1/app/query': 'app.data.query',
|
|
193
|
+
'/vict/v1/app/mutate': 'app.data.mutate',
|
|
194
|
+
};
|
|
195
|
+
/** The command endpoints with explicit verbs (mutation routes). */
|
|
196
|
+
const POST_ROUTES = {
|
|
197
|
+
'/vict/v1/changesets': () => 'changeset.propose',
|
|
198
|
+
'/vict/v1/changesets/commit': () => 'changeset.commit',
|
|
199
|
+
'/vict/v1/changesets/decide': () => 'changeset.decide',
|
|
200
|
+
'/vict/v1/changesets/revise': () => 'changeset.revise',
|
|
201
|
+
'/vict/v1/changesets/check': () => 'changeset.execute-check',
|
|
202
|
+
'/vict/v1/changesets/evidence': () => 'changeset.attach-evidence',
|
|
203
|
+
'/vict/v1/releases/publish': () => 'release.publish',
|
|
204
|
+
'/vict/v1/releases/select': () => 'release.select',
|
|
205
|
+
'/vict/v1/releases/rollback': () => 'release.rollback',
|
|
206
|
+
'/vict/v1/activations/select': () => 'activation.select',
|
|
207
|
+
'/vict/v1/runs/cancel': () => 'run.cancel',
|
|
208
|
+
'/vict/v1/turns': () => 'agent.turn.start',
|
|
209
|
+
'/vict/v1/turns/cancel': () => 'agent.turn.cancel',
|
|
210
|
+
'/vict/v1/app/actions': () => 'app.data.mutate',
|
|
211
|
+
'/vict/v1/actor/whoami': () => 'actor.whoami',
|
|
212
|
+
'/vict/v1/streams/inspect': () => 'stream.inspect',
|
|
213
|
+
};
|
|
214
|
+
/** The bounded reconnect cursor: `v1:<streamId>:<lastSeq>`. */
|
|
215
|
+
const CURSOR_PATTERN = /^v1:([A-Za-z0-9][A-Za-z0-9._:@-]{0,127}):(\d{1,19})$/;
|
|
216
|
+
export function encodeStreamCursor(streamId, lastSeq) {
|
|
217
|
+
return `v1:${streamId}:${lastSeq}`;
|
|
218
|
+
}
|
|
219
|
+
export function decodeStreamCursor(raw) {
|
|
220
|
+
const match = CURSOR_PATTERN.exec(raw);
|
|
221
|
+
if (match === null) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
const lastSeq = Number(match[2]);
|
|
225
|
+
if (!Number.isSafeInteger(lastSeq) || lastSeq < 0) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
return { streamId: match[1], lastSeq };
|
|
229
|
+
}
|
|
230
|
+
/** Create the composed VICT HTTP + SSE server (not yet listening). */
|
|
231
|
+
export function createVictHttpServer(options) {
|
|
232
|
+
let boundAddress;
|
|
233
|
+
const openStreams = new Set();
|
|
234
|
+
const server = options.server ??
|
|
235
|
+
createServer((req, res) => {
|
|
236
|
+
void handle(req, res).catch((error) => {
|
|
237
|
+
let status = 500;
|
|
238
|
+
let code = 'VICT_HTTP_INTERNAL';
|
|
239
|
+
if (error instanceof HttpError) {
|
|
240
|
+
status = error.status;
|
|
241
|
+
code = error.code;
|
|
242
|
+
}
|
|
243
|
+
else if (error instanceof AuthenticationError) {
|
|
244
|
+
status = 401;
|
|
245
|
+
code = error.code;
|
|
246
|
+
}
|
|
247
|
+
else if (error instanceof VictControlError) {
|
|
248
|
+
status = statusForError(error.code);
|
|
249
|
+
code = error.code;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
sendJson(res, status, { ok: false, code });
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* the client already went away */
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
// Track the REAL bound port (0 before `listen`).
|
|
260
|
+
const refreshBoundAddress = () => {
|
|
261
|
+
const address = server.address();
|
|
262
|
+
if (address !== null && typeof address === 'object') {
|
|
263
|
+
boundAddress = { port: address.port };
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
server.on('listening', refreshBoundAddress);
|
|
267
|
+
refreshBoundAddress();
|
|
268
|
+
async function authenticate(req) {
|
|
269
|
+
// Bearer-token authentication: the transport credential NEVER carries
|
|
270
|
+
// identity claims; the authoritative context derives from the directory.
|
|
271
|
+
const header = req.headers.authorization;
|
|
272
|
+
const token = typeof header === 'string' && header.startsWith('Bearer ')
|
|
273
|
+
? header.slice('Bearer '.length)
|
|
274
|
+
: undefined;
|
|
275
|
+
return options.auth.resolve(token);
|
|
276
|
+
}
|
|
277
|
+
async function handle(req, res) {
|
|
278
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
279
|
+
const path = url.pathname;
|
|
280
|
+
// ---- SSE stream endpoint -------------------------------------------
|
|
281
|
+
const streamMatch = /^\/vict\/v1\/streams\/([A-Za-z0-9][A-Za-z0-9._:@-]{0,127})$/.exec(path);
|
|
282
|
+
if (streamMatch !== null && req.method === 'GET') {
|
|
283
|
+
const actor = await authenticate(req);
|
|
284
|
+
try {
|
|
285
|
+
await handleSse(req, res, actor, streamMatch[1], url);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
// Headers may already be flushed: end the stream cleanly instead
|
|
289
|
+
// of leaving the socket half-open.
|
|
290
|
+
if (!res.writableEnded) {
|
|
291
|
+
try {
|
|
292
|
+
if (res.headersSent) {
|
|
293
|
+
res.end();
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
sendJson(res, 500, { ok: false, code: 'VICT_HTTP_INTERNAL' });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
res.destroy();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
void error;
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (path === '/vict/v1/health') {
|
|
308
|
+
// Health is unauthenticated-safe: it discloses only compatibility
|
|
309
|
+
// markers, never deployment details.
|
|
310
|
+
sendJson(res, 200, {
|
|
311
|
+
ok: true,
|
|
312
|
+
data: {
|
|
313
|
+
healthy: true,
|
|
314
|
+
commandSchema: 'vict.command@1',
|
|
315
|
+
streamSchema: 'vict.agent-stream@1',
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
// Everything else is a protected command endpoint.
|
|
321
|
+
const actor = await authenticate(req);
|
|
322
|
+
let body;
|
|
323
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
324
|
+
// EXACT supported content type (application/json, optional charset).
|
|
325
|
+
const contentType = req.headers['content-type'];
|
|
326
|
+
if (typeof contentType !== 'string' || !isSupportedJsonContentType(contentType)) {
|
|
327
|
+
throw new HttpError('VICT_HTTP_CONTENT_TYPE_INVALID', 415);
|
|
328
|
+
}
|
|
329
|
+
body = await readBody(req, res);
|
|
330
|
+
}
|
|
331
|
+
else if (req.method === 'GET') {
|
|
332
|
+
// GET commands may carry bounded query payloads.
|
|
333
|
+
const queryPayload = {};
|
|
334
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
335
|
+
if (queryPayload[key] === undefined) {
|
|
336
|
+
queryPayload[key] = value;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
body = JSON.stringify({ payload: queryPayload });
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
body = '{}';
|
|
343
|
+
}
|
|
344
|
+
let parsed;
|
|
345
|
+
try {
|
|
346
|
+
parsed = body.length === 0 ? { payload: {} } : JSON.parse(body);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
throw new HttpError('VICT_HTTP_BODY_MALFORMED', 400);
|
|
350
|
+
}
|
|
351
|
+
// The COMPLETE body envelope is validated — not only its payload
|
|
352
|
+
// member. The closed top-level field set is `payload` plus the exact
|
|
353
|
+
// schema marker; unknown top-level fields fail closed.
|
|
354
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
355
|
+
throw new HttpError('VICT_HTTP_BODY_MALFORMED', 400);
|
|
356
|
+
}
|
|
357
|
+
const envelope = parsed;
|
|
358
|
+
for (const key of Object.keys(envelope)) {
|
|
359
|
+
if (key !== 'payload' && key !== 'schema') {
|
|
360
|
+
throw new HttpError('VICT_HTTP_BODY_MALFORMED', 400);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (envelope.schema !== undefined && envelope.schema !== 'vict.command@1') {
|
|
364
|
+
throw new HttpError('VICT_HTTP_BODY_MALFORMED', 400);
|
|
365
|
+
}
|
|
366
|
+
let payload = envelope.payload;
|
|
367
|
+
if (payload === undefined) {
|
|
368
|
+
payload = {};
|
|
369
|
+
}
|
|
370
|
+
if (typeof payload === 'object' && payload !== null && !Array.isArray(payload)) {
|
|
371
|
+
if (Object.keys(payload).length > 64) {
|
|
372
|
+
throw new HttpError('VICT_HTTP_RATE_BOUNDS', 400);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Dynamic instance routes inject the authoritative path identity into
|
|
376
|
+
// the bounded payload (path params win over client-supplied fields).
|
|
377
|
+
const resolved = resolveCommand(req, path, url, (typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
|
378
|
+
? payload
|
|
379
|
+
: {}));
|
|
380
|
+
if (resolved.pathParams !== undefined) {
|
|
381
|
+
payload = { ...payload, ...resolved.pathParams };
|
|
382
|
+
}
|
|
383
|
+
const idempotencyKey = req.headers['idempotency-key'];
|
|
384
|
+
const command = resolved.command;
|
|
385
|
+
const outcome = await options.commandService.dispatch(actor, {
|
|
386
|
+
command,
|
|
387
|
+
payload: payload,
|
|
388
|
+
...(typeof idempotencyKey === 'string' ? { idempotencyKey } : {}),
|
|
389
|
+
});
|
|
390
|
+
if (!outcome.ok) {
|
|
391
|
+
sendJson(res, statusForError(outcome.code), outcomeBody(outcome));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
sendJson(res, 200, outcomeBody(outcome));
|
|
395
|
+
}
|
|
396
|
+
/** Resolve the command from the request (closed route table). */
|
|
397
|
+
function resolveCommand(req, path, url, payload) {
|
|
398
|
+
// GET routes with fixed commands (checked first for dual-verb paths).
|
|
399
|
+
if (req.method === 'GET') {
|
|
400
|
+
const fixedGet = ROUTE_COMMANDS[path];
|
|
401
|
+
if (fixedGet !== undefined) {
|
|
402
|
+
return { command: fixedGet };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Explicit POST routes.
|
|
406
|
+
const post = POST_ROUTES[path];
|
|
407
|
+
if (post !== undefined) {
|
|
408
|
+
if (req.method !== 'POST') {
|
|
409
|
+
throw new HttpError('VICT_HTTP_METHOD_UNSUPPORTED', 405);
|
|
410
|
+
}
|
|
411
|
+
return { command: post() };
|
|
412
|
+
}
|
|
413
|
+
// GET routes with fixed commands.
|
|
414
|
+
const fixed = ROUTE_COMMANDS[path];
|
|
415
|
+
if (fixed !== undefined) {
|
|
416
|
+
if (req.method !== 'GET') {
|
|
417
|
+
throw new HttpError('VICT_HTTP_METHOD_UNSUPPORTED', 405);
|
|
418
|
+
}
|
|
419
|
+
return { command: fixed };
|
|
420
|
+
}
|
|
421
|
+
// Dynamic instance routes.
|
|
422
|
+
const changesetMatch = /^\/vict\/v1\/changesets\/([A-Za-z0-9][A-Za-z0-9._:@-]{0,127})$/.exec(path);
|
|
423
|
+
if (changesetMatch !== null) {
|
|
424
|
+
if (req.method !== 'GET') {
|
|
425
|
+
throw new HttpError('VICT_HTTP_METHOD_UNSUPPORTED', 405);
|
|
426
|
+
}
|
|
427
|
+
return { command: 'changeset.get', pathParams: { changesetId: changesetMatch[1] } };
|
|
428
|
+
}
|
|
429
|
+
const turnMatch = /^\/vict\/v1\/turns\/([A-Za-z0-9][A-Za-z0-9._:@-]{0,127})$/.exec(path);
|
|
430
|
+
if (turnMatch !== null) {
|
|
431
|
+
if (req.method !== 'GET') {
|
|
432
|
+
throw new HttpError('VICT_HTTP_METHOD_UNSUPPORTED', 405);
|
|
433
|
+
}
|
|
434
|
+
return { command: 'agent.turn.get', pathParams: { turnId: turnMatch[1] } };
|
|
435
|
+
}
|
|
436
|
+
const approvalMatch = /^\/vict\/v1\/approvals\/([A-Za-z0-9][A-Za-z0-9._:@-]{0,127})$/.exec(path);
|
|
437
|
+
if (approvalMatch !== null && req.method === 'POST') {
|
|
438
|
+
// The decision is part of the closed route contract: the approve and
|
|
439
|
+
// decline commands are distinct in the versioned command surface.
|
|
440
|
+
const decision = payload['decision'];
|
|
441
|
+
if (decision === 'approved') {
|
|
442
|
+
return {
|
|
443
|
+
command: 'agent.tool.approve',
|
|
444
|
+
pathParams: { approvalId: approvalMatch[1] },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (decision === 'declined') {
|
|
448
|
+
return {
|
|
449
|
+
command: 'agent.tool.decline',
|
|
450
|
+
pathParams: { approvalId: approvalMatch[1] },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
throw new HttpError('VICT_HTTP_FIELD_INVALID', 400);
|
|
454
|
+
}
|
|
455
|
+
const streamInspect = /^\/vict\/v1\/streams\/([A-Za-z0-9][A-Za-z0-9._:@-]{0,127})\/inspect$/.exec(path);
|
|
456
|
+
if (streamInspect !== null && req.method === 'GET') {
|
|
457
|
+
return { command: 'stream.inspect', pathParams: { streamId: streamInspect[1] } };
|
|
458
|
+
}
|
|
459
|
+
throw new HttpError('VICT_HTTP_ROUTE_UNKNOWN', 404);
|
|
460
|
+
}
|
|
461
|
+
const TERMINAL_EVENT_KINDS = new Set([
|
|
462
|
+
'response.completed',
|
|
463
|
+
'response.failed',
|
|
464
|
+
'response.cancelled',
|
|
465
|
+
]);
|
|
466
|
+
async function handleSse(req, res, actor, streamId, url) {
|
|
467
|
+
// ---- AUTHORIZATION (fail closed; never treat a stream as public) ----
|
|
468
|
+
// A stream is readable by its owning actor; broader access requires the
|
|
469
|
+
// explicit privileged `operator.resolve` scope. Durable rows without a
|
|
470
|
+
// matching, valid turn ownership record DENY access: a stream that no
|
|
471
|
+
// turn owns does not exist for this caller.
|
|
472
|
+
const turnRows = await options.stores.turns.listTurns();
|
|
473
|
+
const streamTurn = turnRows.find((turn) => turn.streamId === streamId);
|
|
474
|
+
if (streamTurn === undefined) {
|
|
475
|
+
sendJson(res, 404, { ok: false, code: 'VICT_STREAM_UNKNOWN' });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (streamTurn.actorId !== actor.actorId && !actor.scopes.includes('operator.resolve')) {
|
|
479
|
+
sendJson(res, 403, { ok: false, code: 'VICT_STREAM_ACTOR_MISMATCH' });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// ---- Cursor validation (stream identity + sequence) ------------------
|
|
483
|
+
const lastEventId = req.headers['last-event-id'];
|
|
484
|
+
const cursorParam = url.searchParams.get('cursor');
|
|
485
|
+
const rawCursor = typeof lastEventId === 'string' && lastEventId.length > 0 ? lastEventId : cursorParam;
|
|
486
|
+
let lastSeq = 0;
|
|
487
|
+
if (rawCursor !== undefined && rawCursor !== null && rawCursor !== '') {
|
|
488
|
+
const decoded = typeof rawCursor === 'string' ? decodeStreamCursor(rawCursor) : undefined;
|
|
489
|
+
if (decoded === undefined) {
|
|
490
|
+
sendJson(res, 400, { ok: false, code: 'VICT_STREAM_CURSOR_MALFORMED' });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (decoded.streamId !== streamId) {
|
|
494
|
+
// A cursor from ANOTHER stream is rejected (cross-stream replay).
|
|
495
|
+
sendJson(res, 403, { ok: false, code: 'VICT_STREAM_CURSOR_STREAM_MISMATCH' });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Future detection uses the AUTHORITATIVE durable sequence bound
|
|
499
|
+
// (the ledger), which survives restart when the memory buffer is empty.
|
|
500
|
+
if (decoded.lastSeq > (await options.hub.latestSeq(streamId))) {
|
|
501
|
+
sendJson(res, 409, { ok: false, code: 'VICT_STREAM_CURSOR_FUTURE' });
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
lastSeq = decoded.lastSeq;
|
|
505
|
+
}
|
|
506
|
+
// ---- Replay bounds BEFORE streaming (response-mechanism disclosure) --
|
|
507
|
+
// Bounded-memory status: the ledger high-watermark plus exact
|
|
508
|
+
// transient-gap detection — no replay materialization.
|
|
509
|
+
const replay = await options.hub.replayStatus({ streamId, lastSeq });
|
|
510
|
+
res.writeHead(200, {
|
|
511
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
512
|
+
'cache-control': 'no-cache',
|
|
513
|
+
connection: 'keep-alive',
|
|
514
|
+
// Replay status is NOT an event: it crosses the boundary through this
|
|
515
|
+
// separately defined response mechanism (headers). An authoritative
|
|
516
|
+
// `true` discloses that transient deltas between the cursor and the
|
|
517
|
+
// buffer are unrecoverable and completed content must be recovered
|
|
518
|
+
// from the durable, actor-authorized conversation store.
|
|
519
|
+
...(replay.olderThanBuffer ? { 'x-vict-replay-bounded': 'true' } : {}),
|
|
520
|
+
'x-vict-stream-newest-seq': String(replay.newestSeq),
|
|
521
|
+
'x-vict-stream-cursor': encodeStreamCursor(streamId, replay.newestSeq),
|
|
522
|
+
});
|
|
523
|
+
res.flushHeaders?.();
|
|
524
|
+
// Flush the SSE handshake immediately so clients observe the response.
|
|
525
|
+
res.write(': connected\n\n');
|
|
526
|
+
openStreams.add(res);
|
|
527
|
+
let closed = false;
|
|
528
|
+
let overflowed = false;
|
|
529
|
+
const finish = () => {
|
|
530
|
+
if (!closed) {
|
|
531
|
+
closed = true;
|
|
532
|
+
openStreams.delete(res);
|
|
533
|
+
options.hub.unsubscribe(streamId, subscriberId);
|
|
534
|
+
res.end();
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
/** Serialize one frame as a closed wire envelope (validated; fail closed).
|
|
538
|
+
*
|
|
539
|
+
* Every SSE `id:` carries the SAME full cursor format the reconnect
|
|
540
|
+
* parser accepts (`v1:<streamId>:<seq>`), so a browser's automatic
|
|
541
|
+
* `Last-Event-ID` reconnects WITHOUT any client rewriting.
|
|
542
|
+
*/
|
|
543
|
+
const serializeFrame = (event) => {
|
|
544
|
+
const frame = { schema: AGENT_STREAM_SCHEMA, ...event };
|
|
545
|
+
// Every server-emitted frame conforms to the ONE closed wire-envelope
|
|
546
|
+
// validator — including frames reconstructed from durable rows.
|
|
547
|
+
assertAgentStreamWireEnvelope(frame);
|
|
548
|
+
return `id: ${encodeStreamCursor(streamId, event.seq)}\nevent: ${event.kind}\ndata: ${JSON.stringify(frame)}\n\n`;
|
|
549
|
+
};
|
|
550
|
+
/**
|
|
551
|
+
* The transport's OWN bounded frame queue. Backpressure NEVER discards:
|
|
552
|
+
* `response.write() === false` means the bytes were ACCEPTED — the
|
|
553
|
+
* saturated socket's frames wait in this queue and are pumped one by
|
|
554
|
+
* one, in order, after `drain` (per-event identity preserved; the
|
|
555
|
+
* byte-level transport NEVER coalesces). The hub's coalescing pending
|
|
556
|
+
* queue remains the recovery path for a subscriber that signals its
|
|
557
|
+
* own saturation, and its pulled events are re-enqueued here in order.
|
|
558
|
+
*/
|
|
559
|
+
const FRAME_QUEUE_LIMIT = 4096;
|
|
560
|
+
const frameQueue = [];
|
|
561
|
+
let pumping = false;
|
|
562
|
+
let terminalWritten = false;
|
|
563
|
+
const subscriberId = `sse-${actor.actorId}-${Math.random().toString(36).slice(2, 10)}`;
|
|
564
|
+
const subscriber = {
|
|
565
|
+
subscriberId,
|
|
566
|
+
// The hub delivers the ordered backlog + live events through this
|
|
567
|
+
// single door: replay and live delivery share ONE ordering path, and
|
|
568
|
+
// a saturated socket buffers (never discards) in the transport's
|
|
569
|
+
// bounded queue until `drain`.
|
|
570
|
+
deliver: (event) => {
|
|
571
|
+
if (closed) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
if (frameQueue.length >= FRAME_QUEUE_LIMIT) {
|
|
575
|
+
// Hard bound: EXPLICIT recoverable overflow — detach and let the
|
|
576
|
+
// client reconnect from its last acknowledged `Last-Event-ID`
|
|
577
|
+
// (never silent loss, never unbounded memory).
|
|
578
|
+
overflowed = true;
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
frameQueue.push(event);
|
|
582
|
+
void pump();
|
|
583
|
+
return true;
|
|
584
|
+
},
|
|
585
|
+
// EXPLICIT recoverable overflow policy: the bounded pending queue
|
|
586
|
+
// overflowed — detach and let the client reconnect from its last
|
|
587
|
+
// acknowledged `Last-Event-ID` (never silent loss).
|
|
588
|
+
onOverflow: () => {
|
|
589
|
+
overflowed = true;
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
const awaitDrain = () => new Promise((resolve) => {
|
|
593
|
+
if (closed || !res.writableNeedDrain) {
|
|
594
|
+
resolve();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
res.once('drain', () => resolve());
|
|
598
|
+
});
|
|
599
|
+
const pump = async () => {
|
|
600
|
+
if (pumping || closed) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
pumping = true;
|
|
604
|
+
try {
|
|
605
|
+
for (;;) {
|
|
606
|
+
while (frameQueue.length > 0) {
|
|
607
|
+
if (res.writableNeedDrain) {
|
|
608
|
+
await awaitDrain();
|
|
609
|
+
if (closed) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const event = frameQueue.shift();
|
|
614
|
+
try {
|
|
615
|
+
res.write(serializeFrame(event));
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
finish();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (TERMINAL_EVENT_KINDS.has(event.kind)) {
|
|
622
|
+
terminalWritten = true;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (terminalWritten) {
|
|
626
|
+
// The terminal event (and everything before it) was written in
|
|
627
|
+
// order; close cleanly after it.
|
|
628
|
+
finish();
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (overflowed) {
|
|
632
|
+
finish();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Recover hub-pended events (slow-subscriber mode) in order.
|
|
636
|
+
const pending = options.hub.pull(streamId, subscriberId);
|
|
637
|
+
if (pending.length === 0) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
for (const event of pending) {
|
|
641
|
+
frameQueue.push(event);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
pumping = false;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
// The subscription is registered BEFORE the ledger read inside the hub,
|
|
650
|
+
// so no publish can fall into a replay/live gap. The backlog arrives
|
|
651
|
+
// through `deliver` (the transport queue) and is pumped in order.
|
|
652
|
+
await options.hub.subscribe(streamId, subscriber, { lastSeq });
|
|
653
|
+
void pump();
|
|
654
|
+
res.on('drain', () => {
|
|
655
|
+
// `response.write() === false` means the bytes were ACCEPTED; resume
|
|
656
|
+
// delivery only when the socket has drained.
|
|
657
|
+
void pump();
|
|
658
|
+
});
|
|
659
|
+
req.on('close', () => {
|
|
660
|
+
finish();
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
server.on('close', () => {
|
|
664
|
+
for (const res of openStreams) {
|
|
665
|
+
try {
|
|
666
|
+
res.end();
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
/* already gone */
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
openStreams.clear();
|
|
673
|
+
});
|
|
674
|
+
return {
|
|
675
|
+
server,
|
|
676
|
+
port: () => boundAddress?.port ?? 0,
|
|
677
|
+
async close() {
|
|
678
|
+
// Await ACTUAL shutdown: close the server, end every open SSE
|
|
679
|
+
// subscriber, and resolve only when the server emits its close event.
|
|
680
|
+
for (const res of openStreams) {
|
|
681
|
+
try {
|
|
682
|
+
res.end();
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
/* already gone */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
openStreams.clear();
|
|
689
|
+
await new Promise((resolve) => {
|
|
690
|
+
if (server.listening) {
|
|
691
|
+
server.close(() => resolve());
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
resolve();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/** Listen on an ephemeral port (real HTTP, loopback). */
|
|
701
|
+
export function listenVictHttpServer(composed) {
|
|
702
|
+
return new Promise((resolve, reject) => {
|
|
703
|
+
composed.server.listen(0, '127.0.0.1', () => {
|
|
704
|
+
const address = composed.server.address();
|
|
705
|
+
if (address === null || typeof address === 'object') {
|
|
706
|
+
const port = address.port;
|
|
707
|
+
resolve(port);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
reject(new Error('the server did not bind a TCP port'));
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=http.js.map
|