delegation-routes.mjs
328 lines 11.7 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 agent delegation (Phase 7C-L1 hosted parity).
3 *
4 * L1b: identity/consent proposals POST to the canister (Hub-visible); approve apply
5 * runs via POST …/delegation/proposals/:id/apply-approved (gateway hook after approve).
6 *
7 * @see docs/AGENT-DELEGATION-V0-SPEC.md §4
8 */
9
10 import {
11 handleAgentIdentityRegisterProposeRequest,
12 handleAgentIdentityListRequest,
13 handleDelegationConsentProposeRequest,
14 handleDelegationConsentRevokeRequest,
15 handleDelegationGrantMintRequest,
16 handleDelegationGrantListRequest,
17 handleDelegationGrantRevokeRequest,
18 handleDelegationAuditAppendRequest,
19 hashPrincipalRef,
20 } from '../../lib/agent/delegation.mjs';
21 import { createDelegationProposalOnCanister, applyApprovedDelegationProposalFromCanister } from '../../lib/agent/delegation-hosted-proposal.mjs';
22 import {
23 hydrateDelegationStoresFromBlob,
24 withDelegationBlobSync,
25 } from './delegation-blob-store.mjs';
26
27 /**
28 * @param {import('express').Express} app
29 * @param {{
30 * dataDir: string,
31 * canisterUrl: string,
32 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
33 * requireBridgeAuth: import('express').RequestHandler,
34 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
35 * ok: boolean,
36 * status?: number,
37 * error?: string,
38 * code?: string,
39 * vaultId?: string,
40 * effectiveCanisterUid?: string,
41 * actorUid?: string,
42 * }>,
43 * }} deps
44 */
45 export function registerBridgeDelegationRoutes(app, deps) {
46 const { dataDir, canisterUrl, canisterHeaders, requireBridgeAuth, resolveHostedBridgeContext } = deps;
47
48 /**
49 * @param {import('express').Request} req
50 */
51 async function vaultContext(req) {
52 const hctx = await resolveHostedBridgeContext(req, req.uid);
53 return hctx;
54 }
55
56 /**
57 * @param {{
58 * effectiveCanisterUid: string,
59 * actorUid: string,
60 * vaultId: string,
61 * }} ctx
62 */
63 function hostedCreateProposal(ctx) {
64 return async function createProposal(_dataDir, input) {
65 return createDelegationProposalOnCanister({
66 canisterUrl,
67 headers: canisterHeaders({
68 'X-User-Id': ctx.effectiveCanisterUid,
69 'X-Actor-Id': ctx.actorUid,
70 'X-Vault-Id': ctx.vaultId,
71 }),
72 input,
73 });
74 };
75 }
76
77 /**
78 * @param {import('express').Request} req
79 */
80 function blobStoreFromReq(req) {
81 return /** @type {{ blobStore?: import('./delegation-blob-store.mjs').BlobStore | null }} */ (req).blobStore ?? null;
82 }
83
84 /**
85 * @param {import('express').Response} res
86 * @param {unknown} err
87 */
88 function sendRouteError(res, err) {
89 const e = err && typeof err === 'object' ? /** @type {{ status?: number, code?: string, message?: string }} */ (err) : {};
90 const status = typeof e.status === 'number' ? e.status : 500;
91 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
92 const message = typeof e.message === 'string' ? e.message : String(err);
93 return res.status(status).json({ error: message, code });
94 }
95
96 app.post('/api/v1/agents/identities', requireBridgeAuth, async (req, res) => {
97 const hctx = await vaultContext(req);
98 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
99 const body = req.body && typeof req.body === 'object' ? req.body : {};
100 try {
101 const result = await withDelegationBlobSync({
102 blobStore: blobStoreFromReq(req),
103 dataDir,
104 run: () =>
105 handleAgentIdentityRegisterProposeRequest({
106 dataDir,
107 vaultId: hctx.vaultId,
108 userId: req.uid,
109 kind: body.kind,
110 agentId: body.agent_id,
111 label: body.label,
112 scopeCeiling: body.scope_ceiling,
113 createProposal: hostedCreateProposal({
114 effectiveCanisterUid: hctx.effectiveCanisterUid,
115 actorUid: req.uid,
116 vaultId: hctx.vaultId,
117 }),
118 }),
119 });
120 if (!result.ok) {
121 return res.status(result.status).json({ error: result.error, code: result.code });
122 }
123 return res.status(201).json(result.payload);
124 } catch (err) {
125 return sendRouteError(res, err);
126 }
127 });
128
129 app.get('/api/v1/agents/identities', requireBridgeAuth, async (req, res) => {
130 const hctx = await vaultContext(req);
131 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
132 await hydrateDelegationStoresFromBlob(blobStoreFromReq(req), dataDir);
133 const result = handleAgentIdentityListRequest({
134 dataDir,
135 vaultId: hctx.vaultId,
136 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
137 status: typeof req.query.status === 'string' ? req.query.status : undefined,
138 });
139 if (!result.ok) {
140 return res.status(result.status).json({ error: result.error, code: result.code });
141 }
142 return res.json(result.payload);
143 });
144
145 app.post('/api/v1/delegation/consents', requireBridgeAuth, async (req, res) => {
146 const hctx = await vaultContext(req);
147 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
148 const body = req.body && typeof req.body === 'object' ? req.body : {};
149 try {
150 const result = await withDelegationBlobSync({
151 blobStore: blobStoreFromReq(req),
152 dataDir,
153 run: () =>
154 handleDelegationConsentProposeRequest({
155 dataDir,
156 vaultId: hctx.vaultId,
157 userId: req.uid,
158 delegateAgentId: body.delegate_agent_id,
159 scope: body.scope,
160 workspaceId: body.workspace_id,
161 allowedFlowIds: body.allowed_flow_ids,
162 allowedTaskKinds: body.allowed_task_kinds,
163 allowedTaskIds: body.allowed_task_ids,
164 expiresAt: body.expires_at,
165 createProposal: hostedCreateProposal({
166 effectiveCanisterUid: hctx.effectiveCanisterUid,
167 actorUid: req.uid,
168 vaultId: hctx.vaultId,
169 }),
170 }),
171 });
172 if (!result.ok) {
173 return res.status(result.status).json({ error: result.error, code: result.code });
174 }
175 return res.status(201).json(result.payload);
176 } catch (err) {
177 return sendRouteError(res, err);
178 }
179 });
180
181 app.post('/api/v1/delegation/proposals/:proposal_id/apply-approved', requireBridgeAuth, async (req, res) => {
182 const hctx = await vaultContext(req);
183 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
184 const proposalId =
185 typeof req.params.proposal_id === 'string' ? decodeURIComponent(req.params.proposal_id).trim() : '';
186 if (!proposalId) {
187 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
188 }
189 const result = await withDelegationBlobSync({
190 blobStore: blobStoreFromReq(req),
191 dataDir,
192 run: () =>
193 applyApprovedDelegationProposalFromCanister({
194 dataDir,
195 canisterUrl,
196 headers: canisterHeaders({
197 'X-User-Id': hctx.effectiveCanisterUid,
198 'X-Actor-Id': req.uid,
199 'X-Vault-Id': hctx.vaultId,
200 }),
201 proposalId,
202 requireApproved: true,
203 }),
204 });
205 if (!result.ok) {
206 return res.status(result.status).json({ error: result.error, code: result.code });
207 }
208 return res.json(result.payload);
209 });
210
211 app.delete('/api/v1/delegation/consents/:consent_id', requireBridgeAuth, async (req, res) => {
212 const hctx = await vaultContext(req);
213 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
214 const consentId =
215 typeof req.params.consent_id === 'string' ? decodeURIComponent(req.params.consent_id).trim() : '';
216 const result = await withDelegationBlobSync({
217 blobStore: blobStoreFromReq(req),
218 dataDir,
219 run: () =>
220 handleDelegationConsentRevokeRequest({
221 dataDir,
222 vaultId: hctx.vaultId,
223 consentId,
224 userId: req.uid,
225 }),
226 });
227 if (!result.ok) {
228 return res.status(result.status).json({ error: result.error, code: result.code });
229 }
230 return res.json(result.payload);
231 });
232
233 app.post('/api/v1/delegation/grants', requireBridgeAuth, async (req, res) => {
234 const hctx = await vaultContext(req);
235 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
236 const body = req.body && typeof req.body === 'object' ? req.body : {};
237 const result = await withDelegationBlobSync({
238 blobStore: blobStoreFromReq(req),
239 dataDir,
240 run: () =>
241 handleDelegationGrantMintRequest({
242 dataDir,
243 vaultId: hctx.vaultId,
244 consentId: body.consent_id,
245 actorAgentId: body.actor_agent_id,
246 taskRef: body.task_ref,
247 runRef: body.run_ref,
248 flowId: body.flow_id,
249 flowVersion: body.flow_version,
250 ttlSeconds: body.ttl_seconds,
251 }),
252 });
253 if (!result.ok) {
254 return res.status(result.status).json({ error: result.error, code: result.code });
255 }
256 return res.status(201).json(result.payload);
257 });
258
259 app.get('/api/v1/delegation/grants', requireBridgeAuth, async (req, res) => {
260 const hctx = await vaultContext(req);
261 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
262 await hydrateDelegationStoresFromBlob(blobStoreFromReq(req), dataDir);
263 const result = handleDelegationGrantListRequest({
264 dataDir,
265 vaultId: hctx.vaultId,
266 actorAgentId: typeof req.query.actor_agent_id === 'string' ? req.query.actor_agent_id : undefined,
267 });
268 if (!result.ok) {
269 return res.status(result.status).json({ error: result.error, code: result.code });
270 }
271 return res.json(result.payload);
272 });
273
274 app.delete('/api/v1/delegation/grants/:grant_id', requireBridgeAuth, async (req, res) => {
275 const hctx = await vaultContext(req);
276 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
277 const grantId =
278 typeof req.params.grant_id === 'string' ? decodeURIComponent(req.params.grant_id).trim() : '';
279 const result = await withDelegationBlobSync({
280 blobStore: blobStoreFromReq(req),
281 dataDir,
282 run: () =>
283 handleDelegationGrantRevokeRequest({
284 dataDir,
285 vaultId: hctx.vaultId,
286 grantId,
287 }),
288 });
289 if (!result.ok) {
290 return res.status(result.status).json({ error: result.error, code: result.code });
291 }
292 return res.json(result.payload);
293 });
294
295 app.post('/api/v1/delegation/audit', requireBridgeAuth, async (req, res) => {
296 const hctx = await vaultContext(req);
297 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
298 const body = req.body && typeof req.body === 'object' ? req.body : {};
299 const principalRef =
300 typeof body.principal_ref === 'string' && body.principal_ref.trim()
301 ? body.principal_ref.trim()
302 : hashPrincipalRef(req.uid);
303 const result = await withDelegationBlobSync({
304 blobStore: blobStoreFromReq(req),
305 dataDir,
306 run: () =>
307 handleDelegationAuditAppendRequest({
308 dataDir,
309 vaultId: hctx.vaultId,
310 grantId: body.grant_id,
311 actorAgentId: body.actor_agent_id,
312 principalRef,
313 action: body.action,
314 evidenceRefs: body.evidence_refs,
315 taskRef: body.task_ref,
316 runRef: body.run_ref,
317 flowId: body.flow_id,
318 flowVersion: body.flow_version,
319 stepId: body.step_id,
320 executionLocation: body.execution_location,
321 }),
322 });
323 if (!result.ok) {
324 return res.status(result.status).json({ error: result.error, code: result.code });
325 }
326 return res.status(201).json(result.payload);
327 });
328 }
File History 2 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 30 days ago