flow-hosted-proposal.mjs
329 lines 10.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hosted Flow proposal parity (FLOW-WRITE-LIVE-GATEWAY-PROXY).
3 *
4 * Flow authoring proposals must live in the canister proposal store so Hub Activity
5 * can list them and personal self-apply can admit §FWL.4.1 fingerprints. Canister
6 * has no `source` / `flow_meta` columns — those ride in frontmatter (task pattern).
7 *
8 * @see docs/FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1.md
9 * @see lib/task/task-hosted-proposal.mjs
10 */
11
12 import { parseCanisterProposalGetBody } from '../canister-proposal-response-parse.mjs';
13
14 /** Must match `FLOW_PROPOSAL_SOURCE` in flow-authoring.mjs (avoid load cycle). */
15 export const FLOW_PROPOSAL_SOURCE = 'flow';
16
17 /** Frontmatter keys persisted on canister proposals. */
18 export const FM_PROPOSAL_SOURCE = 'knowtation_proposal_source';
19 export const FM_FLOW_KIND = 'flow_proposal_kind';
20 export const FM_FLOW_BASE_VERSION = 'flow_base_version';
21 export const FM_FLOW_BASE_STATE_ID = 'flow_base_state_id';
22 export const FM_FLOW_ID = 'flow_id';
23 export const FM_FLOW_VERSION = 'flow_version';
24 export const FM_FLOW_SCOPE = 'scope';
25
26 /**
27 * @param {unknown} frontmatter
28 * @returns {Record<string, unknown>}
29 */
30 export function parseProposalFrontmatter(frontmatter) {
31 if (frontmatter == null) return {};
32 if (typeof frontmatter === 'object' && !Array.isArray(frontmatter)) {
33 return /** @type {Record<string, unknown>} */ (frontmatter);
34 }
35 if (typeof frontmatter === 'string' && frontmatter.trim()) {
36 try {
37 const parsed = JSON.parse(frontmatter);
38 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
39 ? /** @type {Record<string, unknown>} */ (parsed)
40 : {};
41 } catch {
42 return {};
43 }
44 }
45 return {};
46 }
47
48 /**
49 * Embed Flow metadata in canister frontmatter JSON.
50 *
51 * @param {Record<string, unknown>|undefined|null} baseFm
52 * @param {{
53 * kind: string,
54 * base_version?: string|null,
55 * base_state_id?: string|null,
56 * flow_id?: string|null,
57 * flow_version?: string|null,
58 * scope?: string|null,
59 * }} flowMeta
60 * @returns {Record<string, unknown>}
61 */
62 export function mergeFlowFrontmatter(baseFm, flowMeta) {
63 const fm = {
64 ...(baseFm && typeof baseFm === 'object' && !Array.isArray(baseFm) ? baseFm : {}),
65 };
66 fm[FM_PROPOSAL_SOURCE] = FLOW_PROPOSAL_SOURCE;
67 fm.type = 'flow';
68 fm[FM_FLOW_KIND] = String(flowMeta.kind || '').slice(0, 16);
69 if (flowMeta.base_version != null && String(flowMeta.base_version).trim()) {
70 fm[FM_FLOW_BASE_VERSION] = String(flowMeta.base_version).slice(0, 32);
71 }
72 if (flowMeta.base_state_id != null && String(flowMeta.base_state_id).trim()) {
73 fm[FM_FLOW_BASE_STATE_ID] = String(flowMeta.base_state_id).slice(0, 96);
74 }
75 if (flowMeta.flow_id != null) {
76 fm[FM_FLOW_ID] = String(flowMeta.flow_id).slice(0, 80);
77 }
78 if (flowMeta.flow_version != null) {
79 fm[FM_FLOW_VERSION] = String(flowMeta.flow_version).slice(0, 32);
80 }
81 if (flowMeta.scope != null) {
82 fm[FM_FLOW_SCOPE] = String(flowMeta.scope).slice(0, 16);
83 }
84 return fm;
85 }
86
87 /**
88 * Map a canister proposal row into the shape self-apply / precheck expect
89 * (`source: flow` + `flow_meta`).
90 *
91 * @param {Record<string, unknown>} proposal
92 * @returns {Record<string, unknown>|null}
93 */
94 export function normalizeCanisterProposalForFlowPrecheck(proposal) {
95 if (!proposal || typeof proposal !== 'object') return null;
96
97 const fm = parseProposalFrontmatter(proposal.frontmatter);
98 const fromFm = fm[FM_PROPOSAL_SOURCE] === FLOW_PROPOSAL_SOURCE;
99 const fromSource = proposal.source === FLOW_PROPOSAL_SOURCE;
100 const path =
101 typeof proposal.path === 'string' &&
102 proposal.path.replace(/^\/+/, '').startsWith('meta/flows/');
103
104 if (!fromFm && !fromSource && !path) return null;
105
106 const kindRaw =
107 (typeof fm[FM_FLOW_KIND] === 'string' && fm[FM_FLOW_KIND].trim()) ||
108 (proposal.flow_meta &&
109 typeof proposal.flow_meta === 'object' &&
110 typeof /** @type {{ kind?: unknown }} */ (proposal.flow_meta).kind === 'string' &&
111 String(/** @type {{ kind: string }} */ (proposal.flow_meta).kind).trim()) ||
112 '';
113
114 if (!kindRaw) return null;
115
116 const baseVersion =
117 (typeof fm[FM_FLOW_BASE_VERSION] === 'string' && fm[FM_FLOW_BASE_VERSION].trim()) ||
118 (proposal.flow_meta &&
119 typeof proposal.flow_meta === 'object' &&
120 /** @type {{ base_version?: unknown }} */ (proposal.flow_meta).base_version != null
121 ? String(/** @type {{ base_version: unknown }} */ (proposal.flow_meta).base_version)
122 : null);
123
124 const baseStateId =
125 (typeof fm[FM_FLOW_BASE_STATE_ID] === 'string' && fm[FM_FLOW_BASE_STATE_ID].trim()) ||
126 (proposal.flow_meta &&
127 typeof proposal.flow_meta === 'object' &&
128 typeof /** @type {{ base_state_id?: unknown }} */ (proposal.flow_meta).base_state_id ===
129 'string'
130 ? String(/** @type {{ base_state_id: string }} */ (proposal.flow_meta).base_state_id)
131 : '');
132
133 return {
134 ...proposal,
135 source: FLOW_PROPOSAL_SOURCE,
136 flow_meta: {
137 kind: kindRaw,
138 base_version: baseVersion,
139 base_state_id: baseStateId,
140 },
141 };
142 }
143
144 /**
145 * POST a Flow proposal to the canister (hosted bridge propose path).
146 *
147 * @param {{
148 * canisterUrl: 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 * base_state_id?: string,
157 * external_ref?: string,
158 * flow_meta?: {
159 * kind: string,
160 * base_version?: string|null,
161 * base_state_id?: string,
162 * },
163 * vault_id?: string,
164 * review_queue?: string,
165 * proposed_by?: string,
166 * },
167 * }} opts
168 * @returns {Promise<Record<string, unknown>>}
169 */
170 export async function createFlowProposalOnCanister(opts) {
171 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
172 if (!base) {
173 const err = new Error('CANISTER_URL required for hosted flow proposals');
174 err.status = 503;
175 err.code = 'NOT_AVAILABLE';
176 throw err;
177 }
178
179 const input = opts.input;
180 const flowMeta = input.flow_meta ?? { kind: '' };
181 const frontmatter = mergeFlowFrontmatter(input.frontmatter, {
182 kind: flowMeta.kind,
183 base_version: flowMeta.base_version,
184 base_state_id: flowMeta.base_state_id ?? input.base_state_id,
185 flow_id:
186 input.frontmatter && typeof input.frontmatter.flow_id === 'string'
187 ? input.frontmatter.flow_id
188 : null,
189 flow_version:
190 input.frontmatter && typeof input.frontmatter.flow_version === 'string'
191 ? input.frontmatter.flow_version
192 : null,
193 scope:
194 input.frontmatter && typeof input.frontmatter.scope === 'string'
195 ? input.frontmatter.scope
196 : null,
197 });
198
199 /** @type {Record<string, unknown>} */
200 const payload = {
201 path: input.path,
202 body: input.body ?? '',
203 intent: input.intent ?? '',
204 frontmatter,
205 };
206 if (input.base_state_id) payload.base_state_id = input.base_state_id;
207 if (input.review_queue) payload.review_queue = input.review_queue;
208 if (input.external_ref) payload.external_ref = input.external_ref;
209
210 const { applyPersonalSelfApplyEvaluationE1 } = await import('../hub-proposal-personal-self-apply.mjs');
211 const e1Body = applyPersonalSelfApplyEvaluationE1(
212 {
213 ...payload,
214 source: FLOW_PROPOSAL_SOURCE,
215 flow_meta: {
216 kind: flowMeta.kind,
217 base_version: flowMeta.base_version ?? null,
218 base_state_id: flowMeta.base_state_id ?? input.base_state_id ?? '',
219 },
220 external_ref: input.external_ref,
221 status: 'proposed',
222 },
223 {
224 evaluatedBy: typeof input.proposed_by === 'string' ? input.proposed_by : '',
225 authorActorId: typeof input.proposed_by === 'string' ? input.proposed_by : '',
226 sessionBound: opts.sessionBound === true,
227 },
228 );
229 if (e1Body.evaluation_status === 'passed') {
230 payload.evaluation_status = 'passed';
231 if (e1Body.evaluated_by) payload.evaluated_by = e1Body.evaluated_by;
232 if (e1Body.evaluated_at) payload.evaluated_at = e1Body.evaluated_at;
233 }
234
235 const res = await fetch(`${base}/api/v1/proposals`, {
236 method: 'POST',
237 headers: {
238 Accept: 'application/json',
239 'Content-Type': 'application/json',
240 ...opts.headers,
241 },
242 body: JSON.stringify(payload),
243 });
244
245 const text = await res.text();
246 /** @type {Record<string, unknown>} */
247 let json = {};
248 try {
249 json = text ? JSON.parse(text) : {};
250 } catch {
251 json = {};
252 }
253
254 if (!res.ok) {
255 const err = new Error(
256 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
257 );
258 err.status = res.status;
259 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
260 throw err;
261 }
262
263 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
264 if (!proposalId) {
265 const err = new Error('Canister proposal create missing proposal_id');
266 err.status = 502;
267 err.code = 'BAD_GATEWAY';
268 throw err;
269 }
270
271 const now = new Date().toISOString();
272 return {
273 proposal_id: proposalId,
274 path: typeof json.path === 'string' ? json.path : input.path,
275 status: typeof json.status === 'string' ? json.status : 'proposed',
276 vault_id: input.vault_id,
277 intent: input.intent,
278 body: input.body,
279 frontmatter,
280 base_state_id: input.base_state_id,
281 external_ref: input.external_ref,
282 source: FLOW_PROPOSAL_SOURCE,
283 flow_meta: {
284 kind: flowMeta.kind,
285 base_version: flowMeta.base_version ?? null,
286 base_state_id: flowMeta.base_state_id ?? input.base_state_id ?? '',
287 },
288 review_queue: input.review_queue,
289 proposed_by: input.proposed_by,
290 evaluation_status: e1Body.evaluation_status,
291 created_at: now,
292 updated_at: now,
293 };
294 }
295
296 /**
297 * Fetch one proposal from the canister and normalize for Flow self-apply / precheck.
298 *
299 * @param {{
300 * canisterUrl: string,
301 * headers: Record<string, string>,
302 * proposalId: string,
303 * }} opts
304 * @returns {Promise<{ ok: true, proposal: Record<string, unknown> } | { ok: false }>}
305 */
306 export async function fetchCanisterProposalForFlow(opts) {
307 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
308 if (!base || !opts.proposalId) return { ok: false };
309 try {
310 const res = await fetch(
311 `${base}/api/v1/proposals/${encodeURIComponent(opts.proposalId)}`,
312 {
313 method: 'GET',
314 headers: {
315 Accept: 'application/json',
316 ...opts.headers,
317 },
318 },
319 );
320 if (!res.ok) return { ok: false };
321 const text = await res.text();
322 const parsed = parseCanisterProposalGetBody(opts.proposalId, text, {});
323 if (!parsed || typeof parsed !== 'object') return { ok: false };
324 const normalized = normalizeCanisterProposalForFlowPrecheck(parsed);
325 return { ok: true, proposal: normalized ?? parsed };
326 } catch {
327 return { ok: false };
328 }
329 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago