delegation-routes.mjs
597 lines 21.3 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 3 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 createDelegationAuthorityStore,
24 DELEGATION_ERROR_SCHEMA,
25 DELEGATION_REQUEST_INVALID,
26 DELEGATION_SESSION_REQUIRED,
27 DELEGATION_HELPER_ACTOR_DENIED,
28 DELEGATION_AUTHORITY_CONFLICT,
29 DELEGATION_AUTHORITY_UNAVAILABLE,
30 RETAIL_ACTOR_ID,
31 } from '../../lib/agent/delegation-authority-store.mjs';
32 import {
33 hydrateDelegationStoresFromBlob,
34 withDelegationBlobSync,
35 } from './delegation-blob-store.mjs';
36 import { verifyJwtWithSecretRotation, resolveSessionSecretPrevious } from '../lib/session-secret-rotation.mjs';
37 import { isSessionBoundActor, resolveActorTokenClass } from '../gateway/access-token-authz.mjs';
38
39 /**
40 * @param {import('express').Express} app
41 * @param {{
42 * dataDir: string,
43 * canisterUrl: string,
44 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
45 * requireBridgeAuth: import('express').RequestHandler,
46 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
47 * ok: boolean,
48 * status?: number,
49 * error?: string,
50 * code?: string,
51 * vaultId?: string,
52 * effectiveCanisterUid?: string,
53 * actorUid?: string,
54 * }>,
55 * }} deps
56 */
57 export function registerBridgeDelegationRoutes(app, deps) {
58 const { dataDir, canisterUrl, canisterHeaders, requireBridgeAuth, resolveHostedBridgeContext } = deps;
59 const sessionSecretPrevious = resolveSessionSecretPrevious();
60
61 /**
62 * @param {import('express').Request} req
63 */
64 async function vaultContext(req) {
65 const hctx = await resolveHostedBridgeContext(req, req.uid);
66 return hctx;
67 }
68
69 /**
70 * RHF-b-KN0 — generic Bridge grant mint rejects human session tokens before catalog/store work.
71 *
72 * @param {import('express').Request} req
73 * @returns {boolean}
74 */
75 function humanSessionTokenFromReq(req) {
76 const auth = req.headers.authorization;
77 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
78 const secret = process.env.SESSION_SECRET;
79 if (!token || !secret) return false;
80 const payload = verifyJwtWithSecretRotation(token, secret, sessionSecretPrevious);
81 if (!payload) return false;
82 const tokenClass = resolveActorTokenClass(payload);
83 return tokenClass === 'session' || tokenClass === 'legacy_session';
84 }
85
86 /**
87 * RHF-b-KN1 — renew-personal / helper-access / validate accept only type:session.
88 *
89 * @param {import('express').Request} req
90 * @returns {{ ok: true, payload: object } | { ok: false, status: number, code: string, error: string }}
91 */
92 function requireStrictSessionToken(req) {
93 const auth = req.headers.authorization;
94 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
95 const secret = process.env.SESSION_SECRET;
96 if (!token || !secret) {
97 return { ok: false, status: 401, code: DELEGATION_SESSION_REQUIRED, error: 'Session required' };
98 }
99 const payload = verifyJwtWithSecretRotation(token, secret, sessionSecretPrevious);
100 if (!payload || resolveActorTokenClass(payload) !== 'session') {
101 return { ok: false, status: 401, code: DELEGATION_SESSION_REQUIRED, error: 'Session required' };
102 }
103 return { ok: true, payload };
104 }
105
106 /**
107 * @param {import('express').Response} res
108 * @param {{ status: number, code: string, error?: string }} result
109 */
110 function sendDelegationError(res, result) {
111 return res.status(result.status).json({
112 schema: DELEGATION_ERROR_SCHEMA,
113 code: result.code,
114 error: result.error || result.code,
115 });
116 }
117
118 /**
119 * @param {import('express').Request} req
120 * @param {string} vaultId
121 */
122 function authorityStoreFor(req, vaultId) {
123 return createDelegationAuthorityStore({
124 dataDir,
125 vaultId,
126 blobStore: blobStoreFromReq(req),
127 sessionSecret: process.env.SESSION_SECRET || '',
128 sessionSecretPrevious,
129 operatorAuthorizedMarker: false,
130 });
131 }
132
133 /**
134 * @param {import('express').Request} req
135 * @returns {boolean}
136 */
137 function sessionBoundFromReq(req) {
138 const auth = req.headers.authorization;
139 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
140 const secret = process.env.SESSION_SECRET;
141 if (!token || !secret) return false;
142 const payload = verifyJwtWithSecretRotation(token, secret, sessionSecretPrevious);
143 return payload ? isSessionBoundActor(payload) : false;
144 }
145
146 /**
147 * @param {{
148 * effectiveCanisterUid: string,
149 * actorUid: string,
150 * vaultId: string,
151 * sessionBound?: boolean,
152 * }} ctx
153 */
154 function hostedCreateProposal(ctx) {
155 return async function createProposal(_dataDir, input) {
156 return createDelegationProposalOnCanister({
157 canisterUrl,
158 dataDir,
159 sessionBound: ctx.sessionBound === true,
160 headers: canisterHeaders({
161 'X-User-Id': ctx.effectiveCanisterUid,
162 'X-Actor-Id': ctx.actorUid,
163 'X-Vault-Id': ctx.vaultId,
164 }),
165 input: {
166 ...input,
167 vault_id: ctx.vaultId,
168 proposed_by: ctx.actorUid,
169 },
170 });
171 };
172 }
173
174 /**
175 * @param {import('express').Request} req
176 */
177 function blobStoreFromReq(req) {
178 return /** @type {{ blobStore?: import('./delegation-blob-store.mjs').BlobStore | null }} */ (req).blobStore ?? null;
179 }
180
181 /**
182 * @param {import('express').Response} res
183 * @param {unknown} err
184 */
185 function sendRouteError(res, err) {
186 const e = err && typeof err === 'object' ? /** @type {{ status?: number, code?: string, message?: string }} */ (err) : {};
187 const status = typeof e.status === 'number' ? e.status : 500;
188 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
189 const message = typeof e.message === 'string' ? e.message : String(err);
190 console.error('[bridge] delegation route error', { status, code, message });
191 return res.status(status).json({ error: message, code });
192 }
193
194 app.post('/api/v1/agents/identities', requireBridgeAuth, async (req, res) => {
195 const hctx = await vaultContext(req);
196 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
197 const body = req.body && typeof req.body === 'object' ? req.body : {};
198 try {
199 const result = await withDelegationBlobSync({
200 blobStore: blobStoreFromReq(req),
201 dataDir,
202 run: () =>
203 handleAgentIdentityRegisterProposeRequest({
204 dataDir,
205 vaultId: hctx.vaultId,
206 userId: req.uid,
207 kind: body.kind,
208 agentId: body.agent_id,
209 label: body.label,
210 scopeCeiling: body.scope_ceiling,
211 createProposal: hostedCreateProposal({
212 effectiveCanisterUid: hctx.effectiveCanisterUid,
213 actorUid: req.uid,
214 vaultId: hctx.vaultId,
215 sessionBound: sessionBoundFromReq(req),
216 }),
217 }),
218 });
219 if (!result.ok) {
220 console.error('[bridge] POST /api/v1/agents/identities/propose', {
221 status: result.status,
222 code: result.code,
223 error: result.error,
224 });
225 return res.status(result.status).json({ error: result.error, code: result.code });
226 }
227 return res.status(201).json(result.payload);
228 } catch (err) {
229 return sendRouteError(res, err);
230 }
231 });
232
233 app.get('/api/v1/agents/identities', 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 await hydrateDelegationStoresFromBlob(blobStoreFromReq(req), dataDir);
237 const result = handleAgentIdentityListRequest({
238 dataDir,
239 vaultId: hctx.vaultId,
240 kind: typeof req.query.kind === 'string' ? req.query.kind : undefined,
241 status: typeof req.query.status === 'string' ? req.query.status : undefined,
242 });
243 if (!result.ok) {
244 return res.status(result.status).json({ error: result.error, code: result.code });
245 }
246 return res.json(result.payload);
247 });
248
249 app.post('/api/v1/delegation/consents', requireBridgeAuth, async (req, res) => {
250 const hctx = await vaultContext(req);
251 if (!hctx.ok) {
252 console.error('[bridge] POST /api/v1/delegation/consents vault context denied', {
253 status: hctx.status,
254 code: hctx.code,
255 error: hctx.error,
256 actorUid: req.uid,
257 vaultId: req.headers['x-vault-id'],
258 });
259 return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
260 }
261 const body = req.body && typeof req.body === 'object' ? req.body : {};
262 try {
263 const result = await withDelegationBlobSync({
264 blobStore: blobStoreFromReq(req),
265 dataDir,
266 run: () =>
267 handleDelegationConsentProposeRequest({
268 dataDir,
269 vaultId: hctx.vaultId,
270 userId: req.uid,
271 delegateAgentId: body.delegate_agent_id,
272 scope: body.scope,
273 workspaceId: body.workspace_id,
274 allowedFlowIds: body.allowed_flow_ids,
275 allowedTaskKinds: body.allowed_task_kinds,
276 allowedTaskIds: body.allowed_task_ids,
277 expiresAt: body.expires_at,
278 createProposal: hostedCreateProposal({
279 effectiveCanisterUid: hctx.effectiveCanisterUid,
280 actorUid: req.uid,
281 vaultId: hctx.vaultId,
282 sessionBound: sessionBoundFromReq(req),
283 }),
284 }),
285 });
286 if (!result.ok) {
287 console.error('[bridge] POST /api/v1/delegation/consents', {
288 status: result.status,
289 code: result.code,
290 error: result.error,
291 vaultId: hctx.vaultId,
292 actorUid: req.uid,
293 });
294 return res.status(result.status).json({ error: result.error, code: result.code });
295 }
296 return res.status(201).json(result.payload);
297 } catch (err) {
298 return sendRouteError(res, err);
299 }
300 });
301
302 app.post('/api/v1/delegation/proposals/:proposal_id/apply-approved', requireBridgeAuth, async (req, res) => {
303 const hctx = await vaultContext(req);
304 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
305 const proposalId =
306 typeof req.params.proposal_id === 'string' ? decodeURIComponent(req.params.proposal_id).trim() : '';
307 if (!proposalId) {
308 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
309 }
310 const result = await withDelegationBlobSync({
311 blobStore: blobStoreFromReq(req),
312 dataDir,
313 run: () =>
314 applyApprovedDelegationProposalFromCanister({
315 dataDir,
316 canisterUrl,
317 headers: canisterHeaders({
318 'X-User-Id': hctx.effectiveCanisterUid,
319 'X-Actor-Id': req.uid,
320 'X-Vault-Id': hctx.vaultId,
321 }),
322 proposalId,
323 requireApproved: true,
324 }),
325 });
326 if (!result.ok) {
327 return res.status(result.status).json({ error: result.error, code: result.code });
328 }
329 return res.json(result.payload);
330 });
331
332 app.delete('/api/v1/delegation/consents/:consent_id', requireBridgeAuth, async (req, res) => {
333 const hctx = await vaultContext(req);
334 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
335 const consentId =
336 typeof req.params.consent_id === 'string' ? decodeURIComponent(req.params.consent_id).trim() : '';
337 const result = await withDelegationBlobSync({
338 blobStore: blobStoreFromReq(req),
339 dataDir,
340 run: () =>
341 handleDelegationConsentRevokeRequest({
342 dataDir,
343 vaultId: hctx.vaultId,
344 consentId,
345 userId: req.uid,
346 }),
347 });
348 if (!result.ok) {
349 return res.status(result.status).json({ error: result.error, code: result.code });
350 }
351 return res.json(result.payload);
352 });
353
354 app.post('/api/v1/delegation/grants', requireBridgeAuth, async (req, res) => {
355 if (humanSessionTokenFromReq(req)) {
356 return res.status(403).json({
357 schema: 'knowtation.delegation_error/v1',
358 code: 'DELEGATION_HELPER_ACTOR_DENIED',
359 error: 'Generic grant mint is not available for session tokens',
360 });
361 }
362 const hctx = await vaultContext(req);
363 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
364 const body = req.body && typeof req.body === 'object' ? req.body : {};
365 const result = await withDelegationBlobSync({
366 blobStore: blobStoreFromReq(req),
367 dataDir,
368 run: () =>
369 handleDelegationGrantMintRequest({
370 dataDir,
371 vaultId: hctx.vaultId,
372 consentId: body.consent_id,
373 actorAgentId: body.actor_agent_id,
374 taskRef: body.task_ref,
375 runRef: body.run_ref,
376 flowId: body.flow_id,
377 flowVersion: body.flow_version,
378 ttlSeconds: body.ttl_seconds,
379 }),
380 });
381 if (!result.ok) {
382 return res.status(result.status).json({ error: result.error, code: result.code });
383 }
384 return res.status(201).json(result.payload);
385 });
386
387 app.get('/api/v1/delegation/grants', requireBridgeAuth, async (req, res) => {
388 const hctx = await vaultContext(req);
389 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
390 await hydrateDelegationStoresFromBlob(blobStoreFromReq(req), dataDir);
391 const result = handleDelegationGrantListRequest({
392 dataDir,
393 vaultId: hctx.vaultId,
394 actorAgentId: typeof req.query.actor_agent_id === 'string' ? req.query.actor_agent_id : undefined,
395 });
396 if (!result.ok) {
397 return res.status(result.status).json({ error: result.error, code: result.code });
398 }
399 return res.json(result.payload);
400 });
401
402 app.delete('/api/v1/delegation/grants/:grant_id', requireBridgeAuth, async (req, res) => {
403 const hctx = await vaultContext(req);
404 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
405 const grantId =
406 typeof req.params.grant_id === 'string' ? decodeURIComponent(req.params.grant_id).trim() : '';
407 const result = await withDelegationBlobSync({
408 blobStore: blobStoreFromReq(req),
409 dataDir,
410 run: () =>
411 handleDelegationGrantRevokeRequest({
412 dataDir,
413 vaultId: hctx.vaultId,
414 grantId,
415 }),
416 });
417 if (!result.ok) {
418 return res.status(result.status).json({ error: result.error, code: result.code });
419 }
420 return res.json(result.payload);
421 });
422
423 app.post('/api/v1/delegation/audit', requireBridgeAuth, async (req, res) => {
424 const hctx = await vaultContext(req);
425 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
426 const body = req.body && typeof req.body === 'object' ? req.body : {};
427 const principalRef =
428 typeof body.principal_ref === 'string' && body.principal_ref.trim()
429 ? body.principal_ref.trim()
430 : hashPrincipalRef(req.uid);
431 const result = await withDelegationBlobSync({
432 blobStore: blobStoreFromReq(req),
433 dataDir,
434 run: () =>
435 handleDelegationAuditAppendRequest({
436 dataDir,
437 vaultId: hctx.vaultId,
438 grantId: body.grant_id,
439 actorAgentId: body.actor_agent_id,
440 principalRef,
441 action: body.action,
442 evidenceRefs: body.evidence_refs,
443 taskRef: body.task_ref,
444 runRef: body.run_ref,
445 flowId: body.flow_id,
446 flowVersion: body.flow_version,
447 stepId: body.step_id,
448 executionLocation: body.execution_location,
449 }),
450 });
451 if (!result.ok) {
452 return res.status(result.status).json({ error: result.error, code: result.code });
453 }
454 return res.status(201).json(result.payload);
455 });
456
457 /**
458 * @param {unknown} hctx
459 * @returns {{ status: number, code: string, error: string }}
460 */
461 function mapVaultDenial(hctx) {
462 const status = typeof hctx?.status === 'number' ? hctx.status : 503;
463 if (status === 401) {
464 return { status: 401, code: DELEGATION_SESSION_REQUIRED, error: 'Session required' };
465 }
466 if (status === 403) {
467 return { status: 403, code: DELEGATION_HELPER_ACTOR_DENIED, error: 'Helper actor denied' };
468 }
469 if (status === 409) {
470 return { status: 409, code: DELEGATION_AUTHORITY_CONFLICT, error: 'Authority conflict' };
471 }
472 return {
473 status: 503,
474 code: DELEGATION_AUTHORITY_UNAVAILABLE,
475 error: 'Authority unavailable',
476 };
477 }
478
479 /**
480 * Session-first auth for KN1 retail routes (allowlisted 401 before bridge UNAUTHORIZED).
481 *
482 * @param {import('express').Request} req
483 * @param {import('express').Response} res
484 * @param {import('express').NextFunction} next
485 */
486 function requireRetailSession(req, res, next) {
487 const session = requireStrictSessionToken(req);
488 if (!session.ok) return sendDelegationError(res, session);
489 const sub = typeof session.payload.sub === 'string' ? session.payload.sub.trim() : '';
490 if (!sub) {
491 return sendDelegationError(res, {
492 status: 401,
493 code: DELEGATION_SESSION_REQUIRED,
494 error: 'Session required',
495 });
496 }
497 req.uid = sub;
498 return next();
499 }
500
501 /**
502 * @param {import('express').Response} res
503 * @param {unknown} err
504 */
505 function sendAuthorityUnavailable(res, err) {
506 console.error('[bridge] delegation authority route error', {
507 code: DELEGATION_AUTHORITY_UNAVAILABLE,
508 message: err && typeof err === 'object' && 'message' in err ? String(err.message) : 'error',
509 });
510 return res.status(503).json({
511 schema: DELEGATION_ERROR_SCHEMA,
512 code: DELEGATION_AUTHORITY_UNAVAILABLE,
513 error: 'Authority unavailable',
514 });
515 }
516
517 // --- RHF-b-KN1 retail authority routes (session-only; envelope CAS) ---
518
519 app.post('/api/v1/delegation/grants/renew-personal', requireRetailSession, async (req, res) => {
520 const hctx = await vaultContext(req);
521 if (!hctx.ok) return sendDelegationError(res, mapVaultDenial(hctx));
522 const body = req.body && typeof req.body === 'object' ? req.body : {};
523 const actor =
524 typeof body.actor_agent_id === 'string' ? body.actor_agent_id.trim() : RETAIL_ACTOR_ID;
525 if (actor !== RETAIL_ACTOR_ID) {
526 return sendDelegationError(res, {
527 status: 403,
528 code: DELEGATION_HELPER_ACTOR_DENIED,
529 error: 'Helper actor denied',
530 });
531 }
532 try {
533 const store = authorityStoreFor(req, hctx.vaultId);
534 const result = await store.renewPersonal(req.uid, actor);
535 if (!result.ok) return sendDelegationError(res, result);
536 res.set('Cache-Control', 'no-store');
537 return res.status(201).json(result.payload);
538 } catch (err) {
539 return sendAuthorityUnavailable(res, err);
540 }
541 });
542
543 app.post('/api/v1/delegation/grants/validate', requireRetailSession, async (req, res) => {
544 const hctx = await vaultContext(req);
545 if (!hctx.ok) return sendDelegationError(res, mapVaultDenial(hctx));
546 const bearerHeader = req.headers['x-delegation-bearer'];
547 const actorHeader = req.headers['x-delegation-actor'];
548 const visitHeader = req.headers['x-retail-visit'];
549 const bearer = typeof bearerHeader === 'string' ? bearerHeader.trim() : '';
550 const actor = typeof actorHeader === 'string' ? actorHeader.trim() : '';
551 const visitHandle = typeof visitHeader === 'string' ? visitHeader.trim() : '';
552 if (!bearer || !actor || !visitHandle) {
553 return sendDelegationError(res, {
554 status: 400,
555 code: DELEGATION_REQUEST_INVALID,
556 error: 'Invalid validation request',
557 });
558 }
559 try {
560 const store = authorityStoreFor(req, hctx.vaultId);
561 const result = await store.validateAndConsume({
562 uid: req.uid,
563 bearer,
564 actorId: actor,
565 visitHandle,
566 });
567 if (!result.ok) return sendDelegationError(res, result);
568 res.set('Cache-Control', 'no-store');
569 return res.status(200).json(result.payload);
570 } catch (err) {
571 return sendAuthorityUnavailable(res, err);
572 }
573 });
574
575 app.get('/api/v1/delegation/helper-access', requireRetailSession, async (req, res) => {
576 const hctx = await vaultContext(req);
577 if (!hctx.ok) return sendDelegationError(res, mapVaultDenial(hctx));
578 const actor =
579 typeof req.query.actor_agent_id === 'string' ? req.query.actor_agent_id.trim() : '';
580 if (!actor) {
581 return sendDelegationError(res, {
582 status: 400,
583 code: DELEGATION_REQUEST_INVALID,
584 error: 'actor_agent_id required',
585 });
586 }
587 try {
588 const store = authorityStoreFor(req, hctx.vaultId);
589 const result = await store.readHelperAccess(req.uid, actor);
590 if (!result.ok) return sendDelegationError(res, result);
591 res.set('Cache-Control', 'no-store');
592 return res.status(200).json(result.payload);
593 } catch (err) {
594 return sendAuthorityUnavailable(res, err);
595 }
596 });
597 }
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 3 days ago