task-routes.mjs
398 lines 14.0 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Hosted bridge REST routes for task read + write propose (Phase 2G hosted parity).
3 *
4 * @see docs/TASK-STORE-CONTRACT-2G.md
5 * @see docs/TASK-WRITE-PROPOSAL-CONTRACT-2G-d.md
6 */
7
8 import { handleTaskListRequest, handleTaskGetRequest } from '../../lib/task/task-handlers.mjs';
9 import {
10 handleTaskProposeRequest,
11 handleTaskLoopProposeRequest,
12 handleTaskInstanceMaterializeRequest,
13 } from '../../lib/task/task-write.mjs';
14 import {
15 handleTaskLoopListRequest,
16 handleTaskLoopGetRequest,
17 } from '../../lib/task/task-loop-handlers.mjs';
18 import { handleLoopPassAuditAppendRequest } from '../../lib/task/loop-pass-audit.mjs';
19 import { createTaskProposalOnCanister, applyApprovedTaskProposalFromCanister } from '../../lib/task/task-hosted-proposal.mjs';
20 import { resolveStarterTasksDir } from '../../lib/task/task-store.mjs';
21 import {
22 resolveStarterTaskLoopsDir,
23 resolveStarterOrchestratorGraphsDir,
24 resolveStarterLoopInstancesDir,
25 } from '../../lib/task/task-loop-store.mjs';
26 import { withLoopPassAuditBlobSync } from './loop-pass-audit-blob-store.mjs';
27 import { persistExternalProtocolStoresToBlob } from './external-agent-blob-store.mjs';
28 import { isSessionBoundActor } from '../gateway/access-token-authz.mjs';
29 import { verifyJwtWithSecretRotation } from '../lib/session-secret-rotation.mjs';
30
31 const BRIDGE_STARTER_TASKS_DIR = resolveStarterTasksDir(import.meta.url);
32 const BRIDGE_STARTER_LOOPS_DIR = resolveStarterTaskLoopsDir(import.meta.url);
33 const BRIDGE_STARTER_GRAPHS_DIR = resolveStarterOrchestratorGraphsDir(import.meta.url);
34 const BRIDGE_STARTER_INSTANCES_DIR = resolveStarterLoopInstancesDir(import.meta.url);
35
36 /**
37 * Map bridge role to task handler role (member → editor, matching self-hosted hub/server.mjs).
38 *
39 * @param {string} role
40 * @returns {string}
41 */
42 export function bridgeTaskHandlerRole(role) {
43 const r = typeof role === 'string' ? role.trim().toLowerCase() : '';
44 return r === 'member' || !r ? 'editor' : r;
45 }
46
47 /**
48 * @param {import('express').Express} app
49 * @param {{
50 * dataDir: string,
51 * canisterUrl: string,
52 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
53 * requireBridgeAuth: import('express').RequestHandler,
54 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
55 * ok: boolean,
56 * status?: number,
57 * error?: string,
58 * code?: string,
59 * vaultId?: string,
60 * effectiveCanisterUid?: string,
61 * actorUid?: string,
62 * }>,
63 * effectiveRole: (uid: string, storedRoles: Record<string, string>) => string,
64 * loadRoles: (blobStore: unknown) => Promise<Record<string, string>>,
65 * }} deps
66 */
67 export function registerBridgeTaskRoutes(app, deps) {
68 const {
69 dataDir,
70 canisterUrl,
71 canisterHeaders,
72 requireBridgeAuth,
73 resolveHostedBridgeContext,
74 effectiveRole,
75 loadRoles,
76 } = deps;
77
78 /**
79 * @param {import('express').Request} req
80 */
81 async function vaultContext(req) {
82 return resolveHostedBridgeContext(req, req.uid);
83 }
84
85 /**
86 * @param {import('express').Request} req
87 */
88 async function taskHandlerContext(req) {
89 const hctx = await vaultContext(req);
90 if (!hctx.ok) return hctx;
91 const roles = await loadRoles(req.blobStore);
92 const role = bridgeTaskHandlerRole(effectiveRole(req.uid, roles));
93 return { ok: true, hctx, role };
94 }
95
96 /**
97 * @param {import('express').Request} req
98 * @returns {boolean}
99 */
100 function sessionBoundFromReq(req) {
101 const auth = req.headers.authorization;
102 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
103 const secret = process.env.SESSION_SECRET;
104 if (!token || !secret) return false;
105 const payload = verifyJwtWithSecretRotation(token, secret, process.env.SESSION_SECRET_PREVIOUS);
106 return payload ? isSessionBoundActor(payload) : false;
107 }
108
109 /**
110 * @param {{
111 * effectiveCanisterUid: string,
112 * actorUid: string,
113 * vaultId: string,
114 * sessionBound?: boolean,
115 * }} ctx
116 */
117 function hostedCreateProposal(ctx) {
118 return async function createProposal(_dataDir, input) {
119 return createTaskProposalOnCanister({
120 canisterUrl,
121 sessionBound: ctx.sessionBound === true,
122 headers: canisterHeaders({
123 'X-User-Id': ctx.effectiveCanisterUid,
124 'X-Actor-Id': ctx.actorUid,
125 'X-Vault-Id': ctx.vaultId,
126 }),
127 input: {
128 ...input,
129 vault_id: ctx.vaultId,
130 proposed_by: ctx.actorUid,
131 },
132 });
133 };
134 }
135
136 /**
137 * @param {import('express').Response} res
138 * @param {unknown} err
139 */
140 function sendRouteError(res, err) {
141 const e = err && typeof err === 'object' ? /** @type {{ status?: number, code?: string, message?: string }} */ (err) : {};
142 const status = typeof e.status === 'number' ? e.status : 500;
143 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
144 const message = typeof e.message === 'string' ? e.message : String(err);
145 return res.status(status).json({ error: message, code });
146 }
147
148 app.get('/api/v1/tasks', requireBridgeAuth, async (req, res) => {
149 const ctx = await taskHandlerContext(req);
150 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
151
152 const limitRaw = req.query.limit;
153 let limit;
154 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
155 limit = parseInt(String(limitRaw), 10);
156 }
157
158 const result = handleTaskListRequest({
159 dataDir,
160 vaultId: ctx.hctx.vaultId,
161 userId: req.uid,
162 role: ctx.role,
163 starterDir: BRIDGE_STARTER_TASKS_DIR,
164 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
165 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
166 status: typeof req.query.status === 'string' ? req.query.status : undefined,
167 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
168 limit,
169 });
170 if (!result.ok) {
171 return res.status(result.status).json({ error: result.error, code: result.code });
172 }
173 return res.json(result.payload);
174 });
175
176 app.get('/api/v1/tasks/:id', requireBridgeAuth, async (req, res) => {
177 const ctx = await taskHandlerContext(req);
178 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
179
180 const taskId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
181 const result = handleTaskGetRequest({
182 dataDir,
183 vaultId: ctx.hctx.vaultId,
184 taskId,
185 userId: req.uid,
186 role: ctx.role,
187 starterDir: BRIDGE_STARTER_TASKS_DIR,
188 });
189 if (!result.ok) {
190 return res.status(result.status).json({ error: result.error, code: result.code });
191 }
192 return res.json(result.payload);
193 });
194
195 app.get('/api/v1/task-loops', requireBridgeAuth, async (req, res) => {
196 const ctx = await taskHandlerContext(req);
197 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
198
199 const limitRaw = req.query.limit;
200 let limit;
201 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
202 limit = parseInt(String(limitRaw), 10);
203 }
204
205 const result = handleTaskLoopListRequest({
206 dataDir,
207 vaultId: ctx.hctx.vaultId,
208 userId: req.uid,
209 role: ctx.role,
210 starterDir: BRIDGE_STARTER_LOOPS_DIR,
211 graphsDir: BRIDGE_STARTER_GRAPHS_DIR,
212 instancesDir: BRIDGE_STARTER_INSTANCES_DIR,
213 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
214 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
215 status: typeof req.query.status === 'string' ? req.query.status : undefined,
216 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
217 limit,
218 });
219 if (!result.ok) {
220 return res.status(result.status).json({ error: result.error, code: result.code });
221 }
222 return res.json(result.payload);
223 });
224
225 app.get('/api/v1/task-loops/:loop_id', requireBridgeAuth, async (req, res) => {
226 const ctx = await taskHandlerContext(req);
227 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
228
229 const loopId =
230 typeof req.params.loop_id === 'string' ? decodeURIComponent(req.params.loop_id).trim() : '';
231 const result = handleTaskLoopGetRequest({
232 dataDir,
233 vaultId: ctx.hctx.vaultId,
234 loopId,
235 userId: req.uid,
236 role: ctx.role,
237 starterDir: BRIDGE_STARTER_LOOPS_DIR,
238 graphsDir: BRIDGE_STARTER_GRAPHS_DIR,
239 instancesDir: BRIDGE_STARTER_INSTANCES_DIR,
240 });
241 if (!result.ok) {
242 return res.status(result.status).json({ error: result.error, code: result.code });
243 }
244 return res.json(result.payload);
245 });
246
247 app.post('/api/v1/loop-pass-audit', requireBridgeAuth, async (req, res) => {
248 const hctx = await vaultContext(req);
249 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
250
251 const body = req.body && typeof req.body === 'object' ? req.body : {};
252 const result = await withLoopPassAuditBlobSync({
253 blobStore: req.blobStore ?? null,
254 dataDir,
255 run: () =>
256 handleLoopPassAuditAppendRequest({
257 dataDir,
258 vaultId: hctx.vaultId,
259 body,
260 }),
261 });
262 if (!result.ok) {
263 return res.status(result.status).json({ error: result.error, code: result.code });
264 }
265 return res.status(result.idempotent ? 200 : 201).json(result.payload);
266 });
267
268 app.post('/api/v1/tasks/proposals', requireBridgeAuth, async (req, res) => {
269 const ctx = await taskHandlerContext(req);
270 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
271
272 const body = req.body && typeof req.body === 'object' ? req.body : {};
273 const proposalKind =
274 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
275 ? body.proposal_kind.trim()
276 : 'task_create';
277 try {
278 const result = await handleTaskProposeRequest({
279 dataDir,
280 vaultId: ctx.hctx.vaultId,
281 userId: req.uid,
282 role: ctx.role,
283 proposalKind,
284 body,
285 intent: body.intent,
286 starterDir: BRIDGE_STARTER_TASKS_DIR,
287 createProposal: hostedCreateProposal({
288 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
289 actorUid: req.uid,
290 vaultId: ctx.hctx.vaultId,
291 sessionBound: sessionBoundFromReq(req),
292 }),
293 });
294 if (!result.ok) {
295 return res.status(result.status).json({ error: result.error, code: result.code });
296 }
297 return res.status(201).json(result.payload);
298 } catch (err) {
299 return sendRouteError(res, err);
300 }
301 });
302
303 app.post('/api/v1/task-loops/proposals', requireBridgeAuth, async (req, res) => {
304 const ctx = await taskHandlerContext(req);
305 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
306
307 const body = req.body && typeof req.body === 'object' ? req.body : {};
308 const proposalKind =
309 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
310 ? body.proposal_kind.trim()
311 : 'task_loop_create';
312 try {
313 const result = await handleTaskLoopProposeRequest({
314 dataDir,
315 vaultId: ctx.hctx.vaultId,
316 userId: req.uid,
317 role: ctx.role,
318 proposalKind,
319 body,
320 intent: body.intent,
321 starterDir: BRIDGE_STARTER_TASKS_DIR,
322 createProposal: hostedCreateProposal({
323 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
324 actorUid: req.uid,
325 vaultId: ctx.hctx.vaultId,
326 sessionBound: sessionBoundFromReq(req),
327 }),
328 });
329 if (!result.ok) {
330 return res.status(result.status).json({ error: result.error, code: result.code });
331 }
332 return res.status(201).json(result.payload);
333 } catch (err) {
334 return sendRouteError(res, err);
335 }
336 });
337
338 app.post('/api/v1/task-loops/:loop_id/instances/proposals', requireBridgeAuth, async (req, res) => {
339 const ctx = await taskHandlerContext(req);
340 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
341
342 const loopId =
343 typeof req.params.loop_id === 'string' ? decodeURIComponent(req.params.loop_id).trim() : '';
344 const body = req.body && typeof req.body === 'object' ? req.body : {};
345 try {
346 const result = await handleTaskInstanceMaterializeRequest({
347 dataDir,
348 vaultId: ctx.hctx.vaultId,
349 userId: req.uid,
350 role: ctx.role,
351 loopId,
352 body: { ...body, loop_id: loopId },
353 intent: body.intent,
354 starterDir: BRIDGE_STARTER_TASKS_DIR,
355 createProposal: hostedCreateProposal({
356 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
357 actorUid: req.uid,
358 vaultId: ctx.hctx.vaultId,
359 sessionBound: sessionBoundFromReq(req),
360 }),
361 });
362 if (!result.ok) {
363 return res.status(result.status).json({ error: result.error, code: result.code });
364 }
365 return res.status(201).json(result.payload);
366 } catch (err) {
367 return sendRouteError(res, err);
368 }
369 });
370
371 app.post('/api/v1/tasks/proposals/:proposal_id/apply-approved', requireBridgeAuth, async (req, res) => {
372 const hctx = await vaultContext(req);
373 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
374
375 const proposalId =
376 typeof req.params.proposal_id === 'string' ? decodeURIComponent(req.params.proposal_id).trim() : '';
377 if (!proposalId) {
378 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
379 }
380
381 const result = await applyApprovedTaskProposalFromCanister({
382 dataDir,
383 canisterUrl,
384 headers: canisterHeaders({
385 'X-User-Id': hctx.effectiveCanisterUid,
386 'X-Actor-Id': req.uid,
387 'X-Vault-Id': hctx.vaultId,
388 }),
389 proposalId,
390 requireApproved: true,
391 });
392 if (!result.ok) {
393 return res.status(result.status).json({ error: result.error, code: result.code });
394 }
395 await persistExternalProtocolStoresToBlob(req.blobStore ?? null, dataDir);
396 return res.json(result.payload);
397 });
398 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago