flow-capture-hosted-proposal.mjs
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