attachment-write.mjs
933 lines 29.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Media write proposal facade (Phase 2F-b-d-kn-b).
3 *
4 * Typed facade over `/proposals` (SD-4): external link + attach + import consent.
5 * Canonical mutation only at approve→apply via {@link reconcileApprovedMediaProposal}.
6 *
7 * @see docs/MEDIA-WRITE-SURFACES-CONTRACT-2F-b-d-kn.md
8 */
9
10 import fs from 'fs';
11 import path from 'path';
12 import crypto from 'crypto';
13
14 import { absentNoteStateId, noteStateIdFromParts } from '../note-state-id.mjs';
15 import { resolveFlowWriteAuthority } from '../flow/flow-scope.mjs';
16 import { resolveHandlerVisibleScopes } from '../flow/flow-handlers.mjs';
17 import { hashPrincipalRef } from '../agent/delegation.mjs';
18 import { readNote, noteFileExistsInVault } from '../vault.mjs';
19 import { writeNote } from '../write.mjs';
20 import { MIST_ID_RE } from './attachment-store.mjs';
21 import {
22 getAttachment,
23 deriveAttachmentId,
24 NOTE_REF_RE,
25 ATTACHMENT_ID_RE,
26 inferNoteScope,
27 } from './attachment-store.mjs';
28 import { resolveAttachmentVaultPath } from './attachment-handlers.mjs';
29 import {
30 CONNECTOR_ID_RE,
31 getEnabledConnector,
32 getVaultConnectors,
33 loadMediaConnectorPolicy,
34 saveMediaConnectorPolicy,
35 } from './media-connector-policy.mjs';
36 import {
37 CONSENT_ID_RE,
38 getActiveConsent,
39 listVaultConsents,
40 loadMediaImportConsentStore,
41 saveMediaImportConsentStore,
42 mintConsentId,
43 } from './media-import-consent.mjs';
44 import { getExternalRef, upsertExternalRef } from './attachment-external-ref-store.mjs';
45 import {
46 SCOOLING_MEDIA_EXTERNAL_REF_RE,
47 resolveOptionalScoolingExternalRef,
48 readProposeExternalRefRaw,
49 } from '../scooling-external-ref.mjs';
50
51 export const OPAQUE_REF_RE = /^[A-Za-z0-9._:#-]{1,256}$/;
52 export const MEDIA_WRITE_POLICY_FILE = 'hub_media_write_policy.json';
53 export const MEDIA_PROPOSAL_SCHEMA = 'knowtation.media_proposal/v0';
54 export const MEDIA_PROPOSAL_SOURCE = 'media';
55 export const MEDIA_REVIEW_QUEUE = 'media-writes';
56 export const MAX_MEDIA_INTENT_CHARS = 2000;
57
58 /** @typedef {'personal'|'project'|'org'} MediaScope */
59
60 /**
61 * @param {unknown} v
62 * @returns {boolean|null}
63 */
64 function envTriState(v) {
65 if (v === '1' || v === 'true') return true;
66 if (v === '0' || v === 'false') return false;
67 return null;
68 }
69
70 /**
71 * @param {string} dataDir
72 * @returns {{ media_external_link_enabled?: boolean, media_attach_enabled?: boolean }}
73 */
74 export function readMediaWritePolicyFile(dataDir) {
75 if (!dataDir) return {};
76 const fp = path.join(dataDir, MEDIA_WRITE_POLICY_FILE);
77 try {
78 if (!fs.existsSync(fp)) return {};
79 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
80 if (!j || typeof j !== 'object') return {};
81 const out = {};
82 if (typeof j.media_external_link_enabled === 'boolean') {
83 out.media_external_link_enabled = j.media_external_link_enabled;
84 }
85 if (typeof j.media_attach_enabled === 'boolean') {
86 out.media_attach_enabled = j.media_attach_enabled;
87 }
88 return out;
89 } catch {
90 return {};
91 }
92 }
93
94 /**
95 * @param {string} dataDir
96 * @returns {boolean}
97 */
98 export function getMediaExternalLinkEnabled(dataDir) {
99 const fromEnv = envTriState(process.env.MEDIA_EXTERNAL_LINK_ENABLED);
100 if (fromEnv !== null) return fromEnv;
101 return readMediaWritePolicyFile(dataDir).media_external_link_enabled === true;
102 }
103
104 /**
105 * @param {string} dataDir
106 * @returns {boolean}
107 */
108 export function getMediaAttachEnabled(dataDir) {
109 const fromEnv = envTriState(process.env.MEDIA_ATTACH_ENABLED);
110 if (fromEnv !== null) return fromEnv;
111 return readMediaWritePolicyFile(dataDir).media_attach_enabled === true;
112 }
113
114 /**
115 * @param {number} status
116 * @param {string} code
117 * @param {string} [error]
118 */
119 function refuse(status, code, error) {
120 return { ok: false, status, error: error ?? code, code };
121 }
122
123 /**
124 * @param {string} connectorId
125 * @param {string} opaqueRef
126 * @returns {string}
127 */
128 export function deriveLinkAttachmentId(connectorId, opaqueRef) {
129 const token = crypto
130 .createHash('sha256')
131 .update(`link:${connectorId}|${opaqueRef}`, 'utf8')
132 .digest('hex')
133 .slice(0, 32);
134 return `att_link_${token}`;
135 }
136
137 /**
138 * @param {Set<MediaScope>} visibleScopes
139 * @param {MediaScope} targetScope
140 */
141 export function resolveAttachmentWriteAuthority(visibleScopes, targetScope) {
142 const authority = resolveFlowWriteAuthority(visibleScopes, targetScope);
143 if (!authority.ok) {
144 return {
145 ok: false,
146 status: authority.status,
147 error:
148 authority.code === 'FLOW_SCOPE_DENIED'
149 ? 'Attachment write scope not authorized'
150 : authority.error,
151 code:
152 authority.code === 'FLOW_SCOPE_DENIED'
153 ? 'ATTACHMENT_SCOPE_DENIED'
154 : authority.code === 'FLOW_DRAFT_INVALID'
155 ? 'MEDIA_DRAFT_INVALID'
156 : authority.code,
157 };
158 }
159 return { ok: true };
160 }
161
162 /**
163 * @param {object} input
164 */
165 function resolveWriteScopes(input) {
166 return resolveHandlerVisibleScopes(input);
167 }
168
169 /**
170 * @param {string} noteRef
171 * @returns {string}
172 */
173 function notePathFromRef(noteRef) {
174 return noteRef.startsWith('note:') ? noteRef.slice(5) : noteRef;
175 }
176
177 /**
178 * @param {string} proposalId
179 * @returns {string}
180 */
181 function mediaProposalMirrorPath(proposalId) {
182 return `meta/media/proposals/${proposalId}.json`;
183 }
184
185 /**
186 * @param {string} dataDir
187 * @param {string} proposalId
188 */
189 function updateProposalPath(dataDir, proposalId) {
190 const fp = path.join(dataDir, 'hub_proposals.json');
191 if (!fs.existsSync(fp)) return;
192 const all = JSON.parse(fs.readFileSync(fp, 'utf8'));
193 const idx = all.findIndex((p) => p.proposal_id === proposalId);
194 if (idx >= 0) {
195 all[idx].path = mediaProposalMirrorPath(proposalId);
196 fs.writeFileSync(fp, JSON.stringify(all, null, 2), 'utf8');
197 }
198 }
199
200 /**
201 * @param {object} input
202 * @param {object} proposalInput
203 */
204 async function createProposalRecord(input, proposalInput) {
205 const withSession = {
206 ...proposalInput,
207 ...(typeof input.sessionBound === 'boolean' ? { session_bound: input.sessionBound } : {}),
208 };
209 return await Promise.resolve(input.createProposal(input.dataDir, withSession));
210 }
211
212 /**
213 * Optional Scooling media external_ref on propose (§FCA.4.2). Malformed → 400; absent → ok.
214 * @param {object} input
215 * @returns {{ ok: true, externalRef: string|undefined } | ReturnType<typeof refuse>}
216 */
217 function resolveMediaProposeExternalRef(input) {
218 const resolved = resolveOptionalScoolingExternalRef(
219 readProposeExternalRefRaw(input),
220 SCOOLING_MEDIA_EXTERNAL_REF_RE,
221 );
222 if (!resolved.ok) {
223 return refuse(resolved.status, resolved.code, resolved.error);
224 }
225 return { ok: true, externalRef: resolved.externalRef };
226 }
227
228
229 /**
230 * @param {string} vaultPath
231 * @param {object} vaultConfig
232 * @param {string} attachmentId
233 * @returns {string|null}
234 */
235 export function resolveMediaPointerForAttach(vaultPath, vaultConfig, attachmentId) {
236 if (attachmentId.startsWith('att_mist_')) {
237 const notesDir = path.join(vaultPath);
238 const walkNotes = (dir, prefix) => {
239 let entries;
240 try {
241 entries = fs.readdirSync(dir, { withFileTypes: true });
242 } catch {
243 return null;
244 }
245 for (const entry of entries) {
246 const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
247 const full = path.join(dir, entry.name);
248 if (entry.isDirectory()) {
249 const found = walkNotes(full, rel);
250 if (found) return found;
251 } else if (entry.isFile() && entry.name.endsWith('.md')) {
252 try {
253 const note = readNote(vaultPath, rel);
254 const attachments = note.frontmatter?.attachments;
255 if (!Array.isArray(attachments)) continue;
256 for (const raw of attachments) {
257 if (typeof raw !== 'string' || !MIST_ID_RE.test(raw)) continue;
258 if (deriveAttachmentId('mist', `mist:${raw}`) === attachmentId) {
259 return raw;
260 }
261 }
262 } catch {
263 /* skip unreadable */
264 }
265 }
266 }
267 return null;
268 };
269 return walkNotes(notesDir, '');
270 }
271 return attachmentId;
272 }
273
274 /**
275 * @param {object} fields
276 */
277 function buildMediaProposalEnvelope(fields) {
278 return {
279 ok: true,
280 payload: {
281 schema: MEDIA_PROPOSAL_SCHEMA,
282 proposal_id: fields.proposal_id,
283 proposal_kind: fields.proposal_kind,
284 attachment_id: fields.attachment_id,
285 note_ref: fields.note_ref ?? null,
286 connector_id: fields.connector_id ?? null,
287 scope: fields.scope,
288 base_state_id: fields.base_state_id,
289 external_ref: fields.external_ref ?? null,
290 auto_approvable: false,
291 status: 'proposed',
292 review_queue: MEDIA_REVIEW_QUEUE,
293 },
294 };
295 }
296
297 /**
298 * External-link proposal create.
299 *
300 * @param {object} input
301 */
302 export async function handleMediaLinkProposeRequest(input) {
303 if (!getMediaExternalLinkEnabled(input.dataDir)) {
304 return refuse(403, 'MEDIA_EXTERNAL_LINK_DISABLED', 'Media external link is disabled');
305 }
306 if (typeof input.createProposal !== 'function') {
307 return refuse(500, 'RUNTIME_ERROR', 'createProposal is required');
308 }
309
310 const intentRaw = typeof input.intent === 'string' ? input.intent.trim() : '';
311 if (!intentRaw) {
312 return refuse(400, 'MEDIA_DRAFT_INVALID', 'intent is required');
313 }
314 if (intentRaw.length > MAX_MEDIA_INTENT_CHARS) {
315 return refuse(400, 'MEDIA_DRAFT_INVALID', 'intent too long');
316 }
317
318 const resolved = resolveWriteScopes(input);
319 if (resolved.ambiguous) {
320 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
321 }
322
323 const body = input.body && typeof input.body === 'object' ? input.body : {};
324 const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
325 const connectorId = typeof body.connector_id === 'string' ? body.connector_id.trim() : '';
326 const opaqueRef = typeof body.opaque_ref === 'string' ? body.opaque_ref.trim() : '';
327 const consentId = typeof body.consent_id === 'string' ? body.consent_id.trim() : '';
328 const displayLabel =
329 typeof body.display_label === 'string' && body.display_label.trim()
330 ? body.display_label.trim().slice(0, 256)
331 : connectorId || 'External link';
332
333 if (!scope || !['personal', 'project', 'org'].includes(scope)) {
334 return refuse(400, 'MEDIA_DRAFT_INVALID', 'scope is required');
335 }
336 if (!CONNECTOR_ID_RE.test(connectorId)) {
337 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid connector_id');
338 }
339 if (!OPAQUE_REF_RE.test(opaqueRef)) {
340 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid opaque_ref');
341 }
342 if (!CONSENT_ID_RE.test(consentId)) {
343 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid consent_id');
344 }
345
346 const authority = resolveAttachmentWriteAuthority(resolved.visibleScopes, /** @type {MediaScope} */ (scope));
347 if (!authority.ok) return authority;
348
349 if (!getEnabledConnector(input.dataDir, input.vaultId, connectorId)) {
350 return refuse(403, 'MEDIA_CONNECTOR_DENIED', 'Connector not allowlisted');
351 }
352
353 const consentStore = loadMediaImportConsentStore(input.dataDir);
354 const consentRecord = consentStore.vaults?.[input.vaultId]?.consents?.[consentId];
355 if (
356 !consentRecord ||
357 consentRecord.status !== 'active' ||
358 consentRecord.connector_id !== connectorId ||
359 consentRecord.scope !== scope
360 ) {
361 return refuse(403, 'MEDIA_IMPORT_CONSENT_REQUIRED', 'Active import consent required');
362 }
363 if (consentRecord.expires_at != null) {
364 const exp = new Date(consentRecord.expires_at).getTime();
365 if (!Number.isNaN(exp) && exp <= Date.now()) {
366 return refuse(403, 'MEDIA_IMPORT_CONSENT_REQUIRED', 'Import consent expired');
367 }
368 }
369
370 const attachmentId = deriveLinkAttachmentId(connectorId, opaqueRef);
371 if (!ATTACHMENT_ID_RE.test(attachmentId)) {
372 return refuse(400, 'MEDIA_DRAFT_INVALID', 'derived attachment_id invalid');
373 }
374
375 if (getExternalRef(input.dataDir, input.vaultId, attachmentId)) {
376 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'External reference already exists');
377 }
378
379 const baseStateId = absentNoteStateId();
380 const proposalBody = JSON.stringify(
381 {
382 proposal_kind: 'media_external_link',
383 connector_id: connectorId,
384 opaque_ref: opaqueRef,
385 display_label: displayLabel,
386 consent_id: consentId,
387 scope,
388 attachment_id: attachmentId,
389 },
390 null,
391 2,
392 );
393
394 const ext = resolveMediaProposeExternalRef(input);
395 if (!ext.ok) return ext;
396
397 const proposal = await createProposalRecord(input, {
398 path: mediaProposalMirrorPath('pending'),
399 body: proposalBody,
400 frontmatter: {
401 type: 'media_proposal',
402 proposal_kind: 'media_external_link',
403 attachment_id: attachmentId,
404 },
405 intent: intentRaw,
406 base_state_id: baseStateId,
407 source: MEDIA_PROPOSAL_SOURCE,
408 vault_id: input.vaultId,
409 proposed_by:
410 typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
411 review_queue: MEDIA_REVIEW_QUEUE,
412 ...(ext.externalRef ? { external_ref: ext.externalRef } : {}),
413 media_meta: {
414 record_kind: 'media_external_link',
415 proposal_kind: 'media_external_link',
416 attachment_id: attachmentId,
417 connector_id: connectorId,
418 consent_id: consentId,
419 note_ref: null,
420 },
421 });
422
423 updateProposalPath(input.dataDir, proposal.proposal_id);
424
425 return buildMediaProposalEnvelope({
426 proposal_id: proposal.proposal_id,
427 proposal_kind: 'media_external_link',
428 attachment_id: attachmentId,
429 note_ref: null,
430 connector_id: connectorId,
431 scope,
432 base_state_id: baseStateId,
433 external_ref: proposal.external_ref ?? null,
434 });
435 }
436
437 /**
438 * Attach proposal create.
439 *
440 * @param {object} input
441 */
442 export async function handleMediaAttachProposeRequest(input) {
443 if (!getMediaAttachEnabled(input.dataDir)) {
444 return refuse(403, 'MEDIA_ATTACH_DISABLED', 'Media attach is disabled');
445 }
446 if (typeof input.createProposal !== 'function') {
447 return refuse(500, 'RUNTIME_ERROR', 'createProposal is required');
448 }
449
450 const intentRaw = typeof input.intent === 'string' ? input.intent.trim() : '';
451 if (!intentRaw) {
452 return refuse(400, 'MEDIA_DRAFT_INVALID', 'intent is required');
453 }
454 if (intentRaw.length > MAX_MEDIA_INTENT_CHARS) {
455 return refuse(400, 'MEDIA_DRAFT_INVALID', 'intent too long');
456 }
457
458 const resolved = resolveWriteScopes(input);
459 if (resolved.ambiguous) {
460 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
461 }
462
463 const body = input.body && typeof input.body === 'object' ? input.body : {};
464 const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
465 const attachmentId = typeof body.attachment_id === 'string' ? body.attachment_id.trim() : '';
466 const noteRef = typeof body.note_ref === 'string' ? body.note_ref.trim() : '';
467 const baseStateId = typeof body.base_state_id === 'string' ? body.base_state_id.trim() : '';
468
469 if (!scope || !['personal', 'project', 'org'].includes(scope)) {
470 return refuse(400, 'MEDIA_DRAFT_INVALID', 'scope is required');
471 }
472 if (!ATTACHMENT_ID_RE.test(attachmentId)) {
473 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid attachment_id');
474 }
475 if (!NOTE_REF_RE.test(noteRef)) {
476 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid note_ref');
477 }
478 if (!baseStateId.startsWith('kn1_')) {
479 return refuse(400, 'MEDIA_DRAFT_INVALID', 'base_state_id is required');
480 }
481
482 const payloadScopeAuthority = resolveAttachmentWriteAuthority(
483 resolved.visibleScopes,
484 /** @type {MediaScope} */ (scope),
485 );
486 if (!payloadScopeAuthority.ok) return payloadScopeAuthority;
487
488 const vaultPath = resolveAttachmentVaultPath(input.dataDir, input.vaultPath);
489 const vaultConfig = input.vaultConfig ?? {};
490
491 const media = getAttachment(input.dataDir, vaultPath, input.vaultId, attachmentId, {
492 visibleScopes: resolved.visibleScopes,
493 mediaSubdir: input.mediaSubdir,
494 hubScope: input.hubScope ?? null,
495 vaultConfig,
496 });
497 if (!media) {
498 return refuse(404, 'unknown_attachment', 'unknown_attachment');
499 }
500
501 const notePath = notePathFromRef(noteRef);
502 if (!noteFileExistsInVault(vaultPath, notePath)) {
503 return refuse(404, 'unknown_note', 'unknown_note');
504 }
505
506 let note;
507 try {
508 note = readNote(vaultPath, notePath);
509 } catch {
510 return refuse(404, 'unknown_note', 'unknown_note');
511 }
512
513 const noteScope = inferNoteScope(note);
514 const authority = resolveAttachmentWriteAuthority(resolved.visibleScopes, noteScope);
515 if (!authority.ok) {
516 if (authority.code === 'ATTACHMENT_SCOPE_DENIED') {
517 return refuse(404, 'unknown_note', 'unknown_note');
518 }
519 return authority;
520 }
521
522 // Hosted bridge may pass liveStateIdOverride from the canister GET fingerprint so
523 // yaml-stage → readNote trimEnd cannot false-conflict with the client's base_state_id.
524 const liveStateId =
525 typeof input.liveStateIdOverride === 'string' && input.liveStateIdOverride.startsWith('kn1_')
526 ? input.liveStateIdOverride
527 : noteStateIdFromParts(note.frontmatter ?? {}, note.body ?? '');
528 if (liveStateId !== baseStateId) {
529 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Note changed since base_state_id was captured');
530 }
531
532 const proposalBody = JSON.stringify(
533 {
534 proposal_kind: 'media_attach',
535 attachment_id: attachmentId,
536 note_ref: noteRef,
537 scope,
538 base_state_id: baseStateId,
539 },
540 null,
541 2,
542 );
543
544 const ext = resolveMediaProposeExternalRef(input);
545 if (!ext.ok) return ext;
546
547 const proposal = await createProposalRecord(input, {
548 path: mediaProposalMirrorPath('pending'),
549 body: proposalBody,
550 frontmatter: {
551 type: 'media_proposal',
552 proposal_kind: 'media_attach',
553 attachment_id: attachmentId,
554 note_ref: noteRef,
555 },
556 intent: intentRaw,
557 base_state_id: baseStateId,
558 source: MEDIA_PROPOSAL_SOURCE,
559 vault_id: input.vaultId,
560 proposed_by:
561 typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
562 review_queue: MEDIA_REVIEW_QUEUE,
563 ...(ext.externalRef ? { external_ref: ext.externalRef } : {}),
564 media_meta: {
565 record_kind: 'media_attach',
566 proposal_kind: 'media_attach',
567 attachment_id: attachmentId,
568 connector_id: null,
569 consent_id: null,
570 note_ref: noteRef,
571 },
572 });
573
574 updateProposalPath(input.dataDir, proposal.proposal_id);
575
576 return buildMediaProposalEnvelope({
577 proposal_id: proposal.proposal_id,
578 proposal_kind: 'media_attach',
579 attachment_id: attachmentId,
580 note_ref: noteRef,
581 connector_id: null,
582 scope,
583 base_state_id: baseStateId,
584 external_ref: proposal.external_ref ?? null,
585 });
586 }
587
588 /**
589 * Grant import consent (human writer only — not MCP write).
590 *
591 * @param {object} input
592 */
593 export function handleMediaImportConsentGrantRequest(input) {
594 if (!getMediaExternalLinkEnabled(input.dataDir)) {
595 return refuse(403, 'MEDIA_EXTERNAL_LINK_DISABLED', 'Media external link is disabled');
596 }
597
598 const body = input.body && typeof input.body === 'object' ? input.body : {};
599 const connectorId = typeof body.connector_id === 'string' ? body.connector_id.trim() : '';
600 const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
601 const expiresAt =
602 body.expires_at === null || body.expires_at === undefined
603 ? null
604 : typeof body.expires_at === 'string'
605 ? body.expires_at.trim()
606 : null;
607
608 if (!CONNECTOR_ID_RE.test(connectorId)) {
609 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid connector_id');
610 }
611 if (!scope || !['personal', 'project', 'org'].includes(scope)) {
612 return refuse(400, 'MEDIA_DRAFT_INVALID', 'scope is required');
613 }
614 if (expiresAt != null && Number.isNaN(new Date(expiresAt).getTime())) {
615 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid expires_at');
616 }
617
618 const resolved = resolveWriteScopes(input);
619 if (resolved.ambiguous) {
620 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
621 }
622
623 const authority = resolveAttachmentWriteAuthority(resolved.visibleScopes, /** @type {MediaScope} */ (scope));
624 if (!authority.ok) return authority;
625
626 if (!getEnabledConnector(input.dataDir, input.vaultId, connectorId)) {
627 return refuse(403, 'MEDIA_CONNECTOR_DENIED', 'Connector not allowlisted');
628 }
629
630 const userId = typeof input.userId === 'string' ? input.userId.trim() : '';
631 const grantedBy = userId ? hashPrincipalRef(userId) : 'uid_hash:' + '0'.repeat(64);
632
633 const consentId = mintConsentId();
634 const now = new Date().toISOString();
635 const store = loadMediaImportConsentStore(input.dataDir);
636 if (!store.vaults[input.vaultId]) {
637 store.vaults[input.vaultId] = { consents: {} };
638 }
639 if (!store.vaults[input.vaultId].consents) {
640 store.vaults[input.vaultId].consents = {};
641 }
642 store.vaults[input.vaultId].consents[consentId] = {
643 connector_id: connectorId,
644 scope: /** @type {MediaScope} */ (scope),
645 granted_by: grantedBy,
646 granted_at: now,
647 expires_at: expiresAt,
648 status: 'active',
649 };
650 saveMediaImportConsentStore(input.dataDir, store);
651
652 return {
653 ok: true,
654 payload: {
655 schema: 'knowtation.media_import_consent/v0',
656 consent_id: consentId,
657 connector_id: connectorId,
658 scope,
659 granted_by: grantedBy,
660 granted_at: now,
661 expires_at: expiresAt,
662 status: 'active',
663 },
664 };
665 }
666
667 /**
668 * List import consents (read-only surface).
669 *
670 * @param {object} input
671 */
672 export function handleMediaImportConsentListRequest(input) {
673 const resolved = resolveWriteScopes(input);
674 if (resolved.ambiguous) {
675 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
676 }
677
678 const scopeFilter =
679 typeof input.scope === 'string' && input.scope.trim() ? input.scope.trim() : undefined;
680 if (scopeFilter && !['personal', 'project', 'org'].includes(scopeFilter)) {
681 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid scope filter');
682 }
683 if (scopeFilter) {
684 const authority = resolveAttachmentWriteAuthority(
685 resolved.visibleScopes,
686 /** @type {MediaScope} */ (scopeFilter),
687 );
688 if (!authority.ok) return authority;
689 }
690
691 const rows = listVaultConsents(input.dataDir, input.vaultId, scopeFilter);
692 const visible = rows.filter((row) => resolved.visibleScopes.has(row.record.scope));
693
694 return {
695 ok: true,
696 payload: {
697 schema: 'knowtation.media_import_consent_list/v0',
698 vault_id: input.vaultId,
699 consents: visible.map(({ consent_id, record }) => ({
700 consent_id,
701 connector_id: record.connector_id,
702 scope: record.scope,
703 granted_by: record.granted_by,
704 granted_at: record.granted_at,
705 expires_at: record.expires_at,
706 status: record.status,
707 })),
708 },
709 };
710 }
711
712 /**
713 * Revoke import consent.
714 *
715 * @param {object} input
716 */
717 export function handleMediaImportConsentRevokeRequest(input) {
718 const consentId =
719 typeof input.consentId === 'string'
720 ? input.consentId.trim()
721 : typeof input.body?.consent_id === 'string'
722 ? input.body.consent_id.trim()
723 : '';
724 if (!CONSENT_ID_RE.test(consentId)) {
725 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid consent_id');
726 }
727
728 const store = loadMediaImportConsentStore(input.dataDir);
729 const record = store.vaults?.[input.vaultId]?.consents?.[consentId];
730 if (!record) {
731 return refuse(404, 'NOT_FOUND', 'Consent not found');
732 }
733
734 const resolved = resolveWriteScopes(input);
735 if (resolved.ambiguous) {
736 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
737 }
738
739 const authority = resolveAttachmentWriteAuthority(resolved.visibleScopes, record.scope);
740 if (!authority.ok) return authority;
741
742 record.status = 'revoked';
743 saveMediaImportConsentStore(input.dataDir, store);
744
745 return {
746 ok: true,
747 payload: {
748 schema: 'knowtation.media_import_consent/v0',
749 consent_id: consentId,
750 status: 'revoked',
751 },
752 };
753 }
754
755 /**
756 * @param {object} proposal
757 * @returns {object|null}
758 */
759 function parseMediaProposalBody(proposal) {
760 try {
761 const parsed = JSON.parse(proposal.body || '{}');
762 return parsed && typeof parsed === 'object' ? parsed : null;
763 } catch {
764 return null;
765 }
766 }
767
768 /**
769 * Approve-time authoritative re-check for media proposals.
770 *
771 * @param {string} dataDir
772 * @param {object} proposal
773 * @param {{ vaultPath: string, vaultConfig?: object, mediaSubdir?: string, liveStateIdOverride?: string }} ctx
774 */
775 export function precheckApprovedMediaProposal(dataDir, proposal, ctx) {
776 const vaultId = proposal.vault_id ?? 'default';
777 const meta = proposal.media_meta;
778 const parsed = parseMediaProposalBody(proposal);
779 const proposalKind =
780 meta?.proposal_kind || parsed?.proposal_kind || meta?.record_kind || parsed?.proposal_kind;
781
782 if (!proposalKind) {
783 return refuse(400, 'MEDIA_DRAFT_INVALID', 'missing media proposal_kind');
784 }
785
786 if (proposalKind === 'media_external_link') {
787 const connectorId = meta?.connector_id || parsed?.connector_id;
788 const opaqueRef = parsed?.opaque_ref;
789 const consentId = meta?.consent_id || parsed?.consent_id;
790 const scope = parsed?.scope || meta?.scope;
791 const attachmentId = meta?.attachment_id || parsed?.attachment_id;
792
793 if (!getEnabledConnector(dataDir, vaultId, connectorId)) {
794 return refuse(403, 'MEDIA_CONNECTOR_DENIED', 'Connector not allowlisted');
795 }
796
797 const consentStore = loadMediaImportConsentStore(dataDir);
798 const consentRecord = consentStore.vaults?.[vaultId]?.consents?.[consentId];
799 if (
800 !consentRecord ||
801 consentRecord.status !== 'active' ||
802 consentRecord.connector_id !== connectorId
803 ) {
804 return refuse(403, 'MEDIA_IMPORT_CONSENT_REQUIRED', 'Active import consent required');
805 }
806 if (consentRecord.expires_at != null) {
807 const exp = new Date(consentRecord.expires_at).getTime();
808 if (!Number.isNaN(exp) && exp <= Date.now()) {
809 return refuse(403, 'MEDIA_IMPORT_CONSENT_REQUIRED', 'Import consent expired');
810 }
811 }
812
813 if (getExternalRef(dataDir, vaultId, attachmentId)) {
814 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'External reference already exists');
815 }
816
817 return {
818 ok: true,
819 vaultId,
820 proposalKind,
821 attachmentId,
822 connectorId,
823 opaqueRef,
824 consentId,
825 scope,
826 displayLabel: parsed?.display_label || connectorId,
827 };
828 }
829
830 if (proposalKind === 'media_attach') {
831 const noteRef = meta?.note_ref || parsed?.note_ref;
832 const attachmentId = meta?.attachment_id || parsed?.attachment_id;
833 const baseStateId = proposal.base_state_id || parsed?.base_state_id;
834 // SEC-SEAM-MEDIA SM-C5: propose-time media_pointer stamp (media_meta / body JSON).
835 // Hosted apply prefers this over the vault-wide mist walk (G22).
836 const mediaPointerRaw = meta?.media_pointer ?? parsed?.media_pointer;
837 const mediaPointer =
838 typeof mediaPointerRaw === 'string' && mediaPointerRaw.trim() ? mediaPointerRaw.trim() : null;
839 const vaultPath = ctx.vaultPath;
840 const notePath = notePathFromRef(noteRef);
841
842 if (!noteFileExistsInVault(vaultPath, notePath)) {
843 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Target note missing at approve');
844 }
845
846 let note;
847 try {
848 note = readNote(vaultPath, notePath);
849 } catch {
850 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Target note unreadable at approve');
851 }
852
853 const liveStateId =
854 typeof ctx.liveStateIdOverride === 'string' && ctx.liveStateIdOverride.startsWith('kn1_')
855 ? ctx.liveStateIdOverride
856 : noteStateIdFromParts(note.frontmatter ?? {}, note.body ?? '');
857 if (liveStateId !== baseStateId) {
858 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Note changed since proposal was created');
859 }
860
861 return {
862 ok: true,
863 vaultId,
864 proposalKind,
865 attachmentId,
866 noteRef,
867 notePath,
868 baseStateId,
869 mediaPointer,
870 vaultPath,
871 vaultConfig: ctx.vaultConfig ?? {},
872 };
873 }
874
875 return refuse(400, 'MEDIA_DRAFT_INVALID', 'unknown media proposal_kind');
876 }
877
878 /**
879 * Apply a pre-checked media proposal — external ref store or note frontmatter only.
880 *
881 * @param {string} dataDir
882 * @param {object} applyCtx
883 */
884 export function reconcileApprovedMediaProposal(dataDir, applyCtx) {
885 const kind = applyCtx.proposalKind;
886
887 if (kind === 'media_external_link') {
888 upsertExternalRef(dataDir, applyCtx.vaultId, applyCtx.attachmentId, {
889 connector_id: applyCtx.connectorId,
890 opaque_ref: applyCtx.opaqueRef,
891 scope: applyCtx.scope,
892 display_label: applyCtx.displayLabel,
893 consent_id: applyCtx.consentId,
894 created: new Date().toISOString(),
895 updated: new Date().toISOString(),
896 });
897 return { applied: true, attachment_id: applyCtx.attachmentId };
898 }
899
900 if (kind === 'media_attach') {
901 const note = readNote(applyCtx.vaultPath, applyCtx.notePath);
902 const fm = { ...(note.frontmatter ?? {}) };
903 const attachments = Array.isArray(fm.attachments) ? [...fm.attachments] : [];
904 // SEC-SEAM-MEDIA SM-C5: prefer the propose-time media_pointer stamp; the vault
905 // walk stays the self-hosted fallback for pre-stamp proposals only (G22).
906 const pointer =
907 typeof applyCtx.mediaPointer === 'string' && applyCtx.mediaPointer.trim()
908 ? applyCtx.mediaPointer.trim()
909 : resolveMediaPointerForAttach(
910 applyCtx.vaultPath,
911 applyCtx.vaultConfig,
912 applyCtx.attachmentId,
913 );
914 if (!pointer) {
915 throw new Error('media pointer could not be resolved at apply');
916 }
917 if (!attachments.includes(pointer)) {
918 attachments.push(pointer);
919 }
920 fm.attachments = attachments;
921 fm.updated = new Date().toISOString();
922 writeNote(applyCtx.vaultPath, applyCtx.notePath, {
923 body: note.body ?? '',
924 frontmatter: fm,
925 });
926 return { applied: true, attachment_id: applyCtx.attachmentId, note_ref: applyCtx.noteRef };
927 }
928
929 throw new Error(`unsupported media proposal_kind at apply: ${kind}`);
930 }
931
932 export { CONNECTOR_ID_RE, getVaultConnectors, loadMediaConnectorPolicy, saveMediaConnectorPolicy } from './media-connector-policy.mjs';
933 export { CONSENT_ID_RE } from './media-import-consent.mjs';
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago