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