delegation-hosted-proposal.mjs
346 lines 11.1 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 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 * @param {{
143 * canisterUrl: string,
144 * headers: Record<string, string>,
145 * input: {
146 * path: string,
147 * body?: string,
148 * intent?: string,
149 * frontmatter?: Record<string, unknown>,
150 * delegation_meta?: { record_kind: string, agent_id?: string, consent_id?: string },
151 * vault_id?: string,
152 * review_queue?: string,
153 * },
154 * }} opts
155 * @returns {Promise<Record<string, unknown>>}
156 */
157 export async function createDelegationProposalOnCanister(opts) {
158 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
159 if (!base) {
160 const err = new Error('CANISTER_URL required for hosted delegation proposals');
161 err.status = 503;
162 err.code = 'NOT_AVAILABLE';
163 throw err;
164 }
165
166 const input = opts.input;
167 const frontmatter = mergeDelegationFrontmatter(input.frontmatter, input.delegation_meta ?? { record_kind: '' });
168 /** @type {Record<string, unknown>} */
169 const payload = {
170 path: input.path,
171 body: input.body ?? '',
172 intent: input.intent ?? '',
173 frontmatter,
174 };
175 if (input.review_queue) payload.review_queue = input.review_queue;
176
177 const res = await fetch(`${base}/api/v1/proposals`, {
178 method: 'POST',
179 headers: {
180 Accept: 'application/json',
181 'Content-Type': 'application/json',
182 ...opts.headers,
183 },
184 body: JSON.stringify(payload),
185 });
186
187 const text = await res.text();
188 /** @type {Record<string, unknown>} */
189 let json = {};
190 try {
191 json = text ? JSON.parse(text) : {};
192 } catch {
193 json = {};
194 }
195
196 if (!res.ok) {
197 const err = new Error(
198 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
199 );
200 err.status = res.status;
201 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
202 throw err;
203 }
204
205 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
206 if (!proposalId) {
207 const err = new Error('Canister proposal create missing proposal_id');
208 err.status = 502;
209 err.code = 'BAD_GATEWAY';
210 throw err;
211 }
212
213 const now = new Date().toISOString();
214 return {
215 proposal_id: proposalId,
216 path: typeof json.path === 'string' ? json.path : input.path,
217 status: typeof json.status === 'string' ? json.status : 'proposed',
218 vault_id: input.vault_id,
219 intent: input.intent,
220 body: input.body,
221 frontmatter,
222 source: DELEGATION_PROPOSAL_SOURCE,
223 delegation_meta: input.delegation_meta,
224 review_queue: input.review_queue,
225 created_at: now,
226 updated_at: now,
227 };
228 }
229
230 /**
231 * Fetch one proposal from the canister and normalize for delegation apply.
232 *
233 * @param {{
234 * canisterUrl: string,
235 * headers: Record<string, string>,
236 * proposalId: string,
237 * }} opts
238 * @returns {Promise<{ ok: true, proposal: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
239 */
240 export async function fetchCanisterProposalForDelegation(opts) {
241 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
242 const proposalId = String(opts.proposalId || '').trim();
243 if (!base || !proposalId) {
244 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'canisterUrl and proposalId required' };
245 }
246
247 const res = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, {
248 method: 'GET',
249 headers: { Accept: 'application/json', ...opts.headers },
250 });
251 const text = await res.text();
252 if (!res.ok) {
253 return {
254 ok: false,
255 status: res.status === 404 ? 404 : 502,
256 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
257 error: text.slice(0, 200) || `Canister GET proposal ${res.status}`,
258 };
259 }
260
261 const raw = parseCanisterProposalGetBody(proposalId, text, {});
262 const normalized = normalizeCanisterProposalForDelegationPrecheck(raw);
263 if (!normalized) {
264 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'Not a delegation proposal' };
265 }
266 return { ok: true, proposal: normalized };
267 }
268
269 /**
270 * Apply an approved canister delegation proposal to bridge delegation indexes.
271 *
272 * @param {{
273 * dataDir: string,
274 * canisterUrl: string,
275 * headers: Record<string, string>,
276 * proposalId: string,
277 * requireApproved?: boolean,
278 * }} opts
279 * @returns {Promise<{ ok: true, payload: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
280 */
281 export async function applyApprovedDelegationProposalFromCanister(opts) {
282 const fetched = await fetchCanisterProposalForDelegation({
283 canisterUrl: opts.canisterUrl,
284 headers: opts.headers,
285 proposalId: opts.proposalId,
286 });
287 if (!fetched.ok) return fetched;
288
289 const proposal = fetched.proposal;
290 if (opts.requireApproved !== false && proposal.status !== 'approved') {
291 return {
292 ok: false,
293 status: 409,
294 code: 'CONFLICT',
295 error: 'Proposal must be approved before delegation index apply',
296 };
297 }
298
299 const precheck = precheckApprovedDelegationProposal(opts.dataDir, proposal);
300 if (!precheck.ok) {
301 if (precheck.code === 'CONFLICT') {
302 const meta = proposal.delegation_meta;
303 if (meta && typeof meta === 'object') {
304 const vaultId =
305 typeof proposal.vault_id === 'string' && proposal.vault_id.trim()
306 ? proposal.vault_id.trim()
307 : 'default';
308 if (meta.record_kind === 'agent_identity' && typeof meta.agent_id === 'string') {
309 const existing = getAgentIdentity(opts.dataDir, String(vaultId), meta.agent_id);
310 if (existing && existing.status === 'active') {
311 return {
312 ok: true,
313 payload: { applied: true, idempotent: true, record_kind: 'agent_identity', proposal_id: opts.proposalId },
314 };
315 }
316 }
317 if (meta.record_kind === 'delegation_consent' && typeof meta.consent_id === 'string') {
318 const existing = getConsent(opts.dataDir, String(vaultId), meta.consent_id);
319 if (existing && existing.status === 'active') {
320 return {
321 ok: true,
322 payload: {
323 applied: true,
324 idempotent: true,
325 record_kind: 'delegation_consent',
326 proposal_id: opts.proposalId,
327 },
328 };
329 }
330 }
331 }
332 }
333 return precheck;
334 }
335
336 applyDelegationProposalToIndex(opts.dataDir, precheck);
337 return {
338 ok: true,
339 payload: {
340 applied: true,
341 record_kind: precheck.recordKind,
342 proposal_id: opts.proposalId,
343 vault_id: precheck.vaultId,
344 },
345 };
346 }
File History 2 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 30 days ago