delegation-routes.mjs
375 lines 13.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 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 import { verifyJwtWithSecretRotation, resolveSessionSecretPrevious } from '../lib/session-secret-rotation.mjs';
27 import { isSessionBoundActor } from '../gateway/access-token-authz.mjs';
28
29 /**
30 * @param {import('express').Express} app
31 * @param {{
32 * dataDir: string,
33 * canisterUrl: string,
34 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
35 * requireBridgeAuth: import('express').RequestHandler,
36 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
37 * ok: boolean,
38 * status?: number,
39 * error?: string,
40 * code?: string,
41 * vaultId?: string,
42 * effectiveCanisterUid?: string,
43 * actorUid?: string,
44 * }>,
45 * }} deps
46 */
47 export function registerBridgeDelegationRoutes(app, deps) {
48 const { dataDir, canisterUrl, canisterHeaders, requireBridgeAuth, resolveHostedBridgeContext } = deps;
49 const sessionSecretPrevious = resolveSessionSecretPrevious();
50
51 /**
52 * @param {import('express').Request} req
53 */
54 async function vaultContext(req) {
55 const hctx = await resolveHostedBridgeContext(req, req.uid);
56 return hctx;
57 }
58
59 /**
60 * @param {import('express').Request} req
61 * @returns {boolean}
62 */
63 function sessionBoundFromReq(req) {
64 const auth = req.headers.authorization;
65 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
66 const secret = process.env.SESSION_SECRET;
67 if (!token || !secret) return false;
68 const payload = verifyJwtWithSecretRotation(token, secret, sessionSecretPrevious);
69 return payload ? isSessionBoundActor(payload) : false;
70 }
71
72 /**
73 * @param {{
74 * effectiveCanisterUid: string,
75 * actorUid: string,
76 * vaultId: string,
77 * sessionBound?: boolean,
78 * }} ctx
79 */
80 function hostedCreateProposal(ctx) {
81 return async function createProposal(_dataDir, input) {
82 return createDelegationProposalOnCanister({
83 canisterUrl,
84 dataDir,
85 sessionBound: ctx.sessionBound === true,
86 headers: canisterHeaders({
87 'X-User-Id': ctx.effectiveCanisterUid,
88 'X-Actor-Id': ctx.actorUid,
89 'X-Vault-Id': ctx.vaultId,
90 }),
91 input: {
92 ...input,
93 vault_id: ctx.vaultId,
94 proposed_by: ctx.actorUid,
95 },
96 });
97 };
98 }
99
100 /**
101 * @param {import('express').Request} req
102 */
103 function blobStoreFromReq(req) {
104 return /** @type {{ blobStore?: import('./delegation-blob-store.mjs').BlobStore | null }} */ (req).blobStore ?? null;
105 }
106
107 /**
108 * @param {import('express').Response} res
109 * @param {unknown} err
110 */
111 function sendRouteError(res, err) {
112 const e = err && typeof err === 'object' ? /** @type {{ status?: number, code?: string, message?: string }} */ (err) : {};
113 const status = typeof e.status === 'number' ? e.status : 500;
114 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
115 const message = typeof e.message === 'string' ? e.message : String(err);
116 console.error('[bridge] delegation route error', { status, code, message });
117 return res.status(status).json({ error: message, code });
118 }
119
120 app.post('/api/v1/agents/identities', requireBridgeAuth, async (req, res) => {
121 const hctx = await vaultContext(req);
122 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
123 const body = req.body && typeof req.body === 'object' ? req.body : {};
124 try {
125 const result = await withDelegationBlobSync({
126 blobStore: blobStoreFromReq(req),
127 dataDir,
128 run: () =>
129 handleAgentIdentityRegisterProposeRequest({
130 dataDir,
131 vaultId: hctx.vaultId,
132 userId: req.uid,
133 kind: body.kind,
134 agentId: body.agent_id,
135 label: body.label,
136 scopeCeiling: body.scope_ceiling,
137 createProposal: hostedCreateProposal({
138 effectiveCanisterUid: hctx.effectiveCanisterUid,
139 actorUid: req.uid,
140 vaultId: hctx.vaultId,
141 sessionBound: sessionBoundFromReq(req),
142 }),
143 }),
144 });
145 if (!result.ok) {
146 console.error('[bridge] POST /api/v1/agents/identities/propose', {
147 status: result.status,
148 code: result.code,
149 error: result.error,
150 });
151 return res.status(result.status).json({ error: result.error, code: result.code });
152 }
153 return res.status(201).json(result.payload);
154 } catch (err) {
155 return sendRouteError(res, err);
156 }
157 });
158
159 app.get('/api/v1/agents/identities', requireBridgeAuth, async (req, res) => {
160 const hctx = await vaultContext(req);
161 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
162 await hydrateDelegationStoresFromBlob(blobStoreFromReq(req), dataDir);
163 const result = handleAgentIdentityListRequest({
164 dataDir,
165 vaultId: hctx.vaultId,
166 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
167 status: typeof req.query.status === 'string' ? req.query.status : undefined,
168 });
169 if (!result.ok) {
170 return res.status(result.status).json({ error: result.error, code: result.code });
171 }
172 return res.json(result.payload);
173 });
174
175 app.post('/api/v1/delegation/consents', requireBridgeAuth, async (req, res) => {
176 const hctx = await vaultContext(req);
177 if (!hctx.ok) {
178 console.error('[bridge] POST /api/v1/delegation/consents vault context denied', {
179 status: hctx.status,
180 code: hctx.code,
181 error: hctx.error,
182 actorUid: req.uid,
183 vaultId: req.headers['x-vault-id'],
184 });
185 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
186 }
187 const body = req.body && typeof req.body === 'object' ? req.body : {};
188 try {
189 const result = await withDelegationBlobSync({
190 blobStore: blobStoreFromReq(req),
191 dataDir,
192 run: () =>
193 handleDelegationConsentProposeRequest({
194 dataDir,
195 vaultId: hctx.vaultId,
196 userId: req.uid,
197 delegateAgentId: body.delegate_agent_id,
198 scope: body.scope,
199 workspaceId: body.workspace_id,
200 allowedFlowIds: body.allowed_flow_ids,
201 allowedTaskKinds: body.allowed_task_kinds,
202 allowedTaskIds: body.allowed_task_ids,
203 expiresAt: body.expires_at,
204 createProposal: hostedCreateProposal({
205 effectiveCanisterUid: hctx.effectiveCanisterUid,
206 actorUid: req.uid,
207 vaultId: hctx.vaultId,
208 sessionBound: sessionBoundFromReq(req),
209 }),
210 }),
211 });
212 if (!result.ok) {
213 console.error('[bridge] POST /api/v1/delegation/consents', {
214 status: result.status,
215 code: result.code,
216 error: result.error,
217 vaultId: hctx.vaultId,
218 actorUid: req.uid,
219 });
220 return res.status(result.status).json({ error: result.error, code: result.code });
221 }
222 return res.status(201).json(result.payload);
223 } catch (err) {
224 return sendRouteError(res, err);
225 }
226 });
227
228 app.post('/api/v1/delegation/proposals/:proposal_id/apply-approved', requireBridgeAuth, async (req, res) => {
229 const hctx = await vaultContext(req);
230 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
231 const proposalId =
232 typeof req.params.proposal_id === 'string' ? decodeURIComponent(req.params.proposal_id).trim() : '';
233 if (!proposalId) {
234 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
235 }
236 const result = await withDelegationBlobSync({
237 blobStore: blobStoreFromReq(req),
238 dataDir,
239 run: () =>
240 applyApprovedDelegationProposalFromCanister({
241 dataDir,
242 canisterUrl,
243 headers: canisterHeaders({
244 'X-User-Id': hctx.effectiveCanisterUid,
245 'X-Actor-Id': req.uid,
246 'X-Vault-Id': hctx.vaultId,
247 }),
248 proposalId,
249 requireApproved: true,
250 }),
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 });
257
258 app.delete('/api/v1/delegation/consents/:consent_id', requireBridgeAuth, async (req, res) => {
259 const hctx = await vaultContext(req);
260 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
261 const consentId =
262 typeof req.params.consent_id === 'string' ? decodeURIComponent(req.params.consent_id).trim() : '';
263 const result = await withDelegationBlobSync({
264 blobStore: blobStoreFromReq(req),
265 dataDir,
266 run: () =>
267 handleDelegationConsentRevokeRequest({
268 dataDir,
269 vaultId: hctx.vaultId,
270 consentId,
271 userId: req.uid,
272 }),
273 });
274 if (!result.ok) {
275 return res.status(result.status).json({ error: result.error, code: result.code });
276 }
277 return res.json(result.payload);
278 });
279
280 app.post('/api/v1/delegation/grants', requireBridgeAuth, async (req, res) => {
281 const hctx = await vaultContext(req);
282 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
283 const body = req.body && typeof req.body === 'object' ? req.body : {};
284 const result = await withDelegationBlobSync({
285 blobStore: blobStoreFromReq(req),
286 dataDir,
287 run: () =>
288 handleDelegationGrantMintRequest({
289 dataDir,
290 vaultId: hctx.vaultId,
291 consentId: body.consent_id,
292 actorAgentId: body.actor_agent_id,
293 taskRef: body.task_ref,
294 runRef: body.run_ref,
295 flowId: body.flow_id,
296 flowVersion: body.flow_version,
297 ttlSeconds: body.ttl_seconds,
298 }),
299 });
300 if (!result.ok) {
301 return res.status(result.status).json({ error: result.error, code: result.code });
302 }
303 return res.status(201).json(result.payload);
304 });
305
306 app.get('/api/v1/delegation/grants', requireBridgeAuth, async (req, res) => {
307 const hctx = await vaultContext(req);
308 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
309 await hydrateDelegationStoresFromBlob(blobStoreFromReq(req), dataDir);
310 const result = handleDelegationGrantListRequest({
311 dataDir,
312 vaultId: hctx.vaultId,
313 actorAgentId: typeof req.query.actor_agent_id === 'string' ? req.query.actor_agent_id : undefined,
314 });
315 if (!result.ok) {
316 return res.status(result.status).json({ error: result.error, code: result.code });
317 }
318 return res.json(result.payload);
319 });
320
321 app.delete('/api/v1/delegation/grants/:grant_id', requireBridgeAuth, async (req, res) => {
322 const hctx = await vaultContext(req);
323 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
324 const grantId =
325 typeof req.params.grant_id === 'string' ? decodeURIComponent(req.params.grant_id).trim() : '';
326 const result = await withDelegationBlobSync({
327 blobStore: blobStoreFromReq(req),
328 dataDir,
329 run: () =>
330 handleDelegationGrantRevokeRequest({
331 dataDir,
332 vaultId: hctx.vaultId,
333 grantId,
334 }),
335 });
336 if (!result.ok) {
337 return res.status(result.status).json({ error: result.error, code: result.code });
338 }
339 return res.json(result.payload);
340 });
341
342 app.post('/api/v1/delegation/audit', requireBridgeAuth, async (req, res) => {
343 const hctx = await vaultContext(req);
344 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
345 const body = req.body && typeof req.body === 'object' ? req.body : {};
346 const principalRef =
347 typeof body.principal_ref === 'string' && body.principal_ref.trim()
348 ? body.principal_ref.trim()
349 : hashPrincipalRef(req.uid);
350 const result = await withDelegationBlobSync({
351 blobStore: blobStoreFromReq(req),
352 dataDir,
353 run: () =>
354 handleDelegationAuditAppendRequest({
355 dataDir,
356 vaultId: hctx.vaultId,
357 grantId: body.grant_id,
358 actorAgentId: body.actor_agent_id,
359 principalRef,
360 action: body.action,
361 evidenceRefs: body.evidence_refs,
362 taskRef: body.task_ref,
363 runRef: body.run_ref,
364 flowId: body.flow_id,
365 flowVersion: body.flow_version,
366 stepId: body.step_id,
367 executionLocation: body.execution_location,
368 }),
369 });
370 if (!result.ok) {
371 return res.status(result.status).json({ error: result.error, code: result.code });
372 }
373 return res.status(201).json(result.payload);
374 });
375 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago