media-hosted-proposal.mjs
712 lines 25.3 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Hosted media proposal parity (SEC-SEAM-MEDIA-b — SM-C2/C3/C4/C5).
3 *
4 * Media proposals (`source: media`) must live in the canister proposal store so Hub
5 * Activity can list them. The canister has no `source` / `media_meta` columns — those
6 * ride in frontmatter (task/capture pattern, G20). Approve apply runs via
7 * `POST …/attachments/proposals/:id/apply-approved` (gateway hook after approve) using
8 * the SAME `precheckApprovedMediaProposal` + `reconcileApprovedMediaProposal` pair as
9 * self-hosted Hub approve — no hosted-only precheck fork (SM-C5).
10 *
11 * S3.0 (load-bearing): `normalizeCanisterProposalForMediaPrecheck` is BOTH the gateway
12 * hook trigger (hub/gateway/media-approve-hosted.mjs) AND an `isSeamSurfaceProposal`
13 * condition (lib/hub-proposal-personal-self-apply.mjs), shipped in the same change —
14 * never a hand-written kind/intent list.
15 *
16 * media_attach hosted note I/O uses the SM-C5 **temp-stage** option: GET the canister
17 * note → stage into a per-request temp vaultPath → run the shared precheck/reconcile →
18 * POST the mutated note back to the canister notes surface (`api/v1/notes` family,
19 * hub/icp/src/hub/main.mo notes routes) → discard the temp dir. No Netlify-local vault
20 * filesystem walk is required at apply: the propose-time `media_pointer` stamp is
21 * preferred over `resolveMediaPointerForAttach` (G22).
22 *
23 * @see docs/SEC-SEAM-MEDIA-FREEZE.md
24 * @see lib/task/task-hosted-proposal.mjs (pattern sibling)
25 */
26
27 import fs from 'fs';
28 import os from 'os';
29 import path from 'path';
30 import yaml from 'js-yaml';
31
32 import { parseCanisterProposalGetBody } from '../canister-proposal-response-parse.mjs';
33 import { noteStateIdFromParts } from '../note-state-id.mjs';
34 import { readNote, resolveVaultRelativePath } from '../vault.mjs';
35 import {
36 MEDIA_PROPOSAL_SOURCE,
37 precheckApprovedMediaProposal,
38 reconcileApprovedMediaProposal,
39 resolveMediaPointerForAttach,
40 } from './attachment-write.mjs';
41
42 export const FM_PROPOSAL_SOURCE = 'knowtation_proposal_source';
43 export const FM_MEDIA_PROPOSAL_KIND = 'media_proposal_kind';
44 export const FM_MEDIA_ATTACHMENT_ID = 'attachment_id';
45 export const FM_MEDIA_CONNECTOR_ID = 'connector_id';
46 export const FM_MEDIA_CONSENT_ID = 'consent_id';
47 export const FM_MEDIA_NOTE_REF = 'note_ref';
48 export const FM_MEDIA_POINTER = 'media_pointer';
49
50 /** Closed media kind set — anything else fails closed to null (SM-C2). */
51 const MEDIA_PROPOSAL_KINDS = new Set(['media_external_link', 'media_attach']);
52
53 /**
54 * @param {unknown} frontmatter
55 * @returns {Record<string, unknown>}
56 */
57 export function parseProposalFrontmatter(frontmatter) {
58 if (frontmatter == null) return {};
59 if (typeof frontmatter === 'object' && !Array.isArray(frontmatter)) {
60 return /** @type {Record<string, unknown>} */ (frontmatter);
61 }
62 if (typeof frontmatter === 'string' && frontmatter.trim()) {
63 try {
64 const parsed = JSON.parse(frontmatter);
65 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
66 ? /** @type {Record<string, unknown>} */ (parsed)
67 : {};
68 } catch {
69 return {};
70 }
71 }
72 return {};
73 }
74
75 /**
76 * Embed media metadata in canister frontmatter JSON (canister has no media_meta column).
77 *
78 * @param {Record<string, unknown>|undefined|null} baseFm
79 * @param {{
80 * proposal_kind: string,
81 * attachment_id?: string|null,
82 * connector_id?: string|null,
83 * consent_id?: string|null,
84 * note_ref?: string|null,
85 * media_pointer?: string|null,
86 * }} mediaMeta
87 * @returns {Record<string, unknown>}
88 */
89 export function mergeMediaFrontmatter(baseFm, mediaMeta) {
90 const fm = {
91 ...(baseFm && typeof baseFm === 'object' && !Array.isArray(baseFm) ? baseFm : {}),
92 };
93 fm[FM_PROPOSAL_SOURCE] = MEDIA_PROPOSAL_SOURCE;
94 fm[FM_MEDIA_PROPOSAL_KIND] = String(mediaMeta.proposal_kind || '').slice(0, 32);
95 if (mediaMeta.attachment_id != null && String(mediaMeta.attachment_id).trim()) {
96 fm[FM_MEDIA_ATTACHMENT_ID] = String(mediaMeta.attachment_id).slice(0, 64);
97 }
98 if (mediaMeta.connector_id != null && String(mediaMeta.connector_id).trim()) {
99 fm[FM_MEDIA_CONNECTOR_ID] = String(mediaMeta.connector_id).slice(0, 64);
100 }
101 if (mediaMeta.consent_id != null && String(mediaMeta.consent_id).trim()) {
102 fm[FM_MEDIA_CONSENT_ID] = String(mediaMeta.consent_id).slice(0, 64);
103 }
104 if (mediaMeta.note_ref != null && String(mediaMeta.note_ref).trim()) {
105 fm[FM_MEDIA_NOTE_REF] = String(mediaMeta.note_ref).slice(0, 512);
106 }
107 if (mediaMeta.media_pointer != null && String(mediaMeta.media_pointer).trim()) {
108 fm[FM_MEDIA_POINTER] = String(mediaMeta.media_pointer).slice(0, 256);
109 }
110 return fm;
111 }
112
113 /**
114 * @param {Record<string, unknown>} proposal
115 * @returns {Record<string, unknown>|null}
116 */
117 function bodyObjectOf(proposal) {
118 try {
119 const parsed = JSON.parse(typeof proposal.body === 'string' ? proposal.body : '');
120 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
121 ? /** @type {Record<string, unknown>} */ (parsed)
122 : null;
123 } catch {
124 return null;
125 }
126 }
127
128 /**
129 * @param {Record<string, unknown>|null|undefined} obj
130 * @param {string} key
131 * @returns {string}
132 */
133 function stringField(obj, key) {
134 if (!obj || typeof obj !== 'object') return '';
135 const v = /** @type {Record<string, unknown>} */ (obj)[key];
136 return typeof v === 'string' && v.trim() ? v.trim() : '';
137 }
138
139 /**
140 * Map a canister proposal row into the shape the shared media precheck / seam
141 * classification expect (`source: media` + `media_meta`) — SM-C2.
142 *
143 * Recognition (minimum union, mirroring the task normalizer G20): frontmatter
144 * `knowtation_proposal_source === 'media'`, OR `proposal.source === 'media'`, OR
145 * path prefix `meta/media/proposals/`. The proposal must additionally carry a
146 * resolvable media kind (`media_external_link` | `media_attach`) from frontmatter /
147 * `media_meta` / body JSON — otherwise fail-closed `null`.
148 *
149 * Total over arbitrary input (object guards + defensive parse); never throws.
150 *
151 * @param {Record<string, unknown>|null|undefined} proposal
152 * @returns {Record<string, unknown>|null}
153 */
154 export function normalizeCanisterProposalForMediaPrecheck(proposal) {
155 if (!proposal || typeof proposal !== 'object' || Array.isArray(proposal)) return null;
156
157 const fm = parseProposalFrontmatter(proposal.frontmatter);
158 const fromFm = fm[FM_PROPOSAL_SOURCE] === MEDIA_PROPOSAL_SOURCE;
159 const fromSource = proposal.source === MEDIA_PROPOSAL_SOURCE;
160 const fromPath =
161 typeof proposal.path === 'string' &&
162 proposal.path.replace(/^\/+/, '').startsWith('meta/media/proposals/');
163
164 if (!fromFm && !fromSource && !fromPath) return null;
165
166 const meta =
167 proposal.media_meta && typeof proposal.media_meta === 'object' && !Array.isArray(proposal.media_meta)
168 ? /** @type {Record<string, unknown>} */ (proposal.media_meta)
169 : null;
170 const body = bodyObjectOf(proposal);
171
172 const kind =
173 stringField(fm, FM_MEDIA_PROPOSAL_KIND) ||
174 stringField(fm, 'proposal_kind') ||
175 stringField(meta, 'proposal_kind') ||
176 stringField(meta, 'record_kind') ||
177 stringField(body, 'proposal_kind');
178 if (!MEDIA_PROPOSAL_KINDS.has(kind)) return null;
179
180 /** @type {Record<string, unknown>} */
181 const media_meta = {
182 record_kind: kind,
183 proposal_kind: kind,
184 };
185
186 const attachmentId =
187 stringField(fm, FM_MEDIA_ATTACHMENT_ID) ||
188 stringField(meta, 'attachment_id') ||
189 stringField(body, 'attachment_id');
190 if (attachmentId) media_meta.attachment_id = attachmentId;
191
192 const connectorId =
193 stringField(fm, FM_MEDIA_CONNECTOR_ID) ||
194 stringField(meta, 'connector_id') ||
195 stringField(body, 'connector_id');
196 media_meta.connector_id = connectorId || null;
197
198 const consentId =
199 stringField(fm, FM_MEDIA_CONSENT_ID) ||
200 stringField(meta, 'consent_id') ||
201 stringField(body, 'consent_id');
202 media_meta.consent_id = consentId || null;
203
204 const noteRef =
205 stringField(fm, FM_MEDIA_NOTE_REF) ||
206 stringField(meta, 'note_ref') ||
207 stringField(body, 'note_ref');
208 media_meta.note_ref = noteRef || null;
209
210 const mediaPointer =
211 stringField(fm, FM_MEDIA_POINTER) ||
212 stringField(meta, 'media_pointer') ||
213 stringField(body, 'media_pointer');
214 if (mediaPointer) media_meta.media_pointer = mediaPointer;
215
216 return {
217 ...proposal,
218 source: MEDIA_PROPOSAL_SOURCE,
219 media_meta,
220 };
221 }
222
223 /**
224 * POST a media proposal to the canister (hosted bridge propose path — SM-C3).
225 *
226 * Embeds media markers via {@link mergeMediaFrontmatter} so canister rows survive
227 * without a `media_meta` column. E1 create-time evaluation satisfaction runs through
228 * the existing `applyPersonalSelfApplyEvaluationE1` path (task hosted parity) — the
229 * E1 body carries `source: media` + `media_meta` so the T5 fingerprint can evaluate.
230 *
231 * @param {{
232 * canisterUrl: string,
233 * sessionBound?: boolean,
234 * headers: Record<string, string>,
235 * input: {
236 * path: string,
237 * body?: string,
238 * intent?: string,
239 * frontmatter?: Record<string, unknown>,
240 * base_state_id?: string,
241 * external_ref?: string,
242 * media_meta?: {
243 * record_kind?: string,
244 * proposal_kind: string,
245 * attachment_id?: string|null,
246 * connector_id?: string|null,
247 * consent_id?: string|null,
248 * note_ref?: string|null,
249 * media_pointer?: string|null,
250 * },
251 * vault_id?: string,
252 * review_queue?: string,
253 * proposed_by?: string,
254 * },
255 * }} opts
256 * @returns {Promise<Record<string, unknown>>}
257 */
258 export async function createMediaProposalOnCanister(opts) {
259 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
260 if (!base) {
261 const err = new Error('CANISTER_URL required for hosted media proposals');
262 err.status = 503;
263 err.code = 'NOT_AVAILABLE';
264 throw err;
265 }
266
267 const input = opts.input;
268 const mediaMeta = input.media_meta ?? { proposal_kind: '' };
269 const frontmatter = mergeMediaFrontmatter(input.frontmatter, {
270 proposal_kind: mediaMeta.proposal_kind,
271 attachment_id: mediaMeta.attachment_id,
272 connector_id: mediaMeta.connector_id,
273 consent_id: mediaMeta.consent_id,
274 note_ref: mediaMeta.note_ref,
275 media_pointer: mediaMeta.media_pointer,
276 });
277
278 /** @type {Record<string, unknown>} */
279 const payload = {
280 path: input.path,
281 body: input.body ?? '',
282 intent: input.intent ?? '',
283 frontmatter,
284 };
285 if (input.base_state_id) payload.base_state_id = input.base_state_id;
286 if (input.review_queue) payload.review_queue = input.review_queue;
287 if (input.external_ref) payload.external_ref = input.external_ref;
288
289 // E1 create-time satisfaction for admitted Media fingerprints (pending path allowed;
290 // Motoko rewrites meta/media/proposals/pending.json to the real proposal_id).
291 const { applyPersonalSelfApplyEvaluationE1 } = await import('../hub-proposal-personal-self-apply.mjs');
292 const e1Body = applyPersonalSelfApplyEvaluationE1(
293 {
294 ...payload,
295 source: MEDIA_PROPOSAL_SOURCE,
296 media_meta: input.media_meta,
297 external_ref: input.external_ref,
298 status: 'proposed',
299 },
300 {
301 evaluatedBy: typeof input.proposed_by === 'string' ? input.proposed_by : '',
302 authorActorId: typeof input.proposed_by === 'string' ? input.proposed_by : '',
303 sessionBound: opts.sessionBound === true,
304 },
305 );
306 if (e1Body.evaluation_status === 'passed') {
307 payload.evaluation_status = 'passed';
308 if (e1Body.evaluated_by) payload.evaluated_by = e1Body.evaluated_by;
309 if (e1Body.evaluated_at) payload.evaluated_at = e1Body.evaluated_at;
310 }
311
312 const res = await fetch(`${base}/api/v1/proposals`, {
313 method: 'POST',
314 headers: {
315 Accept: 'application/json',
316 'Content-Type': 'application/json',
317 ...opts.headers,
318 },
319 body: JSON.stringify(payload),
320 });
321
322 const text = await res.text();
323 /** @type {Record<string, unknown>} */
324 let json = {};
325 try {
326 json = text ? JSON.parse(text) : {};
327 } catch {
328 json = {};
329 }
330
331 if (!res.ok) {
332 const err = new Error(
333 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
334 );
335 err.status = res.status;
336 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
337 throw err;
338 }
339
340 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
341 if (!proposalId) {
342 const err = new Error('Canister proposal create missing proposal_id');
343 err.status = 502;
344 err.code = 'BAD_GATEWAY';
345 throw err;
346 }
347
348 const now = new Date().toISOString();
349 return {
350 proposal_id: proposalId,
351 path: typeof json.path === 'string' ? json.path : input.path,
352 status: typeof json.status === 'string' ? json.status : 'proposed',
353 vault_id: input.vault_id,
354 intent: input.intent,
355 body: input.body,
356 frontmatter,
357 base_state_id: input.base_state_id,
358 external_ref: input.external_ref,
359 source: MEDIA_PROPOSAL_SOURCE,
360 media_meta: input.media_meta,
361 review_queue: input.review_queue,
362 proposed_by: input.proposed_by,
363 evaluation_status: e1Body.evaluation_status,
364 created_at: now,
365 updated_at: now,
366 };
367 }
368
369 /**
370 * Fetch one proposal from the canister and normalize for media apply.
371 *
372 * @param {{
373 * canisterUrl: string,
374 * headers: Record<string, string>,
375 * proposalId: string,
376 * }} opts
377 * @returns {Promise<{ ok: true, proposal: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
378 */
379 export async function fetchCanisterProposalForMedia(opts) {
380 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
381 const proposalId = String(opts.proposalId || '').trim();
382 if (!base || !proposalId) {
383 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'canisterUrl and proposalId required' };
384 }
385
386 let res;
387 let text;
388 try {
389 res = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, {
390 method: 'GET',
391 headers: { Accept: 'application/json', ...opts.headers },
392 });
393 text = await res.text();
394 } catch (e) {
395 return { ok: false, status: 502, code: 'BAD_GATEWAY', error: e?.message || 'Canister fetch failed' };
396 }
397 if (!res.ok) {
398 return {
399 ok: false,
400 status: res.status === 404 ? 404 : 502,
401 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
402 error: text.slice(0, 200) || `Canister GET proposal ${res.status}`,
403 };
404 }
405
406 const raw = parseCanisterProposalGetBody(proposalId, text, {});
407 const normalized = normalizeCanisterProposalForMediaPrecheck(raw);
408 if (!normalized) {
409 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'Not a media proposal' };
410 }
411 return { ok: true, proposal: normalized };
412 }
413
414 /**
415 * @param {string} noteRef
416 * @returns {string}
417 */
418 export function notePathFromRef(noteRef) {
419 return noteRef.startsWith('note:') ? noteRef.slice(5) : noteRef;
420 }
421
422 /**
423 * Serialize a staged note exactly like lib/write.mjs `toMarkdown` (yaml frontmatter
424 * fence + body) so `readNote` / `noteStateIdFromParts` see the same shape at propose
425 * staging and at apply staging.
426 *
427 * @param {Record<string, unknown>} frontmatter
428 * @param {string} body
429 * @returns {string}
430 */
431 function stagedNoteMarkdown(frontmatter, body) {
432 const y = yaml.dump(frontmatter ?? {}, { lineWidth: -1, noRefs: true }).trimEnd();
433 return `---\n${y}\n---\n${body || ''}`;
434 }
435
436 /**
437 * GET one note from the canister notes surface.
438 *
439 * @param {{ canisterUrl: string, headers: Record<string, string>, notePath: string }} opts
440 * @returns {Promise<{ ok: true, frontmatter: Record<string, unknown>, body: string } | { ok: false, status: number, code: string, error: string }>}
441 */
442 export async function fetchCanisterNote(opts) {
443 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
444 let res;
445 let text;
446 try {
447 res = await fetch(`${base}/api/v1/notes/${encodeURIComponent(opts.notePath)}`, {
448 method: 'GET',
449 headers: { Accept: 'application/json', ...opts.headers },
450 });
451 text = await res.text();
452 } catch (e) {
453 return { ok: false, status: 502, code: 'BAD_GATEWAY', error: e?.message || 'Canister note fetch failed' };
454 }
455 if (!res.ok) {
456 return {
457 ok: false,
458 status: res.status === 404 ? 404 : 502,
459 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
460 error: text.slice(0, 200) || `Canister GET note ${res.status}`,
461 };
462 }
463 let json;
464 try {
465 json = JSON.parse(text);
466 } catch {
467 return { ok: false, status: 502, code: 'BAD_GATEWAY', error: 'Canister note response not JSON' };
468 }
469 const frontmatter = parseProposalFrontmatter(json?.frontmatter);
470 const body = typeof json?.body === 'string' ? json.body : '';
471 return { ok: true, frontmatter, body };
472 }
473
474 /**
475 * Stage one canister note into a fresh temp vault dir (SM-C5 temp-stage option).
476 *
477 * Also used by the bridge attach-propose route so `handleMediaAttachProposeRequest`
478 * can validate note existence + `base_state_id` against the canister-fresh note.
479 *
480 * `liveStateId` is the fingerprint of the **canister GET** frontmatter+body (same
481 * inputs Hub clients hash for `base_state_id`). It must NOT be recomputed from
482 * `readNote` after yaml-stage: `parseFrontmatterAndBody` `trimEnd`s the body, so
483 * trailing newlines present in the canister payload would flip `kn1_` and false-
484 * refuse with `MEDIA_LINEAGE_CONFLICT`.
485 *
486 * @param {{ canisterUrl: string, headers: Record<string, string>, notePath: string }} opts
487 * @returns {Promise<{ vaultPath: string, staged: boolean, cleanup: () => void, liveStateId?: string, canisterFrontmatter?: Record<string, unknown>, canisterBody?: string } | { error: { ok: false, status: number, code: string, error: string } }>}
488 */
489 export async function stageCanisterNoteToTempVault(opts) {
490 const vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-media-apply-'));
491 const cleanup = () => {
492 try {
493 fs.rmSync(vaultPath, { recursive: true, force: true });
494 } catch {
495 /* non-fatal */
496 }
497 };
498
499 const fetched = await fetchCanisterNote(opts);
500 if (!fetched.ok) {
501 if (fetched.status === 404) {
502 // Missing target note is a precheck concern: run the shared precheck against
503 // the empty staged vault so the refusal code stays MEDIA_LINEAGE_CONFLICT.
504 return { vaultPath, staged: false, cleanup };
505 }
506 cleanup();
507 return { error: fetched };
508 }
509
510 try {
511 const safe = resolveVaultRelativePath(vaultPath, opts.notePath);
512 const full = path.join(vaultPath, safe);
513 fs.mkdirSync(path.dirname(full), { recursive: true });
514 fs.writeFileSync(full, stagedNoteMarkdown(fetched.frontmatter, fetched.body), 'utf8');
515 } catch (e) {
516 cleanup();
517 return { error: { ok: false, status: 500, code: 'RUNTIME_ERROR', error: e?.message || 'stage failed' } };
518 }
519 const liveStateId = noteStateIdFromParts(fetched.frontmatter, fetched.body);
520 return {
521 vaultPath,
522 staged: true,
523 cleanup,
524 liveStateId,
525 canisterFrontmatter: fetched.frontmatter,
526 canisterBody: fetched.body,
527 };
528 }
529
530 /**
531 * Apply an approved canister media proposal on the bridge (SM-C4).
532 *
533 * Ordered per SM-C4: fetch + normalize (400) → approved gate (409, unless
534 * `requireApproved === false` for CHA-C11-style ops recovery) → shared precheck
535 * (refusal codes pass through untouched; no store/note mutate on refusal) → shared
536 * apply → payload. Blob hydrate/persist is the caller's job (bridge route wraps this
537 * in `withMediaBlobSync` — SM-C6).
538 *
539 * `media_external_link` runs entirely on bridge dataDir stores (G23).
540 * `media_attach` uses the temp-stage canister note read-modify-write (SM-C5): the
541 * shared precheck enforces `base_state_id` against the canister-fresh note, and the
542 * shared reconcile prefers the propose-time `media_pointer` stamp so no vault-wide
543 * mist walk runs on the bridge lambda (G22).
544 *
545 * @param {{
546 * dataDir: string,
547 * canisterUrl: string,
548 * headers: Record<string, string>,
549 * proposalId: string,
550 * requireApproved?: boolean,
551 * vaultId?: string,
552 * vaultPath?: string,
553 * vaultConfig?: object,
554 * }} opts
555 * @returns {Promise<{ ok: true, payload: Record<string, unknown> } | { ok: false, status: number, code: string, error: string }>}
556 */
557 export async function applyApprovedMediaProposalFromCanister(opts) {
558 const fetched = await fetchCanisterProposalForMedia({
559 canisterUrl: opts.canisterUrl,
560 headers: opts.headers,
561 proposalId: opts.proposalId,
562 });
563 if (!fetched.ok) return fetched;
564
565 const proposal = fetched.proposal;
566 // Canister rows carry no vault_id column; the shared precheck keys the
567 // connector/consent/external-ref stores by vault, so inject the bridge vault
568 // context (task/capture apply parity).
569 if (opts.vaultId && (typeof proposal.vault_id !== 'string' || !proposal.vault_id.trim())) {
570 proposal.vault_id = opts.vaultId;
571 }
572 if (opts.requireApproved !== false && proposal.status !== 'approved') {
573 return {
574 ok: false,
575 status: 409,
576 code: 'CONFLICT',
577 error: 'Proposal must be approved before media apply',
578 };
579 }
580
581 const meta = /** @type {Record<string, unknown>} */ (proposal.media_meta ?? {});
582 const proposalKind = typeof meta.proposal_kind === 'string' ? meta.proposal_kind : '';
583
584 if (proposalKind === 'media_external_link') {
585 const precheck = precheckApprovedMediaProposal(opts.dataDir, proposal, {
586 vaultPath: opts.vaultPath ?? opts.dataDir,
587 vaultConfig: opts.vaultConfig ?? {},
588 });
589 if (!precheck.ok) {
590 return { ok: false, status: precheck.status, code: precheck.code, error: precheck.error };
591 }
592 reconcileApprovedMediaProposal(opts.dataDir, precheck);
593 return {
594 ok: true,
595 payload: {
596 applied: true,
597 proposal_id: opts.proposalId,
598 vault_id: precheck.vaultId,
599 proposal_kind: precheck.proposalKind,
600 attachment_id: precheck.attachmentId,
601 connector_id: precheck.connectorId ?? null,
602 },
603 };
604 }
605
606 if (proposalKind === 'media_attach') {
607 const noteRefRaw = typeof meta.note_ref === 'string' && meta.note_ref.trim() ? meta.note_ref.trim() : '';
608 if (!noteRefRaw) {
609 return { ok: false, status: 400, code: 'MEDIA_DRAFT_INVALID', error: 'missing media note_ref' };
610 }
611 const notePath = notePathFromRef(noteRefRaw);
612
613 const staged = await stageCanisterNoteToTempVault({
614 canisterUrl: opts.canisterUrl,
615 headers: opts.headers,
616 notePath,
617 });
618 if ('error' in staged) return staged.error;
619
620 try {
621 const precheck = precheckApprovedMediaProposal(opts.dataDir, proposal, {
622 vaultPath: staged.vaultPath,
623 vaultConfig: opts.vaultConfig ?? {},
624 liveStateIdOverride: staged.liveStateId,
625 });
626 if (!precheck.ok) {
627 return { ok: false, status: precheck.status, code: precheck.code, error: precheck.error };
628 }
629
630 // Capture the note BEFORE reconcile: lib/write.mjs `writeNote` String()-coerces
631 // frontmatter arrays (existing quirk), which would turn attachments[] into a
632 // comma-joined string. Hosted RMW posts a real array to the canister notes
633 // surface, so we apply the same append logic the shared reconcile uses and
634 // serialize via stagedNoteMarkdown (yaml-preserving) before POST.
635 // Prefer canister GET body/frontmatter so we do not normalize trailing newlines
636 // via readNote trimEnd during the write-back.
637 const before =
638 staged.canisterFrontmatter != null && typeof staged.canisterBody === 'string'
639 ? { frontmatter: staged.canisterFrontmatter, body: staged.canisterBody }
640 : readNote(staged.vaultPath, precheck.notePath);
641 reconcileApprovedMediaProposal(opts.dataDir, precheck);
642
643 const pointer =
644 typeof precheck.mediaPointer === 'string' && precheck.mediaPointer.trim()
645 ? precheck.mediaPointer.trim()
646 : resolveMediaPointerForAttach(
647 staged.vaultPath,
648 opts.vaultConfig ?? {},
649 String(precheck.attachmentId || ''),
650 );
651 if (!pointer) {
652 return {
653 ok: false,
654 status: 500,
655 code: 'RUNTIME_ERROR',
656 error: 'media pointer could not be resolved at apply',
657 };
658 }
659 const fm = { ...(before.frontmatter ?? {}) };
660 const attachments = Array.isArray(fm.attachments) ? [...fm.attachments] : [];
661 if (!attachments.includes(pointer)) attachments.push(pointer);
662 fm.attachments = attachments;
663 fm.updated = new Date().toISOString();
664
665 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
666 let postRes;
667 let postText;
668 try {
669 postRes = await fetch(`${base}/api/v1/notes`, {
670 method: 'POST',
671 headers: {
672 Accept: 'application/json',
673 'Content-Type': 'application/json',
674 ...opts.headers,
675 },
676 body: JSON.stringify({
677 path: precheck.notePath,
678 body: before.body ?? '',
679 frontmatter: fm,
680 }),
681 });
682 postText = await postRes.text();
683 } catch (e) {
684 return { ok: false, status: 502, code: 'BAD_GATEWAY', error: e?.message || 'Canister note write failed' };
685 }
686 if (!postRes.ok) {
687 return {
688 ok: false,
689 status: 502,
690 code: 'BAD_GATEWAY',
691 error: (postText || '').slice(0, 200) || `Canister note write ${postRes.status}`,
692 };
693 }
694
695 return {
696 ok: true,
697 payload: {
698 applied: true,
699 proposal_id: opts.proposalId,
700 vault_id: precheck.vaultId,
701 proposal_kind: precheck.proposalKind,
702 attachment_id: precheck.attachmentId,
703 note_ref: precheck.noteRef,
704 },
705 };
706 } finally {
707 staged.cleanup();
708 }
709 }
710
711 return { ok: false, status: 400, code: 'MEDIA_DRAFT_INVALID', error: 'unknown media proposal_kind' };
712 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago