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