flow-run-routes.mjs
495 lines 16.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hosted bridge REST routes for Flow run / consent surfaces
3 * (SITE-FINISH-FLOW-RUN-KN-b — §FR.0.4 parity with FLOW-CAPTURE-LIVE-KN-b).
4 *
5 * Gated by FLOW_RUN_WRITES_ENABLED / FLOW_AUTOMATABLE_EXECUTION_ENABLED
6 * (both default OFF → 403 FLOW_RUN_WRITES_DISABLED /
7 * FLOW_AUTOMATABLE_EXECUTION_DISABLED). Does NOT flip those envs.
8 *
9 * Mutating routes and list/get reads use withExternalProtocolBlobSync /
10 * hydrateExternalProtocolStoresFromBlob so hub_flow_store.json survives
11 * Netlify lambda recycle (runs[] already merged in mergeFlowStoreJson).
12 * Consent ledger files stay process-local this slice (ops choice).
13 *
14 * @see ~/scooling/docs/SITE-FINISH-FLOW-RUN-FREEZE.md §FR.0.4
15 * @see hub/bridge/flow-capture-routes.mjs
16 * @see lib/flow/flow-execution.mjs
17 */
18
19 import {
20 handleFlowRunListRequest,
21 handleFlowRunGetRequest,
22 handleFlowRunStartRequest,
23 handleFlowRunAdvanceRequest,
24 handleFlowRunEvidenceRequest,
25 handleFlowRunExecuteAutomatableRequest,
26 handleFlowRunSubmitReviewRequest,
27 handleFlowExecutionConsentMintRequest,
28 } from '../../lib/flow/flow-execution.mjs';
29 import { resolveStarterFlowsDir } from '../../lib/flow/flow-store.mjs';
30 import { FLOW_PROPOSAL_SOURCE } from '../../lib/flow/flow-authoring.mjs';
31 import {
32 withExternalProtocolBlobSync,
33 hydrateExternalProtocolStoresFromBlob,
34 } from './external-agent-blob-store.mjs';
35 import { isSessionBoundActor } from '../gateway/access-token-authz.mjs';
36 import { verifyJwtWithSecretRotation } from '../lib/session-secret-rotation.mjs';
37
38 /** Bundled `flows/starter` for Netlify included_files (parity with task-routes). */
39 const BRIDGE_STARTER_FLOWS_DIR = resolveStarterFlowsDir(import.meta.url);
40
41 /**
42 * Map bridge role to flow-run handler role (member → editor, matching self-hosted hub).
43 *
44 * @param {string} role
45 * @returns {string}
46 */
47 export function bridgeFlowRunHandlerRole(role) {
48 const r = typeof role === 'string' ? role.trim().toLowerCase() : '';
49 return r === 'member' || !r ? 'editor' : r;
50 }
51
52 /**
53 * POST a run-outcome proposal to the canister (hosted submit-review path).
54 *
55 * @param {{
56 * canisterUrl: string,
57 * headers: Record<string, string>,
58 * input: {
59 * path?: string,
60 * body?: string,
61 * intent?: string,
62 * frontmatter?: Record<string, unknown>,
63 * external_ref?: string,
64 * review_queue?: string,
65 * source?: string,
66 * vault_id?: string,
67 * proposed_by?: string,
68 * },
69 * }} opts
70 * @returns {Promise<{ proposal_id: string }>}
71 */
72 export async function createRunOutcomeProposalOnCanister(opts) {
73 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
74 if (!base) {
75 const err = new Error('CANISTER_URL required for hosted run outcome proposals');
76 err.status = 503;
77 err.code = 'NOT_AVAILABLE';
78 throw err;
79 }
80
81 const input = opts.input;
82 const frontmatter = {
83 ...(input.frontmatter && typeof input.frontmatter === 'object' ? input.frontmatter : {}),
84 knowtation_proposal_source: FLOW_PROPOSAL_SOURCE,
85 };
86 const path =
87 typeof input.path === 'string' && input.path.trim()
88 ? input.path.trim()
89 : `inbox/flow-run-outcome-${Date.now()}.md`;
90
91 /** @type {Record<string, unknown>} */
92 const payload = {
93 path,
94 body: input.body ?? '',
95 intent: input.intent ?? '',
96 frontmatter,
97 };
98 if (input.external_ref) payload.external_ref = input.external_ref;
99 if (input.review_queue) payload.review_queue = input.review_queue;
100
101 const res = await fetch(`${base}/api/v1/proposals`, {
102 method: 'POST',
103 headers: {
104 Accept: 'application/json',
105 'Content-Type': 'application/json',
106 ...opts.headers,
107 },
108 body: JSON.stringify(payload),
109 });
110
111 const text = await res.text();
112 /** @type {Record<string, unknown>} */
113 let json = {};
114 try {
115 json = text ? JSON.parse(text) : {};
116 } catch {
117 json = {};
118 }
119
120 if (!res.ok) {
121 const err = new Error(
122 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
123 );
124 err.status = res.status;
125 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
126 throw err;
127 }
128
129 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
130 if (!proposalId) {
131 const err = new Error('Canister proposal create missing proposal_id');
132 err.status = 502;
133 err.code = 'BAD_GATEWAY';
134 throw err;
135 }
136
137 return { proposal_id: proposalId };
138 }
139
140 /**
141 * @param {import('express').Express} app
142 * @param {{
143 * dataDir: string,
144 * canisterUrl: string,
145 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
146 * requireBridgeAuth: import('express').RequestHandler,
147 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
148 * ok: boolean,
149 * status?: number,
150 * error?: string,
151 * code?: string,
152 * vaultId?: string,
153 * effectiveCanisterUid?: string,
154 * actorUid?: string,
155 * }>,
156 * effectiveRole: (uid: string, storedRoles: Record<string, string>) => string,
157 * loadRoles: (blobStore: unknown) => Promise<Record<string, string>>,
158 * }} deps
159 */
160 export function registerBridgeFlowRunRoutes(app, deps) {
161 const {
162 dataDir,
163 canisterUrl,
164 canisterHeaders,
165 requireBridgeAuth,
166 resolveHostedBridgeContext,
167 effectiveRole,
168 loadRoles,
169 } = deps;
170
171 /**
172 * @param {import('express').Request} req
173 */
174 async function runHandlerContext(req) {
175 const hctx = await resolveHostedBridgeContext(req, req.uid);
176 if (!hctx.ok) return hctx;
177 const roles = await loadRoles(req.blobStore);
178 const role = bridgeFlowRunHandlerRole(effectiveRole(req.uid, roles));
179 return { ok: true, hctx, role };
180 }
181
182 /**
183 * @param {import('express').Request} req
184 * @returns {boolean}
185 */
186 function sessionBoundFromReq(req) {
187 const auth = req.headers.authorization;
188 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
189 const secret = process.env.SESSION_SECRET;
190 if (!token || !secret) return false;
191 const payload = verifyJwtWithSecretRotation(token, secret, process.env.SESSION_SECRET_PREVIOUS);
192 return payload ? isSessionBoundActor(payload) : false;
193 }
194
195 /**
196 * @param {{
197 * effectiveCanisterUid: string,
198 * actorUid: string,
199 * vaultId: string,
200 * }} ctx
201 */
202 function hostedCreateProposal(ctx) {
203 return async function createProposal(_dataDir, input) {
204 return createRunOutcomeProposalOnCanister({
205 canisterUrl,
206 headers: canisterHeaders({
207 'X-User-Id': ctx.effectiveCanisterUid,
208 'X-Actor-Id': ctx.actorUid,
209 'X-Vault-Id': ctx.vaultId,
210 }),
211 input: {
212 ...input,
213 vault_id: ctx.vaultId,
214 proposed_by: ctx.actorUid,
215 },
216 });
217 };
218 }
219
220 /**
221 * @param {import('express').Response} res
222 * @param {unknown} err
223 */
224 function sendRouteError(res, err) {
225 const e =
226 err && typeof err === 'object'
227 ? /** @type {{ status?: number, code?: string, message?: string }} */ (err)
228 : {};
229 const status = typeof e.status === 'number' ? e.status : 500;
230 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
231 const message = typeof e.message === 'string' ? e.message : String(err);
232 return res.status(status).json({ error: message, code });
233 }
234
235 // Static /flow-runs before /flows/:id/runs so "flow-runs" is never a flow id.
236 app.get('/api/v1/flow-runs/:run_id', requireBridgeAuth, async (req, res) => {
237 const ctx = await runHandlerContext(req);
238 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
239
240 const runId =
241 typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
242 try {
243 await hydrateExternalProtocolStoresFromBlob(req.blobStore ?? null, dataDir);
244 const result = handleFlowRunGetRequest({
245 dataDir,
246 vaultId: ctx.hctx.vaultId,
247 userId: req.uid,
248 role: ctx.role,
249 runId,
250 starterDir: BRIDGE_STARTER_FLOWS_DIR,
251 });
252 if (!result.ok) {
253 return res.status(result.status).json({ error: result.error, code: result.code });
254 }
255 return res.json(result.payload);
256 } catch (err) {
257 return sendRouteError(res, err);
258 }
259 });
260
261 app.get('/api/v1/flows/:id/runs', requireBridgeAuth, async (req, res) => {
262 const ctx = await runHandlerContext(req);
263 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
264
265 const flowId =
266 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
267 try {
268 await hydrateExternalProtocolStoresFromBlob(req.blobStore ?? null, dataDir);
269 const result = handleFlowRunListRequest({
270 dataDir,
271 vaultId: ctx.hctx.vaultId,
272 userId: req.uid,
273 role: ctx.role,
274 flowId,
275 starterDir: BRIDGE_STARTER_FLOWS_DIR,
276 });
277 if (!result.ok) {
278 return res.status(result.status).json({ error: result.error, code: result.code });
279 }
280 return res.json(result.payload);
281 } catch (err) {
282 return sendRouteError(res, err);
283 }
284 });
285
286 app.post('/api/v1/flows/:id/runs', requireBridgeAuth, async (req, res) => {
287 const ctx = await runHandlerContext(req);
288 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
289
290 const flowId =
291 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
292 const body = req.body && typeof req.body === 'object' ? req.body : {};
293 try {
294 const result = await withExternalProtocolBlobSync({
295 blobStore: req.blobStore ?? null,
296 dataDir,
297 run: () =>
298 handleFlowRunStartRequest({
299 dataDir,
300 vaultId: ctx.hctx.vaultId,
301 userId: req.uid,
302 role: ctx.role,
303 flowId,
304 flowVersion: body.flow_version,
305 taskRef: body.task_ref,
306 externalRef: body.external_ref,
307 harness: 'hub',
308 starterDir: BRIDGE_STARTER_FLOWS_DIR,
309 }),
310 });
311 if (!result.ok) {
312 return res.status(result.status).json({ error: result.error, code: result.code });
313 }
314 return res.status(201).json(result.payload);
315 } catch (err) {
316 return sendRouteError(res, err);
317 }
318 });
319
320 app.post('/api/v1/flows/:id/runs/:run_id/advance', requireBridgeAuth, async (req, res) => {
321 const ctx = await runHandlerContext(req);
322 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
323
324 const runId =
325 typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
326 const body = req.body && typeof req.body === 'object' ? req.body : {};
327 try {
328 const result = await withExternalProtocolBlobSync({
329 blobStore: req.blobStore ?? null,
330 dataDir,
331 run: () =>
332 handleFlowRunAdvanceRequest({
333 dataDir,
334 vaultId: ctx.hctx.vaultId,
335 userId: req.uid,
336 role: ctx.role,
337 runId,
338 stepId: body.step_id,
339 toStatus: body.to_status,
340 skipReason: body.skip_reason,
341 starterDir: BRIDGE_STARTER_FLOWS_DIR,
342 }),
343 });
344 if (!result.ok) {
345 return res.status(result.status).json({ error: result.error, code: result.code });
346 }
347 return res.json(result.payload);
348 } catch (err) {
349 return sendRouteError(res, err);
350 }
351 });
352
353 app.post('/api/v1/flows/:id/runs/:run_id/evidence', requireBridgeAuth, async (req, res) => {
354 const ctx = await runHandlerContext(req);
355 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
356
357 const runId =
358 typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
359 const body = req.body && typeof req.body === 'object' ? req.body : {};
360 try {
361 const result = await withExternalProtocolBlobSync({
362 blobStore: req.blobStore ?? null,
363 dataDir,
364 run: () =>
365 handleFlowRunEvidenceRequest({
366 dataDir,
367 vaultId: ctx.hctx.vaultId,
368 userId: req.uid,
369 role: ctx.role,
370 runId,
371 stepId: body.step_id,
372 evidenceRef: body.evidence_ref,
373 pointerKind: body.pointer_kind,
374 starterDir: BRIDGE_STARTER_FLOWS_DIR,
375 }),
376 });
377 if (!result.ok) {
378 return res.status(result.status).json({ error: result.error, code: result.code });
379 }
380 return res.json(result.payload);
381 } catch (err) {
382 return sendRouteError(res, err);
383 }
384 });
385
386 app.post(
387 '/api/v1/flows/:id/runs/:run_id/execute-automatable',
388 requireBridgeAuth,
389 async (req, res) => {
390 const ctx = await runHandlerContext(req);
391 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
392
393 const runId =
394 typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
395 const body = req.body && typeof req.body === 'object' ? req.body : {};
396 try {
397 const result = await withExternalProtocolBlobSync({
398 blobStore: req.blobStore ?? null,
399 dataDir,
400 run: () =>
401 handleFlowRunExecuteAutomatableRequest({
402 dataDir,
403 vaultId: ctx.hctx.vaultId,
404 userId: req.uid,
405 role: ctx.role,
406 runId,
407 stepId: body.step_id,
408 consentId: body.consent_id,
409 modelLane: body.model_lane,
410 dryRun: body.dry_run,
411 starterDir: BRIDGE_STARTER_FLOWS_DIR,
412 }),
413 });
414 if (!result.ok) {
415 return res.status(result.status).json({ error: result.error, code: result.code });
416 }
417 return res.json(result.payload);
418 } catch (err) {
419 return sendRouteError(res, err);
420 }
421 },
422 );
423
424 app.post('/api/v1/flows/:id/runs/:run_id/submit-review', requireBridgeAuth, async (req, res) => {
425 const ctx = await runHandlerContext(req);
426 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
427
428 const runId =
429 typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
430 const body = req.body && typeof req.body === 'object' ? req.body : {};
431 try {
432 const result = await withExternalProtocolBlobSync({
433 blobStore: req.blobStore ?? null,
434 dataDir,
435 run: () =>
436 handleFlowRunSubmitReviewRequest({
437 dataDir,
438 vaultId: ctx.hctx.vaultId,
439 userId: req.uid,
440 role: ctx.role,
441 runId,
442 intent: body.intent,
443 starterDir: BRIDGE_STARTER_FLOWS_DIR,
444 createProposal: hostedCreateProposal({
445 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
446 actorUid: req.uid,
447 vaultId: ctx.hctx.vaultId,
448 }),
449 }),
450 });
451 if (!result.ok) {
452 return res.status(result.status).json({ error: result.error, code: result.code });
453 }
454 return res.json(result.payload);
455 } catch (err) {
456 return sendRouteError(res, err);
457 }
458 });
459
460 app.post('/api/v1/flows/:id/runs/:run_id/consent', requireBridgeAuth, async (req, res) => {
461 const ctx = await runHandlerContext(req);
462 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
463
464 const runId =
465 typeof req.params.run_id === 'string' ? decodeURIComponent(req.params.run_id).trim() : '';
466 const body = req.body && typeof req.body === 'object' ? req.body : {};
467 // Consent ledger is process-local this slice; still blob-sync the run store
468 // so unknown_run checks see hosted runs.
469 try {
470 const result = await withExternalProtocolBlobSync({
471 blobStore: req.blobStore ?? null,
472 dataDir,
473 run: () =>
474 handleFlowExecutionConsentMintRequest({
475 dataDir,
476 vaultId: ctx.hctx.vaultId,
477 userId: req.uid,
478 role: ctx.role,
479 runId,
480 allowedLanes: body.allowed_lanes,
481 costCapUnits: body.cost_cap_units,
482 ttlSeconds: body.ttl_seconds,
483 actorLabel: sessionBoundFromReq(req) ? req.uid : undefined,
484 starterDir: BRIDGE_STARTER_FLOWS_DIR,
485 }),
486 });
487 if (!result.ok) {
488 return res.status(result.status).json({ error: result.error, code: result.code });
489 }
490 return res.status(201).json(result.payload);
491 } catch (err) {
492 return sendRouteError(res, err);
493 }
494 });
495 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago