attachment-write.mjs
914 lines 28.2 KB
Raw
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 41 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 const liveStateId = noteStateIdFromParts(note.frontmatter ?? {}, note.body ?? '');
523 if (liveStateId !== baseStateId) {
524 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Note changed since base_state_id was captured');
525 }
526
527 const proposalBody = JSON.stringify(
528 {
529 proposal_kind: 'media_attach',
530 attachment_id: attachmentId,
531 note_ref: noteRef,
532 scope,
533 base_state_id: baseStateId,
534 },
535 null,
536 2,
537 );
538
539 const ext = resolveMediaProposeExternalRef(input);
540 if (!ext.ok) return ext;
541
542 const proposal = await createProposalRecord(input, {
543 path: mediaProposalMirrorPath('pending'),
544 body: proposalBody,
545 frontmatter: {
546 type: 'media_proposal',
547 proposal_kind: 'media_attach',
548 attachment_id: attachmentId,
549 note_ref: noteRef,
550 },
551 intent: intentRaw,
552 base_state_id: baseStateId,
553 source: MEDIA_PROPOSAL_SOURCE,
554 vault_id: input.vaultId,
555 proposed_by:
556 typeof input.userId === 'string' && input.userId.trim() ? input.userId.trim() : undefined,
557 review_queue: MEDIA_REVIEW_QUEUE,
558 ...(ext.externalRef ? { external_ref: ext.externalRef } : {}),
559 media_meta: {
560 record_kind: 'media_attach',
561 proposal_kind: 'media_attach',
562 attachment_id: attachmentId,
563 connector_id: null,
564 consent_id: null,
565 note_ref: noteRef,
566 },
567 });
568
569 updateProposalPath(input.dataDir, proposal.proposal_id);
570
571 return buildMediaProposalEnvelope({
572 proposal_id: proposal.proposal_id,
573 proposal_kind: 'media_attach',
574 attachment_id: attachmentId,
575 note_ref: noteRef,
576 connector_id: null,
577 scope,
578 base_state_id: baseStateId,
579 external_ref: proposal.external_ref ?? null,
580 });
581 }
582
583 /**
584 * Grant import consent (human writer only — not MCP write).
585 *
586 * @param {object} input
587 */
588 export function handleMediaImportConsentGrantRequest(input) {
589 if (!getMediaExternalLinkEnabled(input.dataDir)) {
590 return refuse(403, 'MEDIA_EXTERNAL_LINK_DISABLED', 'Media external link is disabled');
591 }
592
593 const body = input.body && typeof input.body === 'object' ? input.body : {};
594 const connectorId = typeof body.connector_id === 'string' ? body.connector_id.trim() : '';
595 const scope = typeof body.scope === 'string' ? body.scope.trim() : '';
596 const expiresAt =
597 body.expires_at === null || body.expires_at === undefined
598 ? null
599 : typeof body.expires_at === 'string'
600 ? body.expires_at.trim()
601 : null;
602
603 if (!CONNECTOR_ID_RE.test(connectorId)) {
604 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid connector_id');
605 }
606 if (!scope || !['personal', 'project', 'org'].includes(scope)) {
607 return refuse(400, 'MEDIA_DRAFT_INVALID', 'scope is required');
608 }
609 if (expiresAt != null && Number.isNaN(new Date(expiresAt).getTime())) {
610 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid expires_at');
611 }
612
613 const resolved = resolveWriteScopes(input);
614 if (resolved.ambiguous) {
615 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
616 }
617
618 const authority = resolveAttachmentWriteAuthority(resolved.visibleScopes, /** @type {MediaScope} */ (scope));
619 if (!authority.ok) return authority;
620
621 if (!getEnabledConnector(input.dataDir, input.vaultId, connectorId)) {
622 return refuse(403, 'MEDIA_CONNECTOR_DENIED', 'Connector not allowlisted');
623 }
624
625 const userId = typeof input.userId === 'string' ? input.userId.trim() : '';
626 const grantedBy = userId ? hashPrincipalRef(userId) : 'uid_hash:' + '0'.repeat(64);
627
628 const consentId = mintConsentId();
629 const now = new Date().toISOString();
630 const store = loadMediaImportConsentStore(input.dataDir);
631 if (!store.vaults[input.vaultId]) {
632 store.vaults[input.vaultId] = { consents: {} };
633 }
634 if (!store.vaults[input.vaultId].consents) {
635 store.vaults[input.vaultId].consents = {};
636 }
637 store.vaults[input.vaultId].consents[consentId] = {
638 connector_id: connectorId,
639 scope: /** @type {MediaScope} */ (scope),
640 granted_by: grantedBy,
641 granted_at: now,
642 expires_at: expiresAt,
643 status: 'active',
644 };
645 saveMediaImportConsentStore(input.dataDir, store);
646
647 return {
648 ok: true,
649 payload: {
650 schema: 'knowtation.media_import_consent/v0',
651 consent_id: consentId,
652 connector_id: connectorId,
653 scope,
654 granted_by: grantedBy,
655 granted_at: now,
656 expires_at: expiresAt,
657 status: 'active',
658 },
659 };
660 }
661
662 /**
663 * List import consents (read-only surface).
664 *
665 * @param {object} input
666 */
667 export function handleMediaImportConsentListRequest(input) {
668 const resolved = resolveWriteScopes(input);
669 if (resolved.ambiguous) {
670 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
671 }
672
673 const scopeFilter =
674 typeof input.scope === 'string' && input.scope.trim() ? input.scope.trim() : undefined;
675 if (scopeFilter && !['personal', 'project', 'org'].includes(scopeFilter)) {
676 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid scope filter');
677 }
678 if (scopeFilter) {
679 const authority = resolveAttachmentWriteAuthority(
680 resolved.visibleScopes,
681 /** @type {MediaScope} */ (scopeFilter),
682 );
683 if (!authority.ok) return authority;
684 }
685
686 const rows = listVaultConsents(input.dataDir, input.vaultId, scopeFilter);
687 const visible = rows.filter((row) => resolved.visibleScopes.has(row.record.scope));
688
689 return {
690 ok: true,
691 payload: {
692 schema: 'knowtation.media_import_consent_list/v0',
693 vault_id: input.vaultId,
694 consents: visible.map(({ consent_id, record }) => ({
695 consent_id,
696 connector_id: record.connector_id,
697 scope: record.scope,
698 granted_by: record.granted_by,
699 granted_at: record.granted_at,
700 expires_at: record.expires_at,
701 status: record.status,
702 })),
703 },
704 };
705 }
706
707 /**
708 * Revoke import consent.
709 *
710 * @param {object} input
711 */
712 export function handleMediaImportConsentRevokeRequest(input) {
713 const consentId =
714 typeof input.consentId === 'string'
715 ? input.consentId.trim()
716 : typeof input.body?.consent_id === 'string'
717 ? input.body.consent_id.trim()
718 : '';
719 if (!CONSENT_ID_RE.test(consentId)) {
720 return refuse(400, 'MEDIA_DRAFT_INVALID', 'invalid consent_id');
721 }
722
723 const store = loadMediaImportConsentStore(input.dataDir);
724 const record = store.vaults?.[input.vaultId]?.consents?.[consentId];
725 if (!record) {
726 return refuse(404, 'NOT_FOUND', 'Consent not found');
727 }
728
729 const resolved = resolveWriteScopes(input);
730 if (resolved.ambiguous) {
731 return refuse(400, 'ATTACHMENT_SCOPE_AMBIGUOUS', 'Ambiguous attachment scope');
732 }
733
734 const authority = resolveAttachmentWriteAuthority(resolved.visibleScopes, record.scope);
735 if (!authority.ok) return authority;
736
737 record.status = 'revoked';
738 saveMediaImportConsentStore(input.dataDir, store);
739
740 return {
741 ok: true,
742 payload: {
743 schema: 'knowtation.media_import_consent/v0',
744 consent_id: consentId,
745 status: 'revoked',
746 },
747 };
748 }
749
750 /**
751 * @param {object} proposal
752 * @returns {object|null}
753 */
754 function parseMediaProposalBody(proposal) {
755 try {
756 const parsed = JSON.parse(proposal.body || '{}');
757 return parsed && typeof parsed === 'object' ? parsed : null;
758 } catch {
759 return null;
760 }
761 }
762
763 /**
764 * Approve-time authoritative re-check for media proposals.
765 *
766 * @param {string} dataDir
767 * @param {object} proposal
768 * @param {{ vaultPath: string, vaultConfig?: object, mediaSubdir?: string }} ctx
769 */
770 export function precheckApprovedMediaProposal(dataDir, proposal, ctx) {
771 const vaultId = proposal.vault_id ?? 'default';
772 const meta = proposal.media_meta;
773 const parsed = parseMediaProposalBody(proposal);
774 const proposalKind =
775 meta?.proposal_kind || parsed?.proposal_kind || meta?.record_kind || parsed?.proposal_kind;
776
777 if (!proposalKind) {
778 return refuse(400, 'MEDIA_DRAFT_INVALID', 'missing media proposal_kind');
779 }
780
781 if (proposalKind === 'media_external_link') {
782 const connectorId = meta?.connector_id || parsed?.connector_id;
783 const opaqueRef = parsed?.opaque_ref;
784 const consentId = meta?.consent_id || parsed?.consent_id;
785 const scope = parsed?.scope || meta?.scope;
786 const attachmentId = meta?.attachment_id || parsed?.attachment_id;
787
788 if (!getEnabledConnector(dataDir, vaultId, connectorId)) {
789 return refuse(403, 'MEDIA_CONNECTOR_DENIED', 'Connector not allowlisted');
790 }
791
792 const consentStore = loadMediaImportConsentStore(dataDir);
793 const consentRecord = consentStore.vaults?.[vaultId]?.consents?.[consentId];
794 if (
795 !consentRecord ||
796 consentRecord.status !== 'active' ||
797 consentRecord.connector_id !== connectorId
798 ) {
799 return refuse(403, 'MEDIA_IMPORT_CONSENT_REQUIRED', 'Active import consent required');
800 }
801 if (consentRecord.expires_at != null) {
802 const exp = new Date(consentRecord.expires_at).getTime();
803 if (!Number.isNaN(exp) && exp <= Date.now()) {
804 return refuse(403, 'MEDIA_IMPORT_CONSENT_REQUIRED', 'Import consent expired');
805 }
806 }
807
808 if (getExternalRef(dataDir, vaultId, attachmentId)) {
809 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'External reference already exists');
810 }
811
812 return {
813 ok: true,
814 vaultId,
815 proposalKind,
816 attachmentId,
817 connectorId,
818 opaqueRef,
819 consentId,
820 scope,
821 displayLabel: parsed?.display_label || connectorId,
822 };
823 }
824
825 if (proposalKind === 'media_attach') {
826 const noteRef = meta?.note_ref || parsed?.note_ref;
827 const attachmentId = meta?.attachment_id || parsed?.attachment_id;
828 const baseStateId = proposal.base_state_id || parsed?.base_state_id;
829 const vaultPath = ctx.vaultPath;
830 const notePath = notePathFromRef(noteRef);
831
832 if (!noteFileExistsInVault(vaultPath, notePath)) {
833 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Target note missing at approve');
834 }
835
836 let note;
837 try {
838 note = readNote(vaultPath, notePath);
839 } catch {
840 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Target note unreadable at approve');
841 }
842
843 const liveStateId = noteStateIdFromParts(note.frontmatter ?? {}, note.body ?? '');
844 if (liveStateId !== baseStateId) {
845 return refuse(409, 'MEDIA_LINEAGE_CONFLICT', 'Note changed since proposal was created');
846 }
847
848 return {
849 ok: true,
850 vaultId,
851 proposalKind,
852 attachmentId,
853 noteRef,
854 notePath,
855 baseStateId,
856 vaultPath,
857 vaultConfig: ctx.vaultConfig ?? {},
858 };
859 }
860
861 return refuse(400, 'MEDIA_DRAFT_INVALID', 'unknown media proposal_kind');
862 }
863
864 /**
865 * Apply a pre-checked media proposal — external ref store or note frontmatter only.
866 *
867 * @param {string} dataDir
868 * @param {object} applyCtx
869 */
870 export function reconcileApprovedMediaProposal(dataDir, applyCtx) {
871 const kind = applyCtx.proposalKind;
872
873 if (kind === 'media_external_link') {
874 upsertExternalRef(dataDir, applyCtx.vaultId, applyCtx.attachmentId, {
875 connector_id: applyCtx.connectorId,
876 opaque_ref: applyCtx.opaqueRef,
877 scope: applyCtx.scope,
878 display_label: applyCtx.displayLabel,
879 consent_id: applyCtx.consentId,
880 created: new Date().toISOString(),
881 updated: new Date().toISOString(),
882 });
883 return { applied: true, attachment_id: applyCtx.attachmentId };
884 }
885
886 if (kind === 'media_attach') {
887 const note = readNote(applyCtx.vaultPath, applyCtx.notePath);
888 const fm = { ...(note.frontmatter ?? {}) };
889 const attachments = Array.isArray(fm.attachments) ? [...fm.attachments] : [];
890 const pointer = resolveMediaPointerForAttach(
891 applyCtx.vaultPath,
892 applyCtx.vaultConfig,
893 applyCtx.attachmentId,
894 );
895 if (!pointer) {
896 throw new Error('media pointer could not be resolved at apply');
897 }
898 if (!attachments.includes(pointer)) {
899 attachments.push(pointer);
900 }
901 fm.attachments = attachments;
902 fm.updated = new Date().toISOString();
903 writeNote(applyCtx.vaultPath, applyCtx.notePath, {
904 body: note.body ?? '',
905 frontmatter: fm,
906 });
907 return { applied: true, attachment_id: applyCtx.attachmentId, note_ref: applyCtx.noteRef };
908 }
909
910 throw new Error(`unsupported media proposal_kind at apply: ${kind}`);
911 }
912
913 export { CONNECTOR_ID_RE, getVaultConnectors, loadMediaConnectorPolicy, saveMediaConnectorPolicy } from './media-connector-policy.mjs';
914 export { CONSENT_ID_RE } from './media-import-consent.mjs';
File History 3 commits
sha256:e4c529f14a0bb908c1caaaeb3f95f3623a1a82e636e7e3722ca2cd3dc9821263 security: npm audit fix pre-bridge 2026-07-29 Human 41 days ago
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 58 days ago
sha256:873e30b7fafe601346295f8f4289f388f21d8f715f28584d5481899ba2b714fc Merge pull request #249 from aaronrene/muse-mirror Agent 75 days ago