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