delegation-hosted-proposal.mjs
372 lines 12.3 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Hosted delegation proposal parity (Phase 7C-L1b).
3 *
4 * Delegation identity/consent proposals must live in the canister proposal store so Hub
5 * Activity/Suggested tabs can list them. Approve still applies bridge-side delegation
6 * indexes via {@link applyApprovedDelegationProposalFromCanister}.
7 *
8 * @see docs/AGENT-DELEGATION-V0-SPEC.md — SD-4 review-before-write
9 */
10
11 import { parseCanisterProposalGetBody } from '../canister-proposal-response-parse.mjs';
12 import {
13 DELEGATION_PROPOSAL_SOURCE,
14 precheckApprovedDelegationProposal,
15 applyDelegationProposalToIndex,
16 getAgentIdentity,
17 getConsent,
18 } from './delegation.mjs';
19
20 /** Intents that require bridge delegation index apply on approve. */
21 export const DELEGATION_PROPOSAL_INTENTS = new Set([
22 'agent_identity_register',
23 'delegation_consent_create',
24 ]);
25
26 /** Frontmatter keys persisted on canister proposals (canister has no delegation_meta column). */
27 export const FM_PROPOSAL_SOURCE = 'knowtation_proposal_source';
28 export const FM_RECORD_KIND = 'delegation_record_kind';
29 export const FM_AGENT_ID = 'delegation_agent_id';
30 export const FM_CONSENT_ID = 'delegation_consent_id';
31
32 /**
33 * @param {string} intent
34 * @returns {boolean}
35 */
36 export function isDelegationProposalIntent(intent) {
37 return typeof intent === 'string' && DELEGATION_PROPOSAL_INTENTS.has(intent);
38 }
39
40 /**
41 * @param {string} intent
42 * @returns {'agent_identity'|'delegation_consent'|''}
43 */
44 export function delegationRecordKindFromIntent(intent) {
45 if (intent === 'agent_identity_register') return 'agent_identity';
46 if (intent === 'delegation_consent_create') return 'delegation_consent';
47 return '';
48 }
49
50 /**
51 * @param {unknown} frontmatter
52 * @returns {Record<string, unknown>}
53 */
54 export function parseProposalFrontmatter(frontmatter) {
55 if (frontmatter == null) return {};
56 if (typeof frontmatter === 'object' && !Array.isArray(frontmatter)) {
57 return /** @type {Record<string, unknown>} */ (frontmatter);
58 }
59 if (typeof frontmatter === 'string' && frontmatter.trim()) {
60 try {
61 const parsed = JSON.parse(frontmatter);
62 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
63 ? /** @type {Record<string, unknown>} */ (parsed)
64 : {};
65 } catch {
66 return {};
67 }
68 }
69 return {};
70 }
71
72 /**
73 * Embed delegation metadata in canister frontmatter JSON.
74 *
75 * @param {Record<string, unknown>|undefined|null} baseFm
76 * @param {{ record_kind: string, agent_id?: string, consent_id?: string }} delegationMeta
77 * @returns {Record<string, unknown>}
78 */
79 export function mergeDelegationFrontmatter(baseFm, delegationMeta) {
80 const fm = {
81 ...(baseFm && typeof baseFm === 'object' && !Array.isArray(baseFm) ? baseFm : {}),
82 };
83 fm[FM_PROPOSAL_SOURCE] = DELEGATION_PROPOSAL_SOURCE;
84 fm[FM_RECORD_KIND] = String(delegationMeta.record_kind || '').slice(0, 32);
85 if (delegationMeta.agent_id != null) {
86 fm[FM_AGENT_ID] = String(delegationMeta.agent_id).slice(0, 64);
87 }
88 if (delegationMeta.consent_id != null) {
89 fm[FM_CONSENT_ID] = String(delegationMeta.consent_id).slice(0, 64);
90 }
91 return fm;
92 }
93
94 /**
95 * Map a canister proposal GET/list row into the shape `precheckApprovedDelegationProposal` expects.
96 *
97 * @param {Record<string, unknown>} proposal
98 * @returns {Record<string, unknown>|null}
99 */
100 export function normalizeCanisterProposalForDelegationPrecheck(proposal) {
101 if (!proposal || typeof proposal !== 'object') return null;
102
103 const fm = parseProposalFrontmatter(proposal.frontmatter);
104 const intent = typeof proposal.intent === 'string' ? proposal.intent : '';
105 const fromFm = fm[FM_PROPOSAL_SOURCE] === DELEGATION_PROPOSAL_SOURCE;
106 const fromIntent = isDelegationProposalIntent(intent);
107
108 if (!fromFm && !fromIntent && proposal.source !== DELEGATION_PROPOSAL_SOURCE) {
109 return null;
110 }
111
112 /** @type {{ record_kind: string, agent_id?: string, consent_id?: string }} */
113 const delegation_meta = {
114 record_kind:
115 (typeof fm[FM_RECORD_KIND] === 'string' && fm[FM_RECORD_KIND].trim()) ||
116 delegationRecordKindFromIntent(intent) ||
117 (proposal.delegation_meta &&
118 typeof proposal.delegation_meta === 'object' &&
119 typeof /** @type {{ record_kind?: string }} */ (proposal.delegation_meta).record_kind === 'string'
120 ? /** @type {{ record_kind: string }} */ (proposal.delegation_meta).record_kind
121 : ''),
122 };
123 if (typeof fm[FM_AGENT_ID] === 'string' && fm[FM_AGENT_ID].trim()) {
124 delegation_meta.agent_id = fm[FM_AGENT_ID].trim();
125 }
126 if (typeof fm[FM_CONSENT_ID] === 'string' && fm[FM_CONSENT_ID].trim()) {
127 delegation_meta.consent_id = fm[FM_CONSENT_ID].trim();
128 }
129
130 if (!delegation_meta.record_kind) return null;
131
132 return {
133 ...proposal,
134 source: DELEGATION_PROPOSAL_SOURCE,
135 delegation_meta,
136 };
137 }
138
139 /**
140 * POST a delegation proposal to the canister (hosted bridge propose path).
141 *
142 * Parity with task/media hosted proposals: merge hosted evaluation policy + review
143 * triggers before canister POST (gateway uses the same augment on proxied creates).
144 * Delegation surface never E1 self-passes; proposals stay pending for Hub review.
145 *
146 * @param {{
147 * canisterUrl: string,
148 * dataDir?: string,
149 * sessionBound?: boolean,
150 * headers: Record<string, string>,
151 * input: {
152 * path: string,
153 * body?: string,
154 * intent?: string,
155 * frontmatter?: Record<string, unknown>,
156 * delegation_meta?: { record_kind: string, agent_id?: string, consent_id?: string },
157 * vault_id?: string,
158 * review_queue?: string,
159 * proposed_by?: string,
160 * },
161 * }} opts
162 * @returns {Promise<Record<string, unknown>>}
163 */
164 export async function createDelegationProposalOnCanister(opts) {
165 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
166 if (!base) {
167 const err = new Error('CANISTER_URL required for hosted delegation proposals');
168 err.status = 503;
169 err.code = 'NOT_AVAILABLE';
170 throw err;
171 }
172
173 const input = opts.input;
174 const frontmatter = mergeDelegationFrontmatter(input.frontmatter, input.delegation_meta ?? { record_kind: '' });
175 const proposedBy =
176 typeof input.proposed_by === 'string' && input.proposed_by.trim() ? input.proposed_by.trim() : '';
177 /** @type {Record<string, unknown>} */
178 let payload = {
179 path: input.path,
180 body: input.body ?? '',
181 intent: input.intent ?? '',
182 frontmatter,
183 source: DELEGATION_PROPOSAL_SOURCE,
184 };
185 if (input.review_queue) payload.review_queue = input.review_queue;
186
187 const dataDir = typeof opts.dataDir === 'string' ? opts.dataDir.trim() : '';
188 if (dataDir) {
189 const { augmentProposalCreateRequestBody } = await import('../hub-proposal-create-augment.mjs');
190 payload = augmentProposalCreateRequestBody(payload, dataDir, {
191 evaluatedBy: proposedBy || undefined,
192 sessionBound: opts.sessionBound === true,
193 authorActorId: proposedBy || undefined,
194 });
195 }
196 // Delegation proposals always require human review (SD-4); never leave eval unset.
197 if (String(payload.evaluation_status ?? '').trim() !== 'passed') {
198 payload.evaluation_status = 'pending';
199 }
200
201 const res = await fetch(`${base}/api/v1/proposals`, {
202 method: 'POST',
203 headers: {
204 Accept: 'application/json',
205 'Content-Type': 'application/json',
206 ...opts.headers,
207 },
208 body: JSON.stringify(payload),
209 });
210
211 const text = await res.text();
212 /** @type {Record<string, unknown>} */
213 let json = {};
214 try {
215 json = text ? JSON.parse(text) : {};
216 } catch {
217 json = {};
218 }
219
220 if (!res.ok) {
221 const err = new Error(
222 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
223 );
224 err.status = res.status;
225 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
226 throw err;
227 }
228
229 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
230 if (!proposalId) {
231 const err = new Error('Canister proposal create missing proposal_id');
232 err.status = 502;
233 err.code = 'BAD_GATEWAY';
234 throw err;
235 }
236
237 const now = new Date().toISOString();
238 return {
239 proposal_id: proposalId,
240 path: typeof json.path === 'string' ? json.path : input.path,
241 status: typeof json.status === 'string' ? json.status : 'proposed',
242 vault_id: input.vault_id,
243 intent: input.intent,
244 body: input.body,
245 frontmatter,
246 source: DELEGATION_PROPOSAL_SOURCE,
247 delegation_meta: input.delegation_meta,
248 review_queue: input.review_queue,
249 created_at: now,
250 updated_at: now,
251 };
252 }
253
254 /**
255 * Fetch one proposal from the canister and normalize for delegation apply.
256 *
257 * @param {{
258 * canisterUrl: string,
259 * headers: Record<string, string>,
260 * proposalId: string,
261 * }} opts
262 * @returns {Promise<{ ok: true, proposal: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
263 */
264 export async function fetchCanisterProposalForDelegation(opts) {
265 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
266 const proposalId = String(opts.proposalId || '').trim();
267 if (!base || !proposalId) {
268 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'canisterUrl and proposalId required' };
269 }
270
271 const res = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, {
272 method: 'GET',
273 headers: { Accept: 'application/json', ...opts.headers },
274 });
275 const text = await res.text();
276 if (!res.ok) {
277 return {
278 ok: false,
279 status: res.status === 404 ? 404 : 502,
280 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
281 error: text.slice(0, 200) || `Canister GET proposal ${res.status}`,
282 };
283 }
284
285 const raw = parseCanisterProposalGetBody(proposalId, text, {});
286 const normalized = normalizeCanisterProposalForDelegationPrecheck(raw);
287 if (!normalized) {
288 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'Not a delegation proposal' };
289 }
290 return { ok: true, proposal: normalized };
291 }
292
293 /**
294 * Apply an approved canister delegation proposal to bridge delegation indexes.
295 *
296 * @param {{
297 * dataDir: string,
298 * canisterUrl: string,
299 * headers: Record<string, string>,
300 * proposalId: string,
301 * requireApproved?: boolean,
302 * }} opts
303 * @returns {Promise<{ ok: true, payload: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
304 */
305 export async function applyApprovedDelegationProposalFromCanister(opts) {
306 const fetched = await fetchCanisterProposalForDelegation({
307 canisterUrl: opts.canisterUrl,
308 headers: opts.headers,
309 proposalId: opts.proposalId,
310 });
311 if (!fetched.ok) return fetched;
312
313 const proposal = fetched.proposal;
314 if (opts.requireApproved !== false && proposal.status !== 'approved') {
315 return {
316 ok: false,
317 status: 409,
318 code: 'CONFLICT',
319 error: 'Proposal must be approved before delegation index apply',
320 };
321 }
322
323 const precheck = precheckApprovedDelegationProposal(opts.dataDir, proposal, {
324 author: typeof proposal.created_by === 'string' ? proposal.created_by : '',
325 });
326 if (!precheck.ok) {
327 if (precheck.code === 'CONFLICT') {
328 const meta = proposal.delegation_meta;
329 if (meta && typeof meta === 'object') {
330 const vaultId =
331 typeof proposal.vault_id === 'string' && proposal.vault_id.trim()
332 ? proposal.vault_id.trim()
333 : 'default';
334 if (meta.record_kind === 'agent_identity' && typeof meta.agent_id === 'string') {
335 const existing = getAgentIdentity(opts.dataDir, String(vaultId), meta.agent_id);
336 if (existing && existing.status === 'active') {
337 return {
338 ok: true,
339 payload: { applied: true, idempotent: true, record_kind: 'agent_identity', proposal_id: opts.proposalId },
340 };
341 }
342 }
343 if (meta.record_kind === 'delegation_consent' && typeof meta.consent_id === 'string') {
344 const existing = getConsent(opts.dataDir, String(vaultId), meta.consent_id);
345 if (existing && existing.status === 'active') {
346 return {
347 ok: true,
348 payload: {
349 applied: true,
350 idempotent: true,
351 record_kind: 'delegation_consent',
352 proposal_id: opts.proposalId,
353 },
354 };
355 }
356 }
357 }
358 }
359 return precheck;
360 }
361
362 applyDelegationProposalToIndex(opts.dataDir, precheck);
363 return {
364 ok: true,
365 payload: {
366 applied: true,
367 record_kind: precheck.recordKind,
368 proposal_id: opts.proposalId,
369 vault_id: precheck.vaultId,
370 },
371 };
372 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago