task-routes.mjs
402 lines 14.0 KB
Raw
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 44 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 jwt from 'jsonwebtoken';
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 try {
102 const auth = req.headers.authorization;
103 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
104 const secret = process.env.SESSION_SECRET;
105 if (!token || !secret) return false;
106 const payload = jwt.verify(token, secret);
107 return isSessionBoundActor(payload);
108 } catch {
109 return false;
110 }
111 }
112
113 /**
114 * @param {{
115 * effectiveCanisterUid: string,
116 * actorUid: string,
117 * vaultId: string,
118 * sessionBound?: boolean,
119 * }} ctx
120 */
121 function hostedCreateProposal(ctx) {
122 return async function createProposal(_dataDir, input) {
123 return createTaskProposalOnCanister({
124 canisterUrl,
125 sessionBound: ctx.sessionBound === true,
126 headers: canisterHeaders({
127 'X-User-Id': ctx.effectiveCanisterUid,
128 'X-Actor-Id': ctx.actorUid,
129 'X-Vault-Id': ctx.vaultId,
130 }),
131 input: {
132 ...input,
133 vault_id: ctx.vaultId,
134 proposed_by: ctx.actorUid,
135 },
136 });
137 };
138 }
139
140 /**
141 * @param {import('express').Response} res
142 * @param {unknown} err
143 */
144 function sendRouteError(res, err) {
145 const e = err && typeof err === 'object' ? /** @type {{ status?: number, code?: string, message?: string }} */ (err) : {};
146 const status = typeof e.status === 'number' ? e.status : 500;
147 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
148 const message = typeof e.message === 'string' ? e.message : String(err);
149 return res.status(status).json({ error: message, code });
150 }
151
152 app.get('/api/v1/tasks', requireBridgeAuth, async (req, res) => {
153 const ctx = await taskHandlerContext(req);
154 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
155
156 const limitRaw = req.query.limit;
157 let limit;
158 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
159 limit = parseInt(String(limitRaw), 10);
160 }
161
162 const result = handleTaskListRequest({
163 dataDir,
164 vaultId: ctx.hctx.vaultId,
165 userId: req.uid,
166 role: ctx.role,
167 starterDir: BRIDGE_STARTER_TASKS_DIR,
168 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
169 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
170 status: typeof req.query.status === 'string' ? req.query.status : undefined,
171 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
172 limit,
173 });
174 if (!result.ok) {
175 return res.status(result.status).json({ error: result.error, code: result.code });
176 }
177 return res.json(result.payload);
178 });
179
180 app.get('/api/v1/tasks/:id', requireBridgeAuth, async (req, res) => {
181 const ctx = await taskHandlerContext(req);
182 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
183
184 const taskId = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
185 const result = handleTaskGetRequest({
186 dataDir,
187 vaultId: ctx.hctx.vaultId,
188 taskId,
189 userId: req.uid,
190 role: ctx.role,
191 starterDir: BRIDGE_STARTER_TASKS_DIR,
192 });
193 if (!result.ok) {
194 return res.status(result.status).json({ error: result.error, code: result.code });
195 }
196 return res.json(result.payload);
197 });
198
199 app.get('/api/v1/task-loops', requireBridgeAuth, async (req, res) => {
200 const ctx = await taskHandlerContext(req);
201 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
202
203 const limitRaw = req.query.limit;
204 let limit;
205 if (limitRaw !== undefined && limitRaw !== null && String(limitRaw).trim() !== '') {
206 limit = parseInt(String(limitRaw), 10);
207 }
208
209 const result = handleTaskLoopListRequest({
210 dataDir,
211 vaultId: ctx.hctx.vaultId,
212 userId: req.uid,
213 role: ctx.role,
214 starterDir: BRIDGE_STARTER_LOOPS_DIR,
215 graphsDir: BRIDGE_STARTER_GRAPHS_DIR,
216 instancesDir: BRIDGE_STARTER_INSTANCES_DIR,
217 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
218 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
219 status: typeof req.query.status === 'string' ? req.query.status : undefined,
220 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
221 limit,
222 });
223 if (!result.ok) {
224 return res.status(result.status).json({ error: result.error, code: result.code });
225 }
226 return res.json(result.payload);
227 });
228
229 app.get('/api/v1/task-loops/:loop_id', requireBridgeAuth, async (req, res) => {
230 const ctx = await taskHandlerContext(req);
231 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
232
233 const loopId =
234 typeof req.params.loop_id === 'string' ? decodeURIComponent(req.params.loop_id).trim() : '';
235 const result = handleTaskLoopGetRequest({
236 dataDir,
237 vaultId: ctx.hctx.vaultId,
238 loopId,
239 userId: req.uid,
240 role: ctx.role,
241 starterDir: BRIDGE_STARTER_LOOPS_DIR,
242 graphsDir: BRIDGE_STARTER_GRAPHS_DIR,
243 instancesDir: BRIDGE_STARTER_INSTANCES_DIR,
244 });
245 if (!result.ok) {
246 return res.status(result.status).json({ error: result.error, code: result.code });
247 }
248 return res.json(result.payload);
249 });
250
251 app.post('/api/v1/loop-pass-audit', requireBridgeAuth, async (req, res) => {
252 const hctx = await vaultContext(req);
253 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
254
255 const body = req.body && typeof req.body === 'object' ? req.body : {};
256 const result = await withLoopPassAuditBlobSync({
257 blobStore: req.blobStore ?? null,
258 dataDir,
259 run: () =>
260 handleLoopPassAuditAppendRequest({
261 dataDir,
262 vaultId: hctx.vaultId,
263 body,
264 }),
265 });
266 if (!result.ok) {
267 return res.status(result.status).json({ error: result.error, code: result.code });
268 }
269 return res.status(result.idempotent ? 200 : 201).json(result.payload);
270 });
271
272 app.post('/api/v1/tasks/proposals', requireBridgeAuth, async (req, res) => {
273 const ctx = await taskHandlerContext(req);
274 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
275
276 const body = req.body && typeof req.body === 'object' ? req.body : {};
277 const proposalKind =
278 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
279 ? body.proposal_kind.trim()
280 : 'task_create';
281 try {
282 const result = await handleTaskProposeRequest({
283 dataDir,
284 vaultId: ctx.hctx.vaultId,
285 userId: req.uid,
286 role: ctx.role,
287 proposalKind,
288 body,
289 intent: body.intent,
290 starterDir: BRIDGE_STARTER_TASKS_DIR,
291 createProposal: hostedCreateProposal({
292 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
293 actorUid: req.uid,
294 vaultId: ctx.hctx.vaultId,
295 sessionBound: sessionBoundFromReq(req),
296 }),
297 });
298 if (!result.ok) {
299 return res.status(result.status).json({ error: result.error, code: result.code });
300 }
301 return res.status(201).json(result.payload);
302 } catch (err) {
303 return sendRouteError(res, err);
304 }
305 });
306
307 app.post('/api/v1/task-loops/proposals', requireBridgeAuth, async (req, res) => {
308 const ctx = await taskHandlerContext(req);
309 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
310
311 const body = req.body && typeof req.body === 'object' ? req.body : {};
312 const proposalKind =
313 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
314 ? body.proposal_kind.trim()
315 : 'task_loop_create';
316 try {
317 const result = await handleTaskLoopProposeRequest({
318 dataDir,
319 vaultId: ctx.hctx.vaultId,
320 userId: req.uid,
321 role: ctx.role,
322 proposalKind,
323 body,
324 intent: body.intent,
325 starterDir: BRIDGE_STARTER_TASKS_DIR,
326 createProposal: hostedCreateProposal({
327 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
328 actorUid: req.uid,
329 vaultId: ctx.hctx.vaultId,
330 sessionBound: sessionBoundFromReq(req),
331 }),
332 });
333 if (!result.ok) {
334 return res.status(result.status).json({ error: result.error, code: result.code });
335 }
336 return res.status(201).json(result.payload);
337 } catch (err) {
338 return sendRouteError(res, err);
339 }
340 });
341
342 app.post('/api/v1/task-loops/:loop_id/instances/proposals', requireBridgeAuth, async (req, res) => {
343 const ctx = await taskHandlerContext(req);
344 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
345
346 const loopId =
347 typeof req.params.loop_id === 'string' ? decodeURIComponent(req.params.loop_id).trim() : '';
348 const body = req.body && typeof req.body === 'object' ? req.body : {};
349 try {
350 const result = await handleTaskInstanceMaterializeRequest({
351 dataDir,
352 vaultId: ctx.hctx.vaultId,
353 userId: req.uid,
354 role: ctx.role,
355 loopId,
356 body: { ...body, loop_id: loopId },
357 intent: body.intent,
358 starterDir: BRIDGE_STARTER_TASKS_DIR,
359 createProposal: hostedCreateProposal({
360 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
361 actorUid: req.uid,
362 vaultId: ctx.hctx.vaultId,
363 sessionBound: sessionBoundFromReq(req),
364 }),
365 });
366 if (!result.ok) {
367 return res.status(result.status).json({ error: result.error, code: result.code });
368 }
369 return res.status(201).json(result.payload);
370 } catch (err) {
371 return sendRouteError(res, err);
372 }
373 });
374
375 app.post('/api/v1/tasks/proposals/:proposal_id/apply-approved', requireBridgeAuth, async (req, res) => {
376 const hctx = await vaultContext(req);
377 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
378
379 const proposalId =
380 typeof req.params.proposal_id === 'string' ? decodeURIComponent(req.params.proposal_id).trim() : '';
381 if (!proposalId) {
382 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
383 }
384
385 const result = await applyApprovedTaskProposalFromCanister({
386 dataDir,
387 canisterUrl,
388 headers: canisterHeaders({
389 'X-User-Id': hctx.effectiveCanisterUid,
390 'X-Actor-Id': req.uid,
391 'X-Vault-Id': hctx.vaultId,
392 }),
393 proposalId,
394 requireApproved: true,
395 });
396 if (!result.ok) {
397 return res.status(result.status).json({ error: result.error, code: result.code });
398 }
399 await persistExternalProtocolStoresToBlob(req.blobStore ?? null, dataDir);
400 return res.json(result.payload);
401 });
402 }
File History 3 commits
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 44 days ago
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 60 days ago
sha256:873e30b7fafe601346295f8f4289f388f21d8f715f28584d5481899ba2b714fc Merge pull request #249 from aaronrene/muse-mirror Agent 78 days ago