delegation.mjs
1,246 lines 40.8 KB
Raw
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor ⚠ breaking 32 days ago
1 /**
2 * Agent delegation gate — identity registry, consent proposals, grant mint/revoke,
3 * audit append, and delegation chain validation (Phase 7C-6).
4 *
5 * Canonical records: agent_identity, delegation_consent, delegation_grant,
6 * delegation_audit. All gated by `DELEGATION_ENABLED` (default **off**).
7 *
8 * @see docs/AGENT-DELEGATION-V0-SPEC.md
9 */
10
11 import fs from 'fs';
12 import path from 'path';
13 import { createHash, randomBytes } from 'crypto';
14
15 export const DELEGATION_POLICY_FILE = 'hub_delegation_policy.json';
16 export const DELEGATION_IDENTITIES_FILE = 'hub_delegation_identities.json';
17 export const DELEGATION_CONSENTS_FILE = 'hub_delegation_consents.json';
18 export const DELEGATION_GRANTS_FILE = 'hub_delegation_grants.json';
19 export const DELEGATION_AUDIT_FILE = 'hub_delegation_audit.json';
20
21 export const AGENT_IDENTITY_SCHEMA = 'knowtation.agent_identity/v0';
22 export const DELEGATION_CONSENT_SCHEMA = 'knowtation.delegation_consent/v0';
23 export const DELEGATION_GRANT_SCHEMA = 'knowtation.delegation_grant/v0';
24 export const DELEGATION_AUDIT_SCHEMA = 'knowtation.delegation_audit/v0';
25 export const DELEGATION_GRANT_MINT_SCHEMA = 'knowtation.delegation_grant_mint/v0';
26
27 export const AGENT_ID_PREFIX = 'agent_';
28 export const CONSENT_ID_PREFIX = 'dcons_';
29 export const GRANT_ID_PREFIX = 'dgrnt_';
30 export const AUDIT_ID_PREFIX = 'daud_';
31 export const GRANT_BEARER_PREFIX = 'dgrnt_bearer_';
32
33 export const DEFAULT_TTL_SECONDS = 3600;
34 export const MAX_TTL_SECONDS = 86400;
35
36 export const DELEGATION_PROPOSAL_SOURCE = 'delegation';
37 export const DELEGATION_REVIEW_QUEUE = 'delegation';
38
39 /** @typedef {'personal' | 'project' | 'org'} Scope */
40 /** @typedef {'user_owned' | 'org_owned' | 'delegate'} AgentKind */
41 /** @typedef {'active' | 'suspended' | 'revoked'} IdentityStatus */
42 /** @typedef {'advance_step' | 'complete_task' | 'propose_outcome' | 'invoke_tool' | 'mint_subgrant'} AuditAction */
43 /** @typedef {'local' | 'hosted' | 'hybrid'} ExecutionLocation */
44
45 const AGENT_KINDS = new Set(['user_owned', 'org_owned', 'delegate']);
46 const IDENTITY_STATUSES = new Set(['active', 'suspended', 'revoked']);
47 const SCOPES = new Set(['personal', 'project', 'org']);
48 const AUDIT_ACTIONS = new Set([
49 'advance_step',
50 'complete_task',
51 'propose_outcome',
52 'invoke_tool',
53 'mint_subgrant',
54 ]);
55 const EXECUTION_LOCATIONS = new Set(['local', 'hosted', 'hybrid']);
56
57 const ID_TOKEN_RE = /^[a-z0-9_]{8,48}$/;
58 const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9._-]+)?(\+[a-zA-Z0-9._-]+)?$/;
59
60 /**
61 * @param {unknown} v
62 * @returns {boolean|null}
63 */
64 function envTriState(v) {
65 if (v === '1' || v === 'true') return true;
66 if (v === '0' || v === 'false') return false;
67 return null;
68 }
69
70 /**
71 * @param {string} dataDir
72 * @returns {object}
73 */
74 export function readDelegationPolicyFile(dataDir) {
75 if (!dataDir) return {};
76 const fp = path.join(dataDir, DELEGATION_POLICY_FILE);
77 try {
78 if (!fs.existsSync(fp)) return {};
79 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
80 return j && typeof j === 'object' ? j : {};
81 } catch {
82 return {};
83 }
84 }
85
86 /**
87 * @param {string} dataDir
88 * @returns {boolean}
89 */
90 export function getDelegationEnabled(dataDir) {
91 const fromEnv = envTriState(process.env.DELEGATION_ENABLED);
92 if (fromEnv !== null) return fromEnv;
93 const policy = readDelegationPolicyFile(dataDir);
94 const d = policy.delegation;
95 if (d && typeof d === 'object' && typeof d.enabled === 'boolean') {
96 return d.enabled;
97 }
98 return false;
99 }
100
101 /**
102 * @param {string} dataDir
103 * @returns {boolean}
104 */
105 export function getDelegationPolicyForbidden(dataDir) {
106 const fromEnv = envTriState(process.env.DELEGATION_POLICY_FORBIDDEN);
107 if (fromEnv !== null) return fromEnv;
108 const policy = readDelegationPolicyFile(dataDir);
109 const d = policy.delegation;
110 if (d && typeof d === 'object' && typeof d.forbidden === 'boolean') {
111 return d.forbidden;
112 }
113 return false;
114 }
115
116 /**
117 * @param {string} dataDir
118 * @returns {{ defaultTtlSeconds: number, maxTtlSeconds: number }}
119 */
120 export function readVaultDelegationPolicy(dataDir) {
121 const policy = readDelegationPolicyFile(dataDir);
122 const d = policy.delegation && typeof policy.delegation === 'object' ? policy.delegation : {};
123 const defaultTtl =
124 typeof d.default_ttl_seconds === 'number' && d.default_ttl_seconds > 0
125 ? d.default_ttl_seconds
126 : DEFAULT_TTL_SECONDS;
127 const maxTtl =
128 typeof d.max_ttl_seconds === 'number' && d.max_ttl_seconds > 0
129 ? d.max_ttl_seconds
130 : MAX_TTL_SECONDS;
131 return { defaultTtlSeconds: defaultTtl, maxTtlSeconds: maxTtl };
132 }
133
134 /**
135 * Derive hashed principal ref from verified session user id — never accept client principal.
136 *
137 * @param {string} userId
138 * @returns {string}
139 */
140 export function hashPrincipalRef(userId) {
141 const trimmed = typeof userId === 'string' ? userId.trim() : '';
142 const hash = createHash('sha256').update(trimmed, 'utf8').digest('hex');
143 return `uid_hash:${hash}`;
144 }
145
146 /**
147 * @param {Scope} a
148 * @param {Scope} b
149 * @returns {Scope|null}
150 */
151 export function intersectScope(a, b) {
152 const order = { personal: 0, project: 1, org: 2 };
153 const oa = order[a];
154 const ob = order[b];
155 if (oa === undefined || ob === undefined) return null;
156 return oa <= ob ? a : b;
157 }
158
159 /**
160 * @param {Scope} ceiling
161 * @param {Scope} requested
162 * @returns {Scope|null}
163 */
164 export function effectiveScope(ceiling, requested) {
165 return intersectScope(ceiling, requested);
166 }
167
168 /**
169 * @param {string} bearer
170 * @returns {string}
171 */
172 export function hashGrantBearer(bearer) {
173 return createHash('sha256').update(bearer, 'utf8').digest('hex');
174 }
175
176 /**
177 * @param {string} prefix
178 * @param {number} [byteLen]
179 * @returns {string}
180 */
181 function randomToken(prefix, byteLen = 16) {
182 const token = randomBytes(byteLen)
183 .toString('base64url')
184 .replace(/[^a-z0-9]/gi, '')
185 .toLowerCase()
186 .slice(0, 24);
187 return prefix + (token.length >= 8 ? token : token.padEnd(8, '0'));
188 }
189
190 /**
191 * @param {string} id
192 * @param {string} prefix
193 * @returns {boolean}
194 */
195 function isValidId(id, prefix) {
196 if (typeof id !== 'string' || !id.startsWith(prefix)) return false;
197 const token = id.slice(prefix.length);
198 return ID_TOKEN_RE.test(token);
199 }
200
201 /**
202 * @param {string} ref
203 * @returns {boolean}
204 */
205 function isValidOwnerRef(ref) {
206 if (typeof ref !== 'string') return false;
207 if (ref.startsWith('uid_hash:')) {
208 return /^uid_hash:[0-9a-f]{64}$/.test(ref);
209 }
210 if (ref.startsWith('org_ref:')) {
211 return ref.length > 'org_ref:'.length;
212 }
213 return false;
214 }
215
216 /**
217 * @param {object} record
218 * @returns {{ ok: true } | { ok: false, error: string }}
219 */
220 export function validateAgentIdentityRecord(record) {
221 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
222 if (record.schema !== AGENT_IDENTITY_SCHEMA) return { ok: false, error: 'schema mismatch' };
223 if (!isValidId(record.agent_id, AGENT_ID_PREFIX)) return { ok: false, error: 'invalid agent_id' };
224 if (!AGENT_KINDS.has(record.kind)) return { ok: false, error: 'invalid kind' };
225 if (!isValidOwnerRef(record.owner_ref)) return { ok: false, error: 'invalid owner_ref' };
226 if (typeof record.vault_id !== 'string' || !record.vault_id.trim()) {
227 return { ok: false, error: 'invalid vault_id' };
228 }
229 if (!SCOPES.has(record.scope_ceiling)) return { ok: false, error: 'invalid scope_ceiling' };
230 if (!IDENTITY_STATUSES.has(record.status)) return { ok: false, error: 'invalid status' };
231 if (typeof record.created !== 'string' || typeof record.updated !== 'string') {
232 return { ok: false, error: 'invalid timestamps' };
233 }
234 return { ok: true };
235 }
236
237 /**
238 * @param {object} record
239 * @returns {{ ok: true } | { ok: false, error: string }}
240 */
241 export function validateConsentRecord(record) {
242 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
243 if (record.schema !== DELEGATION_CONSENT_SCHEMA) return { ok: false, error: 'schema mismatch' };
244 if (!isValidId(record.consent_id, CONSENT_ID_PREFIX)) return { ok: false, error: 'invalid consent_id' };
245 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
246 if (!isValidId(record.delegate_agent_id, AGENT_ID_PREFIX)) {
247 return { ok: false, error: 'invalid delegate_agent_id' };
248 }
249 if (!SCOPES.has(record.scope)) return { ok: false, error: 'invalid scope' };
250 if (record.scope === 'project' || record.scope === 'org') {
251 if (typeof record.workspace_id !== 'string' || !record.workspace_id.trim()) {
252 return { ok: false, error: 'workspace_id required for project/org scope' };
253 }
254 }
255 if (record.revoked_at !== null && typeof record.revoked_at !== 'string') {
256 return { ok: false, error: 'invalid revoked_at' };
257 }
258 if (typeof record.evidence_ref !== 'string' || !record.evidence_ref.startsWith('proposal:')) {
259 return { ok: false, error: 'invalid evidence_ref' };
260 }
261 if (typeof record.created !== 'string') return { ok: false, error: 'invalid created' };
262 return { ok: true };
263 }
264
265 /**
266 * @param {object} record
267 * @returns {{ ok: true } | { ok: false, error: string }}
268 */
269 export function validateGrantRecord(record) {
270 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
271 if (record.schema !== DELEGATION_GRANT_SCHEMA) return { ok: false, error: 'schema mismatch' };
272 if (!isValidId(record.grant_id, GRANT_ID_PREFIX)) return { ok: false, error: 'invalid grant_id' };
273 if (!isValidId(record.consent_id, CONSENT_ID_PREFIX)) return { ok: false, error: 'invalid consent_id' };
274 if (!isValidId(record.actor_agent_id, AGENT_ID_PREFIX)) {
275 return { ok: false, error: 'invalid actor_agent_id' };
276 }
277 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
278 if (!SCOPES.has(record.scope)) return { ok: false, error: 'invalid scope' };
279 if (record.flow_id && !record.flow_version) return { ok: false, error: 'flow_version required' };
280 if (record.flow_version && !SEMVER_RE.test(record.flow_version)) {
281 return { ok: false, error: 'invalid flow_version' };
282 }
283 if (record.revoked_at !== null && typeof record.revoked_at !== 'string') {
284 return { ok: false, error: 'invalid revoked_at' };
285 }
286 if (typeof record.action_count !== 'number' || record.action_count < 0) {
287 return { ok: false, error: 'invalid action_count' };
288 }
289 if (typeof record.expires_at !== 'string' || typeof record.issued_at !== 'string') {
290 return { ok: false, error: 'invalid timestamps' };
291 }
292 return { ok: true };
293 }
294
295 /**
296 * @param {object} record
297 * @returns {{ ok: true } | { ok: false, error: string }}
298 */
299 export function validateAuditRecord(record) {
300 if (!record || typeof record !== 'object') return { ok: false, error: 'invalid record' };
301 if (record.schema !== DELEGATION_AUDIT_SCHEMA) return { ok: false, error: 'schema mismatch' };
302 if (!isValidId(record.audit_id, AUDIT_ID_PREFIX)) return { ok: false, error: 'invalid audit_id' };
303 if (!isValidId(record.grant_id, GRANT_ID_PREFIX)) return { ok: false, error: 'invalid grant_id' };
304 if (!isValidId(record.actor_agent_id, AGENT_ID_PREFIX)) {
305 return { ok: false, error: 'invalid actor_agent_id' };
306 }
307 if (!isValidOwnerRef(record.principal_ref)) return { ok: false, error: 'invalid principal_ref' };
308 if (!AUDIT_ACTIONS.has(record.action)) return { ok: false, error: 'invalid action' };
309 if (!Array.isArray(record.evidence_refs)) return { ok: false, error: 'invalid evidence_refs' };
310 if (typeof record.occurred_at !== 'string') return { ok: false, error: 'invalid occurred_at' };
311 if (
312 record.execution_location != null &&
313 !EXECUTION_LOCATIONS.has(record.execution_location)
314 ) {
315 return { ok: false, error: 'invalid execution_location' };
316 }
317 return { ok: true };
318 }
319
320 /**
321 * @param {object} consent
322 * @returns {'active' | 'revoked' | 'expired'}
323 */
324 export function resolveConsentStatus(consent) {
325 if (consent.revoked_at) return 'revoked';
326 if (consent.expires_at) {
327 const exp = Date.parse(consent.expires_at);
328 if (Number.isFinite(exp) && Date.now() > exp) return 'expired';
329 }
330 return 'active';
331 }
332
333 /**
334 * @param {object} grant
335 * @returns {'active' | 'revoked' | 'expired' | 'exhausted'}
336 */
337 export function resolveGrantStatus(grant) {
338 if (grant.revoked_at) return 'revoked';
339 const exp = Date.parse(grant.expires_at);
340 if (!Number.isFinite(exp) || Date.now() > exp) return 'expired';
341 if (
342 typeof grant.max_actions === 'number' &&
343 grant.max_actions > 0 &&
344 grant.action_count >= grant.max_actions
345 ) {
346 return 'exhausted';
347 }
348 return 'active';
349 }
350
351 /**
352 * Strip internal fields from stored grant for client responses.
353 *
354 * @param {object} stored
355 * @returns {object}
356 */
357 export function grantForClient(stored) {
358 const { grant_bearer_hash: _b, ...grant } = stored;
359 return grant;
360 }
361
362 /**
363 * @param {string} dataDir
364 * @param {string} filename
365 * @returns {{ vaults: Record<string, object> }}
366 */
367 function loadVaultStore(dataDir, filename) {
368 const fp = path.join(dataDir, filename);
369 if (!fs.existsSync(fp)) return { vaults: {} };
370 try {
371 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
372 if (!j || typeof j !== 'object') return { vaults: {} };
373 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
374 } catch {
375 return { vaults: {} };
376 }
377 }
378
379 /**
380 * @param {string} dataDir
381 * @param {string} filename
382 * @param {{ vaults: Record<string, object> }} store
383 */
384 function saveVaultStore(dataDir, filename, store) {
385 const fp = path.join(dataDir, filename);
386 fs.mkdirSync(path.dirname(fp), { recursive: true });
387 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
388 }
389
390 /**
391 * @param {string} dataDir
392 * @returns {{ vaults: Record<string, { identities: object[] }> }}
393 */
394 export function loadIdentitiesStore(dataDir) {
395 const raw = loadVaultStore(dataDir, DELEGATION_IDENTITIES_FILE);
396 for (const v of Object.values(raw.vaults)) {
397 if (!Array.isArray(v.identities)) v.identities = [];
398 }
399 return /** @type {{ vaults: Record<string, { identities: object[] }> }} */ (raw);
400 }
401
402 /**
403 * @param {string} dataDir
404 * @param {{ vaults: Record<string, { identities: object[] }> }} store
405 */
406 export function saveIdentitiesStore(dataDir, store) {
407 saveVaultStore(dataDir, DELEGATION_IDENTITIES_FILE, store);
408 }
409
410 /**
411 * @param {string} dataDir
412 * @returns {{ vaults: Record<string, { consents: object[] }> }}
413 */
414 export function loadConsentsStore(dataDir) {
415 const raw = loadVaultStore(dataDir, DELEGATION_CONSENTS_FILE);
416 for (const v of Object.values(raw.vaults)) {
417 if (!Array.isArray(v.consents)) v.consents = [];
418 }
419 return /** @type {{ vaults: Record<string, { consents: object[] }> }} */ (raw);
420 }
421
422 /**
423 * @param {string} dataDir
424 * @param {{ vaults: Record<string, { consents: object[] }> }} store
425 */
426 export function saveConsentsStore(dataDir, store) {
427 saveVaultStore(dataDir, DELEGATION_CONSENTS_FILE, store);
428 }
429
430 /**
431 * @param {string} dataDir
432 * @returns {{ vaults: Record<string, { grants: object[] }> }}
433 */
434 export function loadGrantsStore(dataDir) {
435 const raw = loadVaultStore(dataDir, DELEGATION_GRANTS_FILE);
436 for (const v of Object.values(raw.vaults)) {
437 if (!Array.isArray(v.grants)) v.grants = [];
438 }
439 return /** @type {{ vaults: Record<string, { grants: object[] }> }} */ (raw);
440 }
441
442 /**
443 * @param {string} dataDir
444 * @param {{ vaults: Record<string, { grants: object[] }> }} store
445 */
446 export function saveGrantsStore(dataDir, store) {
447 saveVaultStore(dataDir, DELEGATION_GRANTS_FILE, store);
448 }
449
450 /**
451 * @param {string} dataDir
452 * @returns {{ vaults: Record<string, { audits: object[] }> }}
453 */
454 export function loadAuditStore(dataDir) {
455 const raw = loadVaultStore(dataDir, DELEGATION_AUDIT_FILE);
456 for (const v of Object.values(raw.vaults)) {
457 if (!Array.isArray(v.audits)) v.audits = [];
458 }
459 return /** @type {{ vaults: Record<string, { audits: object[] }> }} */ (raw);
460 }
461
462 /**
463 * @param {string} dataDir
464 * @param {{ vaults: Record<string, { audits: object[] }> }} store
465 */
466 export function saveAuditStore(dataDir, store) {
467 saveVaultStore(dataDir, DELEGATION_AUDIT_FILE, store);
468 }
469
470 /**
471 * @param {string} dataDir
472 * @param {string} vaultId
473 * @param {string} agentId
474 * @returns {object|null}
475 */
476 export function getAgentIdentity(dataDir, vaultId, agentId) {
477 const store = loadIdentitiesStore(dataDir);
478 const vault = store.vaults[vaultId];
479 if (!vault) return null;
480 return vault.identities.find((i) => i.agent_id === agentId) ?? null;
481 }
482
483 /**
484 * @param {string} dataDir
485 * @param {string} vaultId
486 * @param {string} consentId
487 * @returns {object|null}
488 */
489 export function getConsent(dataDir, vaultId, consentId) {
490 const store = loadConsentsStore(dataDir);
491 const vault = store.vaults[vaultId];
492 if (!vault) return null;
493 return vault.consents.find((c) => c.consent_id === consentId) ?? null;
494 }
495
496 /**
497 * @param {string} dataDir
498 * @param {string} vaultId
499 * @param {string} grantId
500 * @returns {object|null}
501 */
502 export function getGrant(dataDir, vaultId, grantId) {
503 const store = loadGrantsStore(dataDir);
504 const vault = store.vaults[vaultId];
505 if (!vault) return null;
506 return vault.grants.find((g) => g.grant_id === grantId) ?? null;
507 }
508
509 /**
510 * @param {object} ctx
511 * @returns {{ ok: false, status: number, error: string, code: string }}
512 */
513 function refuse(status, code, error) {
514 return { ok: false, status, error, code };
515 }
516
517 /**
518 * Gate check shared by mutating handlers.
519 *
520 * @param {string} dataDir
521 * @returns {{ ok: true } | { ok: false, status: number, error: string, code: string }}
522 */
523 function checkDelegationGate(dataDir) {
524 if (getDelegationPolicyForbidden(dataDir)) {
525 return refuse(403, 'DELEGATION_POLICY_FORBIDDEN', 'Delegation forbidden by policy');
526 }
527 if (!getDelegationEnabled(dataDir)) {
528 return refuse(403, 'DELEGATION_DISABLED', 'Delegation gate is disabled');
529 }
530 return { ok: true };
531 }
532
533 /**
534 * @param {string[]} allowlist
535 * @param {string} value
536 * @returns {boolean}
537 */
538 function allowlistPermits(allowlist, value) {
539 if (!Array.isArray(allowlist) || allowlist.length === 0) return true;
540 return allowlist.includes(value);
541 }
542
543 /**
544 * Validate delegation chain reconstructability per spec §2.
545 *
546 * @param {{
547 * dataDir: string,
548 * vaultId: string,
549 * actorAgentId: string,
550 * principalRef: string,
551 * grantId?: string,
552 * taskRef?: string,
553 * runRef?: string,
554 * flowId?: string,
555 * flowVersion?: string,
556 * requireGrant?: boolean,
557 * }} input
558 * @returns {{ ok: true, grant?: object, identity: object, consent?: object } | { ok: false, status: number, code: string }}
559 */
560 export function validateChain(input) {
561 const gate = checkDelegationGate(input.dataDir);
562 if (!gate.ok) return { ok: false, status: gate.status, code: gate.code };
563
564 const actorId = typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
565 const principalRef =
566 typeof input.principalRef === 'string' ? input.principalRef.trim() : '';
567 if (!actorId || !principalRef) {
568 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
569 }
570
571 const identity = getAgentIdentity(input.dataDir, input.vaultId, actorId);
572 if (!identity || identity.status !== 'active') {
573 return { ok: false, status: 403, code: 'DELEGATION_IDENTITY_DENIED' };
574 }
575
576 const actorIsOwner =
577 identity.kind === 'user_owned' && identity.owner_ref === principalRef;
578 const grantRequired =
579 input.requireGrant === true || (!actorIsOwner && identity.kind !== 'user_owned');
580
581 if (!grantRequired && actorIsOwner) {
582 return { ok: true, identity };
583 }
584
585 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
586 if (!grantId) {
587 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_REQUIRED' };
588 }
589
590 const stored = getGrant(input.dataDir, input.vaultId, grantId);
591 if (!stored) {
592 return { ok: false, status: 404, code: 'unknown_grant' };
593 }
594
595 const grantStatus = resolveGrantStatus(stored);
596 if (grantStatus === 'revoked') {
597 return { ok: false, status: 403, code: 'DELEGATION_GRANT_REVOKED' };
598 }
599 if (grantStatus === 'expired') {
600 return { ok: false, status: 403, code: 'DELEGATION_GRANT_EXPIRED' };
601 }
602 if (grantStatus === 'exhausted') {
603 return { ok: false, status: 403, code: 'DELEGATION_GRANT_EXHAUSTED' };
604 }
605
606 if (stored.actor_agent_id !== actorId) {
607 return { ok: false, status: 403, code: 'DELEGATION_ACTOR_MISMATCH' };
608 }
609 if (stored.principal_ref !== principalRef) {
610 return { ok: false, status: 403, code: 'DELEGATION_PRINCIPAL_MISMATCH' };
611 }
612
613 const consent = getConsent(input.dataDir, input.vaultId, stored.consent_id);
614 if (!consent) {
615 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
616 }
617 const consentStatus = resolveConsentStatus(consent);
618 if (consentStatus === 'revoked') {
619 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_REVOKED' };
620 }
621 if (consentStatus === 'expired') {
622 return { ok: false, status: 403, code: 'DELEGATION_CONSENT_EXPIRED' };
623 }
624
625 const effective = effectiveScope(identity.scope_ceiling, stored.scope);
626 if (!effective || effective !== stored.scope) {
627 return { ok: false, status: 403, code: 'DELEGATION_IDENTITY_SCOPE_DENIED' };
628 }
629
630 if (input.taskRef && stored.task_ref && stored.task_ref !== input.taskRef.trim()) {
631 return { ok: false, status: 403, code: 'DELEGATION_TASK_DENIED' };
632 }
633 if (input.runRef && stored.run_ref && stored.run_ref !== input.runRef.trim()) {
634 return { ok: false, status: 403, code: 'DELEGATION_CHAIN_INVALID' };
635 }
636 if (input.flowId && stored.flow_id && stored.flow_id !== input.flowId.trim()) {
637 return { ok: false, status: 403, code: 'DELEGATION_GRANT_FLOW_MISMATCH' };
638 }
639 if (
640 input.flowVersion &&
641 stored.flow_version &&
642 stored.flow_version !== input.flowVersion.trim()
643 ) {
644 return { ok: false, status: 403, code: 'DELEGATION_GRANT_FLOW_MISMATCH' };
645 }
646
647 return { ok: true, grant: grantForClient(stored), identity, consent };
648 }
649
650 /**
651 * @param {{
652 * dataDir: string,
653 * vaultId: string,
654 * userId: string,
655 * kind: AgentKind,
656 * agentId?: string,
657 * label?: string,
658 * scopeCeiling?: Scope,
659 * ownerRef?: string,
660 * createProposal: (dataDir: string, input: object) => object | Promise<object>,
661 * }} input
662 */
663 export async function handleAgentIdentityRegisterProposeRequest(input) {
664 const gate = checkDelegationGate(input.dataDir);
665 if (!gate.ok) return gate;
666
667 const principalRef = hashPrincipalRef(input.userId);
668 const kind = input.kind;
669 if (!AGENT_KINDS.has(kind)) {
670 return refuse(400, 'BAD_REQUEST', 'kind must be user_owned, org_owned, or delegate');
671 }
672
673 const agentId =
674 typeof input.agentId === 'string' && input.agentId.trim()
675 ? input.agentId.trim()
676 : randomToken(AGENT_ID_PREFIX);
677 if (!isValidId(agentId, AGENT_ID_PREFIX)) {
678 return refuse(400, 'BAD_REQUEST', 'agent_id format invalid');
679 }
680
681 const scopeCeiling = input.scopeCeiling ?? 'personal';
682 if (!SCOPES.has(scopeCeiling)) {
683 return refuse(400, 'BAD_REQUEST', 'invalid scope_ceiling');
684 }
685
686 const ownerRef =
687 typeof input.ownerRef === 'string' && input.ownerRef.trim()
688 ? input.ownerRef.trim()
689 : principalRef;
690 if (!isValidOwnerRef(ownerRef)) {
691 return refuse(400, 'BAD_REQUEST', 'invalid owner_ref');
692 }
693
694 const now = new Date().toISOString();
695 const identityPayload = {
696 schema: AGENT_IDENTITY_SCHEMA,
697 agent_id: agentId,
698 kind,
699 owner_ref: ownerRef,
700 vault_id: input.vaultId,
701 scope_ceiling: scopeCeiling,
702 label: typeof input.label === 'string' ? input.label.slice(0, 256) : undefined,
703 status: 'active',
704 created: now,
705 updated: now,
706 };
707
708 const validation = validateAgentIdentityRecord(identityPayload);
709 if (!validation.ok) {
710 return refuse(400, 'BAD_REQUEST', validation.error);
711 }
712
713 const proposal = await Promise.resolve(
714 input.createProposal(input.dataDir, {
715 path: `meta/agents/${agentId.replace(/^agent_/, '')}.md`,
716 body: JSON.stringify(identityPayload, null, 2),
717 frontmatter: { agent_id: agentId, kind },
718 intent: 'agent_identity_register',
719 source: DELEGATION_PROPOSAL_SOURCE,
720 vault_id: input.vaultId,
721 proposed_by: input.userId?.trim() || undefined,
722 review_queue: DELEGATION_REVIEW_QUEUE,
723 delegation_meta: { record_kind: 'agent_identity', agent_id: agentId },
724 }),
725 );
726
727 return {
728 ok: true,
729 payload: {
730 schema: 'knowtation.delegation_proposal/v0',
731 proposal_id: proposal.proposal_id,
732 intent: 'agent_identity_register',
733 agent_id: agentId,
734 },
735 };
736 }
737
738 /**
739 * @param {{
740 * dataDir: string,
741 * vaultId: string,
742 * userId: string,
743 * delegateAgentId: string,
744 * scope: Scope,
745 * workspaceId?: string,
746 * allowedFlowIds?: string[],
747 * allowedTaskKinds?: string[],
748 * allowedTaskIds?: string[],
749 * expiresAt?: string,
750 * createProposal: (dataDir: string, input: object) => object | Promise<object>,
751 * }} input
752 */
753 export async function handleDelegationConsentProposeRequest(input) {
754 const gate = checkDelegationGate(input.dataDir);
755 if (!gate.ok) return gate;
756
757 const principalRef = hashPrincipalRef(input.userId);
758 const delegateAgentId =
759 typeof input.delegateAgentId === 'string' ? input.delegateAgentId.trim() : '';
760 if (!isValidId(delegateAgentId, AGENT_ID_PREFIX)) {
761 return refuse(400, 'BAD_REQUEST', 'delegate_agent_id required');
762 }
763
764 const identity = getAgentIdentity(input.dataDir, input.vaultId, delegateAgentId);
765 if (!identity || identity.status !== 'active') {
766 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Delegate agent not found or inactive');
767 }
768
769 const scope = input.scope;
770 if (!SCOPES.has(scope)) {
771 return refuse(400, 'BAD_REQUEST', 'invalid scope');
772 }
773 const effective = effectiveScope(identity.scope_ceiling, scope);
774 if (!effective || effective !== scope) {
775 return refuse(403, 'DELEGATION_IDENTITY_SCOPE_DENIED', 'Scope exceeds agent ceiling');
776 }
777
778 if ((scope === 'project' || scope === 'org') && !input.workspaceId?.trim()) {
779 return refuse(400, 'BAD_REQUEST', 'workspace_id required for project/org scope');
780 }
781
782 const consentId = randomToken(CONSENT_ID_PREFIX);
783 const now = new Date().toISOString();
784 const consentPayload = {
785 schema: DELEGATION_CONSENT_SCHEMA,
786 consent_id: consentId,
787 principal_ref: principalRef,
788 delegate_agent_id: delegateAgentId,
789 scope,
790 workspace_id: input.workspaceId?.trim() || undefined,
791 allowed_flow_ids: input.allowedFlowIds?.length ? [...input.allowedFlowIds] : undefined,
792 allowed_task_kinds: input.allowedTaskKinds?.length ? [...input.allowedTaskKinds] : undefined,
793 allowed_task_ids: input.allowedTaskIds?.length ? [...input.allowedTaskIds] : undefined,
794 expires_at: input.expiresAt || undefined,
795 revoked_at: null,
796 evidence_ref: 'proposal:pending',
797 created: now,
798 };
799
800 const proposal = await Promise.resolve(
801 input.createProposal(input.dataDir, {
802 path: `meta/delegation/consents/${consentId.replace(/^dcons_/, '')}.md`,
803 body: JSON.stringify(consentPayload, null, 2),
804 frontmatter: { consent_id: consentId, delegate_agent_id: delegateAgentId },
805 intent: 'delegation_consent_create',
806 source: DELEGATION_PROPOSAL_SOURCE,
807 vault_id: input.vaultId,
808 proposed_by: input.userId?.trim() || undefined,
809 review_queue: DELEGATION_REVIEW_QUEUE,
810 delegation_meta: { record_kind: 'delegation_consent', consent_id: consentId },
811 }),
812 );
813
814 consentPayload.evidence_ref = `proposal:${proposal.proposal_id}`;
815
816 return {
817 ok: true,
818 payload: {
819 schema: 'knowtation.delegation_proposal/v0',
820 proposal_id: proposal.proposal_id,
821 intent: 'delegation_consent_create',
822 consent_id: consentId,
823 consent_preview: consentPayload,
824 },
825 };
826 }
827
828 /**
829 * @param {string} dataDir
830 * @param {object} proposal
831 * @returns {{ ok: true, vaultId: string, recordKind: string, record: object } | { ok: false, status: number, error: string, code: string }}
832 */
833 export function precheckApprovedDelegationProposal(dataDir, proposal) {
834 if (proposal.source !== DELEGATION_PROPOSAL_SOURCE) {
835 return refuse(400, 'BAD_REQUEST', 'Not a delegation proposal');
836 }
837 const meta = proposal.delegation_meta;
838 if (!meta || typeof meta !== 'object' || typeof meta.record_kind !== 'string') {
839 return refuse(400, 'BAD_REQUEST', 'Missing delegation_meta');
840 }
841
842 let record;
843 try {
844 record = JSON.parse(proposal.body ?? '{}');
845 } catch {
846 return refuse(400, 'BAD_REQUEST', 'Proposal body is not valid JSON');
847 }
848
849 const vaultId =
850 typeof proposal.vault_id === 'string' && proposal.vault_id.trim()
851 ? proposal.vault_id.trim()
852 : 'default';
853
854 if (meta.record_kind === 'agent_identity') {
855 const v = validateAgentIdentityRecord(record);
856 if (!v.ok) return refuse(400, 'BAD_REQUEST', v.error);
857 const existing = getAgentIdentity(dataDir, vaultId, record.agent_id);
858 if (existing) {
859 return refuse(409, 'CONFLICT', 'Agent identity already registered');
860 }
861 } else if (meta.record_kind === 'delegation_consent') {
862 const v = validateConsentRecord(record);
863 if (!v.ok) return refuse(400, 'BAD_REQUEST', v.error);
864 record.evidence_ref = `proposal:${proposal.proposal_id}`;
865 const identity = getAgentIdentity(dataDir, vaultId, record.delegate_agent_id);
866 if (!identity || identity.status !== 'active') {
867 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Delegate agent not active');
868 }
869 } else {
870 return refuse(400, 'BAD_REQUEST', 'Unknown delegation record kind');
871 }
872
873 return { ok: true, vaultId, recordKind: meta.record_kind, record };
874 }
875
876 /**
877 * @param {string} dataDir
878 * @param {{ vaultId: string, recordKind: string, record: object }} apply
879 */
880 export function applyDelegationProposalToIndex(dataDir, apply) {
881 if (apply.recordKind === 'agent_identity') {
882 const store = loadIdentitiesStore(dataDir);
883 if (!store.vaults[apply.vaultId]) store.vaults[apply.vaultId] = { identities: [] };
884 store.vaults[apply.vaultId].identities.push(apply.record);
885 saveIdentitiesStore(dataDir, store);
886 return;
887 }
888 if (apply.recordKind === 'delegation_consent') {
889 const store = loadConsentsStore(dataDir);
890 if (!store.vaults[apply.vaultId]) store.vaults[apply.vaultId] = { consents: [] };
891 store.vaults[apply.vaultId].consents.push(apply.record);
892 saveConsentsStore(dataDir, store);
893 }
894 }
895
896 /**
897 * @param {{ dataDir: string, vaultId: string, kind?: AgentKind, status?: IdentityStatus }} input
898 */
899 export function handleAgentIdentityListRequest(input) {
900 const gate = checkDelegationGate(input.dataDir);
901 if (!gate.ok) return gate;
902
903 const store = loadIdentitiesStore(input.dataDir);
904 const vault = store.vaults[input.vaultId];
905 let identities = vault && Array.isArray(vault.identities) ? vault.identities : [];
906
907 if (input.kind && AGENT_KINDS.has(input.kind)) {
908 identities = identities.filter((i) => i.kind === input.kind);
909 }
910 if (input.status && IDENTITY_STATUSES.has(input.status)) {
911 identities = identities.filter((i) => i.status === input.status);
912 }
913
914 return {
915 ok: true,
916 payload: {
917 schema: 'knowtation.agent_identity_list/v0',
918 vault_id: input.vaultId,
919 identities,
920 },
921 };
922 }
923
924 /**
925 * @param {{
926 * dataDir: string,
927 * vaultId: string,
928 * consentId: string,
929 * actorAgentId: string,
930 * taskRef?: string,
931 * runRef?: string,
932 * flowId?: string,
933 * flowVersion?: string,
934 * ttlSeconds?: number,
935 * maxActions?: number,
936 * }} input
937 */
938 export function handleDelegationGrantMintRequest(input) {
939 const gate = checkDelegationGate(input.dataDir);
940 if (!gate.ok) return gate;
941
942 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
943 const actorAgentId =
944 typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
945 if (!isValidId(consentId, CONSENT_ID_PREFIX) || !isValidId(actorAgentId, AGENT_ID_PREFIX)) {
946 return refuse(400, 'BAD_REQUEST', 'consent_id and actor_agent_id required');
947 }
948
949 const consent = getConsent(input.dataDir, input.vaultId, consentId);
950 if (!consent) {
951 return refuse(404, 'unknown_consent', 'unknown_consent');
952 }
953
954 const consentStatus = resolveConsentStatus(consent);
955 if (consentStatus === 'revoked') {
956 return refuse(403, 'DELEGATION_CONSENT_REVOKED', 'Consent revoked');
957 }
958 if (consentStatus === 'expired') {
959 return refuse(403, 'DELEGATION_CONSENT_EXPIRED', 'Consent expired');
960 }
961
962 if (consent.delegate_agent_id !== actorAgentId) {
963 return refuse(403, 'DELEGATION_ACTOR_MISMATCH', 'Actor does not match consent');
964 }
965
966 const identity = getAgentIdentity(input.dataDir, input.vaultId, actorAgentId);
967 if (!identity || identity.status !== 'active') {
968 return refuse(403, 'DELEGATION_IDENTITY_DENIED', 'Actor identity denied');
969 }
970
971 const scope = effectiveScope(identity.scope_ceiling, consent.scope);
972 if (!scope || scope !== consent.scope) {
973 return refuse(403, 'DELEGATION_IDENTITY_SCOPE_DENIED', 'Scope denied');
974 }
975
976 const taskRef = typeof input.taskRef === 'string' ? input.taskRef.trim() : '';
977 if (taskRef && !allowlistPermits(consent.allowed_task_ids, taskRef)) {
978 return refuse(403, 'DELEGATION_TASK_DENIED', 'Task not on consent allowlist');
979 }
980
981 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
982 const flowVersion = typeof input.flowVersion === 'string' ? input.flowVersion.trim() : '';
983 if (flowId && !flowVersion) {
984 return refuse(400, 'BAD_REQUEST', 'flow_version required when flow_id set');
985 }
986 if (flowId && !allowlistPermits(consent.allowed_flow_ids, flowId)) {
987 return refuse(403, 'DELEGATION_FLOW_DENIED', 'Flow not on consent allowlist');
988 }
989
990 const runRef = typeof input.runRef === 'string' ? input.runRef.trim() : '';
991
992 const vaultPolicy = readVaultDelegationPolicy(input.dataDir);
993 const ttlRequested =
994 typeof input.ttlSeconds === 'number' && input.ttlSeconds > 0
995 ? Math.min(input.ttlSeconds, vaultPolicy.maxTtlSeconds)
996 : vaultPolicy.defaultTtlSeconds;
997 const ttl = Math.min(ttlRequested, vaultPolicy.maxTtlSeconds);
998 const now = new Date();
999 const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
1000
1001 const grantId = randomToken(GRANT_ID_PREFIX);
1002 const bearer = randomToken(GRANT_BEARER_PREFIX, 24);
1003
1004 const grant = {
1005 schema: DELEGATION_GRANT_SCHEMA,
1006 grant_id: grantId,
1007 consent_id: consentId,
1008 actor_agent_id: actorAgentId,
1009 principal_ref: consent.principal_ref,
1010 scope: consent.scope,
1011 workspace_id: consent.workspace_id,
1012 task_ref: taskRef || undefined,
1013 run_ref: runRef || undefined,
1014 flow_id: flowId || undefined,
1015 flow_version: flowVersion || undefined,
1016 expires_at: expiresAt,
1017 revoked_at: null,
1018 max_actions:
1019 typeof input.maxActions === 'number' && input.maxActions >= 0 ? input.maxActions : undefined,
1020 action_count: 0,
1021 issued_at: now.toISOString(),
1022 };
1023
1024 const store = loadGrantsStore(input.dataDir);
1025 if (!store.vaults[input.vaultId]) store.vaults[input.vaultId] = { grants: [] };
1026 store.vaults[input.vaultId].grants.push({
1027 ...grant,
1028 grant_bearer_hash: hashGrantBearer(bearer),
1029 });
1030 saveGrantsStore(input.dataDir, store);
1031
1032 return {
1033 ok: true,
1034 payload: {
1035 schema: DELEGATION_GRANT_MINT_SCHEMA,
1036 grant: grantForClient(grant),
1037 bearer,
1038 expires_at: expiresAt,
1039 },
1040 };
1041 }
1042
1043 /**
1044 * @param {{ dataDir: string, vaultId: string, grantId: string }} input
1045 */
1046 export function handleDelegationGrantRevokeRequest(input) {
1047 const gate = checkDelegationGate(input.dataDir);
1048 if (!gate.ok) return gate;
1049
1050 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
1051 if (!isValidId(grantId, GRANT_ID_PREFIX)) {
1052 return refuse(400, 'BAD_REQUEST', 'grant_id required');
1053 }
1054
1055 const store = loadGrantsStore(input.dataDir);
1056 const vault = store.vaults[input.vaultId];
1057 if (!vault || !Array.isArray(vault.grants)) {
1058 return refuse(404, 'unknown_grant', 'unknown_grant');
1059 }
1060
1061 const idx = vault.grants.findIndex((g) => g.grant_id === grantId);
1062 if (idx < 0) {
1063 return refuse(404, 'unknown_grant', 'unknown_grant');
1064 }
1065
1066 vault.grants[idx] = {
1067 ...vault.grants[idx],
1068 revoked_at: new Date().toISOString(),
1069 };
1070 saveGrantsStore(input.dataDir, store);
1071
1072 return { ok: true, payload: grantForClient(vault.grants[idx]) };
1073 }
1074
1075 /**
1076 * @param {{ dataDir: string, vaultId: string, actorAgentId?: string }} input
1077 */
1078 export function handleDelegationGrantListRequest(input) {
1079 const gate = checkDelegationGate(input.dataDir);
1080 if (!gate.ok) return gate;
1081
1082 const store = loadGrantsStore(input.dataDir);
1083 const vault = store.vaults[input.vaultId];
1084 let grants = vault && Array.isArray(vault.grants) ? vault.grants : [];
1085
1086 const actorFilter =
1087 typeof input.actorAgentId === 'string' ? input.actorAgentId.trim() : '';
1088 if (actorFilter) {
1089 grants = grants.filter((g) => g.actor_agent_id === actorFilter);
1090 }
1091
1092 return {
1093 ok: true,
1094 payload: {
1095 schema: 'knowtation.delegation_grant_list/v0',
1096 vault_id: input.vaultId,
1097 grants: grants.map(grantForClient),
1098 },
1099 };
1100 }
1101
1102 /**
1103 * @param {{ dataDir: string, vaultId: string, consentId: string, userId: string }} input
1104 */
1105 export function handleDelegationConsentRevokeRequest(input) {
1106 const gate = checkDelegationGate(input.dataDir);
1107 if (!gate.ok) return gate;
1108
1109 const consentId = typeof input.consentId === 'string' ? input.consentId.trim() : '';
1110 if (!isValidId(consentId, CONSENT_ID_PREFIX)) {
1111 return refuse(400, 'BAD_REQUEST', 'consent_id required');
1112 }
1113
1114 const store = loadConsentsStore(input.dataDir);
1115 const vault = store.vaults[input.vaultId];
1116 if (!vault || !Array.isArray(vault.consents)) {
1117 return refuse(404, 'unknown_consent', 'unknown_consent');
1118 }
1119
1120 const idx = vault.consents.findIndex((c) => c.consent_id === consentId);
1121 if (idx < 0) {
1122 return refuse(404, 'unknown_consent', 'unknown_consent');
1123 }
1124
1125 const principalRef = hashPrincipalRef(input.userId);
1126 if (vault.consents[idx].principal_ref !== principalRef) {
1127 return refuse(403, 'DELEGATION_PRINCIPAL_MISMATCH', 'Principal mismatch');
1128 }
1129
1130 vault.consents[idx] = {
1131 ...vault.consents[idx],
1132 revoked_at: new Date().toISOString(),
1133 };
1134 saveConsentsStore(input.dataDir, store);
1135
1136 return { ok: true, payload: vault.consents[idx] };
1137 }
1138
1139 /**
1140 * @param {{
1141 * dataDir: string,
1142 * vaultId: string,
1143 * grantId: string,
1144 * actorAgentId: string,
1145 * principalRef: string,
1146 * action: AuditAction,
1147 * evidenceRefs: string[],
1148 * taskRef?: string,
1149 * runRef?: string,
1150 * flowId?: string,
1151 * flowVersion?: string,
1152 * stepId?: string,
1153 * executionLocation?: ExecutionLocation,
1154 * }} input
1155 */
1156 export function handleDelegationAuditAppendRequest(input) {
1157 const gate = checkDelegationGate(input.dataDir);
1158 if (!gate.ok) return gate;
1159
1160 const chain = validateChain({
1161 dataDir: input.dataDir,
1162 vaultId: input.vaultId,
1163 actorAgentId: input.actorAgentId,
1164 principalRef: input.principalRef,
1165 grantId: input.grantId,
1166 taskRef: input.taskRef,
1167 runRef: input.runRef,
1168 flowId: input.flowId,
1169 flowVersion: input.flowVersion,
1170 requireGrant: true,
1171 });
1172 if (!chain.ok) {
1173 return refuse(chain.status, chain.code, chain.code);
1174 }
1175
1176 const action = input.action;
1177 if (!AUDIT_ACTIONS.has(action)) {
1178 return refuse(400, 'BAD_REQUEST', 'invalid action');
1179 }
1180
1181 const evidenceRefs = Array.isArray(input.evidenceRefs)
1182 ? input.evidenceRefs.filter((r) => typeof r === 'string' && r.trim()).slice(0, 32)
1183 : [];
1184 if (evidenceRefs.length === 0) {
1185 return refuse(400, 'BAD_REQUEST', 'evidence_refs required');
1186 }
1187
1188 const auditId = randomToken(AUDIT_ID_PREFIX);
1189 const audit = {
1190 schema: DELEGATION_AUDIT_SCHEMA,
1191 audit_id: auditId,
1192 grant_id: input.grantId,
1193 actor_agent_id: input.actorAgentId,
1194 principal_ref: input.principalRef,
1195 task_ref: input.taskRef?.trim() || undefined,
1196 run_ref: input.runRef?.trim() || undefined,
1197 flow_id: input.flowId?.trim() || undefined,
1198 flow_version: input.flowVersion?.trim() || undefined,
1199 step_id: input.stepId?.trim() || undefined,
1200 action,
1201 evidence_refs: evidenceRefs,
1202 occurred_at: new Date().toISOString(),
1203 execution_location: input.executionLocation,
1204 };
1205
1206 const validation = validateAuditRecord(audit);
1207 if (!validation.ok) {
1208 return refuse(400, 'BAD_REQUEST', validation.error);
1209 }
1210
1211 const grantStore = loadGrantsStore(input.dataDir);
1212 const vault = grantStore.vaults[input.vaultId];
1213 const grantIdx = vault?.grants?.findIndex((g) => g.grant_id === input.grantId) ?? -1;
1214 if (grantIdx >= 0) {
1215 vault.grants[grantIdx].action_count = (vault.grants[grantIdx].action_count ?? 0) + 1;
1216 saveGrantsStore(input.dataDir, grantStore);
1217 }
1218
1219 const auditStore = loadAuditStore(input.dataDir);
1220 if (!auditStore.vaults[input.vaultId]) auditStore.vaults[input.vaultId] = { audits: [] };
1221 auditStore.vaults[input.vaultId].audits.push(audit);
1222 saveAuditStore(input.dataDir, auditStore);
1223
1224 return { ok: true, payload: audit };
1225 }
1226
1227 /**
1228 * Seed identity + consent directly for tests (bypasses proposal when gate on in test fixtures).
1229 *
1230 * @param {string} dataDir
1231 * @param {string} vaultId
1232 * @param {object} identity
1233 * @param {object} [consent]
1234 */
1235 export function seedDelegationFixtures(dataDir, vaultId, identity, consent) {
1236 const idStore = loadIdentitiesStore(dataDir);
1237 if (!idStore.vaults[vaultId]) idStore.vaults[vaultId] = { identities: [] };
1238 idStore.vaults[vaultId].identities.push(identity);
1239 saveIdentitiesStore(dataDir, idStore);
1240 if (consent) {
1241 const cStore = loadConsentsStore(dataDir);
1242 if (!cStore.vaults[vaultId]) cStore.vaults[vaultId] = { consents: [] };
1243 cStore.vaults[vaultId].consents.push(consent);
1244 saveConsentsStore(dataDir, cStore);
1245 }
1246 }
File History 1 commit
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 32 days ago