attachment-store.mjs
661 lines 20.7 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 11 days ago
1 /**
2 * Derived attachment read store (Phase 2F-b-b).
3 *
4 * Read-time index over vault media files, Mist frontmatter blobs, and embedded URLs.
5 * Scope is inherited from owning notes; policy overlay supplies agent_visible consent.
6 *
7 * @see docs/ATTACHMENT-STORE-CONTRACT-2F-b.md
8 */
9
10 import crypto from 'crypto';
11 import fs from 'fs';
12 import path from 'path';
13 import { extractImageUrls, extractVideoUrls } from '../media-url-extract.mjs';
14 import { getNotesWithMeta } from '../list-notes.mjs';
15 import { applyScopeFilterToNotes } from '../../hub/lib/scope-filter.mjs';
16 import { SCOPE_RANK, highestFlowScope } from '../flow/flow-scope.mjs';
17 import { getVaultAttachmentPolicies } from './attachment-store-file.mjs';
18 import { getVaultExternalRefs } from './attachment-external-ref-store.mjs';
19 import { walkAllMediaFiles, mimeFromExtension } from './derive.mjs';
20
21 export const MAX_ATTACHMENT_SUMMARIES = 500;
22 export const MAX_LINKED_NOTES = 64;
23
24 export const ATTACHMENT_ID_RE = /^att_(file|mist|url|link)_[a-f0-9]{32}$/;
25 export const NOTE_REF_RE = /^note:[A-Za-z0-9._:\/-]{1,256}$/;
26 export const MIST_ID_RE = /^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{12}$/;
27
28 /** Scooling consumer charset/length cap for linked_note_refs on the wire. */
29 export const SCOOLING_NOTE_REF_RE = /^note:[A-Za-z0-9._:-]{1,128}$/;
30
31 export const ATTACHMENT_SOURCES = /** @type {const} */ ([
32 'vault_file',
33 'mist_blob',
34 'embedded_url',
35 'connector_ref',
36 ]);
37 export const STORAGE_KINDS = /** @type {const} */ (['vault_blob', 'external_link']);
38 export const MIME_CLASSES = /** @type {const} */ (['image', 'video', 'audio', 'document', 'unknown']);
39
40 /** @typedef {'personal'|'project'|'org'} AttachmentScope */
41 /** @typedef {typeof ATTACHMENT_SOURCES[number]} AttachmentSource */
42 /** @typedef {typeof MIME_CLASSES[number]} MimeClass */
43
44 /**
45 * @typedef {Object} StoredAttachment
46 * @property {'knowtation.attachment/v0'} schema
47 * @property {string} attachment_id
48 * @property {AttachmentSource} source
49 * @property {'vault_blob'|'external_link'} storage_kind
50 * @property {MimeClass} mime_class
51 * @property {string|null} mime_type
52 * @property {AttachmentScope} scope
53 * @property {string} display_label
54 * @property {number|null} byte_size
55 * @property {string[]} linked_note_refs
56 * @property {boolean} agent_visible
57 * @property {string} created
58 * @property {string} updated
59 * @property {boolean} truncated
60 */
61
62 /**
63 * @param {string} input
64 * @returns {string}
65 */
66 function sha256Token(input) {
67 return crypto.createHash('sha256').update(input, 'utf8').digest('hex').slice(0, 32);
68 }
69
70 /**
71 * @param {'file'|'mist'|'url'|'link'} sourceTag
72 * @param {string} canonical
73 * @returns {string}
74 */
75 export function deriveAttachmentId(sourceTag, canonical) {
76 return `att_${sourceTag}_${sha256Token(canonical)}`;
77 }
78
79 /**
80 * @param {string} notePath
81 * @returns {string}
82 */
83 export function noteRefFromPath(notePath) {
84 const normalized = notePath.replace(/\\/g, '/').replace(/^\/+/, '');
85 if (!normalized || normalized.includes('..')) {
86 return '';
87 }
88 return `note:${normalized}`;
89 }
90
91 /**
92 * @param {string} ref
93 * @returns {boolean}
94 */
95 export function isScoolingSafeNoteRef(ref) {
96 return SCOOLING_NOTE_REF_RE.test(ref);
97 }
98
99 /**
100 * @param {string} url
101 * @returns {string}
102 */
103 export function normalizeUrlForId(url) {
104 try {
105 const u = new URL(url.trim());
106 u.username = '';
107 u.password = '';
108 u.hash = '';
109 return u.toString();
110 } catch {
111 return url.trim().split('#')[0].split('?')[0];
112 }
113 }
114
115 /**
116 * @param {string} url
117 * @returns {string}
118 */
119 export function urlHostOnly(url) {
120 try {
121 return new URL(url.trim()).host || 'unknown';
122 } catch {
123 const cleaned = url.trim().replace(/^https?:\/\//i, '');
124 const slash = cleaned.indexOf('/');
125 const host = slash === -1 ? cleaned : cleaned.slice(0, slash);
126 return host.split('@').pop()?.split('?')[0] || 'unknown';
127 }
128 }
129
130 /**
131 * @param {string} relPath
132 * @returns {string}
133 */
134 function filenameStem(relPath) {
135 const base = path.basename(relPath.replace(/\\/g, '/'));
136 const dot = base.lastIndexOf('.');
137 return dot > 0 ? base.slice(0, dot) : base;
138 }
139
140 /**
141 * Infer authorization scope tier from a note record.
142 *
143 * @param {{ path: string, frontmatter?: object }} note
144 * @returns {AttachmentScope}
145 */
146 export function inferNoteScope(note) {
147 const fm = note.frontmatter ?? {};
148 const raw = fm.scope;
149 if (raw === 'personal' || raw === 'project' || raw === 'org') {
150 return raw;
151 }
152 const p = note.path.replace(/\\/g, '/');
153 if (p.startsWith('org/')) return 'org';
154 if (p.startsWith('projects/')) return 'project';
155 return 'personal';
156 }
157
158 /**
159 * @param {AttachmentScope[]} scopes
160 * @returns {AttachmentScope}
161 */
162 export function mostRestrictiveScope(scopes) {
163 if (scopes.length === 0) return 'personal';
164 let best = scopes[0];
165 for (const s of scopes) {
166 if (SCOPE_RANK[s] > SCOPE_RANK[best]) best = s;
167 }
168 return best;
169 }
170
171 /**
172 * @param {StoredAttachment} attachment
173 * @returns {object}
174 */
175 export function attachmentSummaryForClient(attachment) {
176 return {
177 schema: 'knowtation.attachment/v0',
178 attachment_id: attachment.attachment_id,
179 source: attachment.source,
180 storage_kind: attachment.storage_kind,
181 mime_class: attachment.mime_class,
182 scope: attachment.scope,
183 display_label: attachment.display_label,
184 created: attachment.created,
185 truncated: attachment.truncated,
186 };
187 }
188
189 /**
190 * @param {StoredAttachment} attachment
191 * @returns {StoredAttachment}
192 */
193 export function attachmentForClient(attachment) {
194 let refs = attachment.linked_note_refs ?? [];
195 let truncated = attachment.truncated;
196 const safeRefs = refs.filter((r) => isScoolingSafeNoteRef(r));
197 if (safeRefs.length < refs.length) {
198 truncated = true;
199 }
200 if (safeRefs.length > MAX_LINKED_NOTES) {
201 refs = safeRefs.slice(0, MAX_LINKED_NOTES);
202 truncated = true;
203 } else {
204 refs = safeRefs;
205 }
206 return {
207 schema: attachment.schema,
208 attachment_id: attachment.attachment_id,
209 source: attachment.source,
210 storage_kind: attachment.storage_kind,
211 mime_class: attachment.mime_class,
212 mime_type: attachment.mime_type,
213 scope: attachment.scope,
214 display_label: attachment.display_label,
215 byte_size: attachment.byte_size,
216 linked_note_refs: refs,
217 agent_visible: attachment.agent_visible,
218 created: attachment.created,
219 updated: attachment.updated,
220 truncated,
221 };
222 }
223
224 /**
225 * @param {string} dataDir
226 * @param {string} attachmentId
227 * @param {Record<string, { agent_visible?: boolean, retention?: string, updated?: string }>} policies
228 * @returns {{ agent_visible: boolean, retention: 'vault_lifetime'|'pinned', updated: string }}
229 */
230 function resolvePolicy(dataDir, attachmentId, policies, fallbackUpdated) {
231 const row = policies[attachmentId];
232 const fallback =
233 typeof fallbackUpdated === 'string' && fallbackUpdated.trim() ? fallbackUpdated : null;
234 if (!row || typeof row !== 'object') {
235 return {
236 agent_visible: false,
237 retention: 'vault_lifetime',
238 updated: fallback ?? fallbackUpdated ?? '1970-01-01T00:00:00.000Z',
239 };
240 }
241 return {
242 agent_visible: row.agent_visible === true,
243 retention: row.retention === 'pinned' ? 'pinned' : 'vault_lifetime',
244 updated:
245 typeof row.updated === 'string' && row.updated.trim()
246 ? row.updated
247 : fallback ?? '1970-01-01T00:00:00.000Z',
248 };
249 }
250
251 /**
252 * @param {{ path: string, frontmatter?: object, body?: string, project?: string }} note
253 * @param {string} vaultPath
254 * @param {string} mediaSubdir
255 * @param {Map<string, { linked_note_refs: Set<string>, noteScopes: AttachmentScope[], created: string, updated: string, displayBits: object }>} index
256 */
257 function deriveFromNote(note, vaultPath, mediaSubdir, index) {
258 const notePath = note.path.replace(/\\/g, '/');
259 const noteRef = noteRefFromPath(notePath);
260 if (!noteRef || !NOTE_REF_RE.test(noteRef)) return;
261
262 const noteScope = inferNoteScope(note);
263 const fm = note.frontmatter ?? {};
264 const noteTitle =
265 typeof fm.title === 'string' && fm.title.trim() ? fm.title.trim() : filenameStem(notePath);
266 let noteMtime =
267 typeof fm.updated === 'string' && fm.updated.trim()
268 ? fm.updated
269 : typeof note.updated === 'string' && note.updated.trim()
270 ? note.updated
271 : null;
272 if (!noteMtime) {
273 try {
274 noteMtime = fs.statSync(path.join(vaultPath, notePath)).mtime.toISOString();
275 } catch {
276 noteMtime = '1970-01-01T00:00:00.000Z';
277 }
278 }
279
280 const attachmentsRaw = fm.attachments;
281 const attachmentList = Array.isArray(attachmentsRaw)
282 ? attachmentsRaw
283 : typeof attachmentsRaw === 'string' && attachmentsRaw.trim()
284 ? [attachmentsRaw.trim()]
285 : [];
286 if (attachmentList.length > 0) {
287 let mistIdx = 0;
288 for (const raw of attachmentList) {
289 if (typeof raw !== 'string') continue;
290 if (MIST_ID_RE.test(raw)) {
291 mistIdx += 1;
292 const id = deriveAttachmentId('mist', `mist:${raw}`);
293 upsertIndex(index, id, {
294 source: 'mist_blob',
295 storage_kind: 'vault_blob',
296 mime_class: 'unknown',
297 mime_type: null,
298 byte_size: null,
299 display_label: noteTitle === filenameStem(notePath) ? `attachment ${mistIdx}` : noteTitle,
300 noteRef,
301 noteScope,
302 created: noteMtime,
303 updated: noteMtime,
304 });
305 continue;
306 }
307 if (ATTACHMENT_ID_RE.test(raw)) {
308 const existing = index.get(raw);
309 if (existing) {
310 upsertIndex(index, raw, {
311 source: existing.source,
312 storage_kind: existing.storage_kind,
313 mime_class: existing.mime_class,
314 mime_type: existing.mime_type,
315 byte_size: existing.byte_size,
316 display_label: existing.display_label,
317 noteRef,
318 noteScope,
319 created: noteMtime,
320 updated: noteMtime,
321 });
322 }
323 }
324 }
325 }
326
327 const body = typeof note.body === 'string' ? note.body : '';
328 const imageUrls = extractImageUrls(body);
329 const videoUrls = extractVideoUrls(body);
330 const urlEntries = [
331 ...imageUrls.map((u) => ({ ...u, kind: 'image' })),
332 ...videoUrls.map((u) => ({ ...u, kind: 'video' })),
333 ];
334
335 for (const entry of urlEntries) {
336 const normalized = normalizeUrlForId(entry.url);
337 const id = deriveAttachmentId('url', `url:${notePath}|${normalized}`);
338 const ext = entry.mimeType?.split('/')[1] ?? '';
339 const { mimeClass, mimeType } = mimeFromExtension(ext.replace('jpeg', 'jpg').replace('quicktime', 'mov'));
340 upsertIndex(index, id, {
341 source: 'embedded_url',
342 storage_kind: 'external_link',
343 mime_class: entry.kind === 'video' ? 'video' : mimeClass === 'unknown' ? 'image' : mimeClass,
344 mime_type: entry.mimeType ?? mimeType,
345 byte_size: null,
346 display_label: urlHostOnly(entry.url),
347 noteRef,
348 noteScope,
349 created: noteMtime,
350 updated: noteMtime,
351 });
352 }
353 }
354
355 /**
356 * @param {Map<string, object>} index
357 * @param {string} id
358 * @param {object} row
359 */
360 function upsertIndex(index, id, row) {
361 const existing = index.get(id);
362 if (!existing) {
363 index.set(id, {
364 attachment_id: id,
365 source: row.source,
366 storage_kind: row.storage_kind,
367 mime_class: row.mime_class,
368 mime_type: row.mime_type,
369 byte_size: row.byte_size,
370 display_label: row.display_label,
371 linked_note_refs: new Set([row.noteRef]),
372 noteScopes: [row.noteScope],
373 created: row.created,
374 updated: row.updated,
375 });
376 return;
377 }
378 existing.linked_note_refs.add(row.noteRef);
379 existing.noteScopes.push(row.noteScope);
380 if (row.created < existing.created) existing.created = row.created;
381 if (row.updated > existing.updated) existing.updated = row.updated;
382 }
383
384 /**
385 * Build the full derived attachment index for a vault.
386 *
387 * @param {string} vaultPath
388 * @param {string} dataDir
389 * @param {string} vaultId
390 * @param {{ mediaSubdir?: string, vaultConfig?: object }} [options]
391 * @returns {StoredAttachment[]}
392 */
393 export function deriveAllAttachments(vaultPath, dataDir, vaultId, options = {}) {
394 const mediaSubdir = options.mediaSubdir ?? 'media';
395 const policies = getVaultAttachmentPolicies(dataDir, vaultId);
396 /** @type {Map<string, object>} */
397 const index = new Map();
398
399 for (const file of walkAllMediaFiles(vaultPath, mediaSubdir)) {
400 const id = deriveAttachmentId('file', `file:${file.relPath}`);
401 const { mimeClass, mimeType } = mimeFromExtension(file.ext);
402 index.set(id, {
403 attachment_id: id,
404 source: 'vault_file',
405 storage_kind: 'vault_blob',
406 mime_class: mimeClass,
407 mime_type: mimeType,
408 byte_size: file.byteSize,
409 display_label: filenameStem(file.relPath),
410 linked_note_refs: new Set(),
411 noteScopes: [],
412 created: file.mtimeIso,
413 updated: file.mtimeIso,
414 vaultRelPath: file.relPath,
415 });
416 }
417
418 const externalRefs = getVaultExternalRefs(dataDir, vaultId);
419 for (const [attachmentId, ref] of Object.entries(externalRefs)) {
420 if (!ref || typeof ref !== 'object') continue;
421 if (!ATTACHMENT_ID_RE.test(attachmentId)) continue;
422 index.set(attachmentId, {
423 attachment_id: attachmentId,
424 source: 'connector_ref',
425 storage_kind: 'external_link',
426 mime_class: 'unknown',
427 mime_type: null,
428 byte_size: null,
429 display_label:
430 typeof ref.display_label === 'string' && ref.display_label.trim()
431 ? ref.display_label.trim()
432 : ref.connector_id || 'External link',
433 linked_note_refs: new Set(),
434 noteScopes: [ref.scope ?? 'personal'],
435 created: ref.created ?? '1970-01-01T00:00:00.000Z',
436 updated: ref.updated ?? ref.created ?? '1970-01-01T00:00:00.000Z',
437 });
438 }
439
440 const notes = getNotesWithMeta(vaultPath, options.vaultConfig ?? {});
441 for (const note of notes) {
442 deriveFromNote(note, vaultPath, mediaSubdir, index);
443 }
444
445 const now = new Date().toISOString();
446 /** @type {StoredAttachment[]} */
447 const out = [];
448
449 for (const row of index.values()) {
450 const linkedRefs = [...row.linked_note_refs].sort();
451 const scope =
452 linkedRefs.length === 0 &&
453 (row.source === 'vault_file' || row.source === 'connector_ref')
454 ? row.noteScopes?.[0] ?? 'personal'
455 : mostRestrictiveScope(row.noteScopes.length ? row.noteScopes : ['personal']);
456
457 const policy = resolvePolicy(dataDir, row.attachment_id, policies, row.updated);
458 out.push({
459 schema: 'knowtation.attachment/v0',
460 attachment_id: row.attachment_id,
461 source: row.source,
462 storage_kind: row.storage_kind,
463 mime_class: row.mime_class,
464 mime_type: row.mime_type,
465 scope,
466 display_label: row.display_label,
467 byte_size: row.byte_size ?? null,
468 linked_note_refs: linkedRefs,
469 agent_visible: policy.agent_visible,
470 created: row.created ?? now,
471 updated: policy.updated ?? row.updated ?? now,
472 truncated: false,
473 });
474 }
475
476 return out;
477 }
478
479 /**
480 * @param {StoredAttachment} attachment
481 * @param {{ hubScope?: { projects?: string[], folders?: string[] }|null, notesByRef?: Map<string, object> }} ctx
482 * @returns {boolean}
483 */
484 function isAttachmentNoteVisible(attachment, ctx) {
485 if (attachment.linked_note_refs.length === 0) {
486 return attachment.source === 'vault_file' || attachment.source === 'connector_ref';
487 }
488 const hubScope = ctx.hubScope;
489 const notesByRef = ctx.notesByRef ?? new Map();
490 for (const ref of attachment.linked_note_refs) {
491 const pathPart = ref.startsWith('note:') ? ref.slice(5) : ref;
492 const note = notesByRef.get(ref) ?? { path: pathPart, project: undefined };
493 if (!hubScope || (!hubScope.projects?.length && !hubScope.folders?.length)) {
494 return true;
495 }
496 const filtered = applyScopeFilterToNotes([note], hubScope);
497 if (filtered.length > 0) return true;
498 }
499 return false;
500 }
501
502 /**
503 * @param {StoredAttachment} attachment
504 * @param {Set<AttachmentScope>} filterScopes
505 * @param {{ hubScope?: object|null, notesByRef?: Map<string, object>, agentContext?: boolean, agentVisibleOnly?: boolean }} ctx
506 * @returns {boolean}
507 */
508 function attachmentVisible(attachment, filterScopes, ctx) {
509 if (!filterScopes.has(attachment.scope)) return false;
510 if (!isAttachmentNoteVisible(attachment, ctx)) return false;
511 if (ctx.agentContext && !attachment.agent_visible) return false;
512 if (ctx.agentVisibleOnly && !attachment.agent_visible) return false;
513 return true;
514 }
515
516 /**
517 * @param {string} vaultPath
518 * @param {object} [vaultConfig]
519 * @returns {Map<string, object>}
520 */
521 function buildNotesByRef(vaultPath, vaultConfig) {
522 const notes = getNotesWithMeta(vaultPath, vaultConfig ?? {});
523 const map = new Map();
524 for (const note of notes) {
525 map.set(noteRefFromPath(note.path), note);
526 }
527 return map;
528 }
529
530 const MIME_CLASS_ORDER = { audio: 0, document: 1, image: 2, unknown: 3, video: 4 };
531
532 /**
533 * @param {StoredAttachment} a
534 * @param {StoredAttachment} b
535 * @returns {number}
536 */
537 function compareAttachmentsForList(a, b) {
538 const mc = (MIME_CLASS_ORDER[a.mime_class] ?? 99) - (MIME_CLASS_ORDER[b.mime_class] ?? 99);
539 if (mc !== 0) return mc;
540 return a.attachment_id.localeCompare(b.attachment_id);
541 }
542
543 /**
544 * List scope-visible attachment summaries.
545 *
546 * @param {string} dataDir
547 * @param {string} vaultPath
548 * @param {string} vaultId
549 * @param {{
550 * visibleScopes?: Set<AttachmentScope>,
551 * filterScopes?: Set<AttachmentScope>,
552 * effectiveScope: AttachmentScope,
553 * noteRef?: string,
554 * source?: string,
555 * mimeClass?: string,
556 * storageKind?: string,
557 * agentVisibleOnly?: boolean,
558 * agentContext?: boolean,
559 * limit?: number,
560 * mediaSubdir?: string,
561 * hubScope?: { projects?: string[], folders?: string[] }|null,
562 * vaultConfig?: object,
563 * }} query
564 */
565 export function listAttachments(dataDir, vaultPath, vaultId, query) {
566 const filterScopes = query.filterScopes ?? query.visibleScopes ?? new Set(['personal']);
567 let limit = typeof query.limit === 'number' ? query.limit : MAX_ATTACHMENT_SUMMARIES;
568 if (!Number.isInteger(limit) || limit < 1) limit = MAX_ATTACHMENT_SUMMARIES;
569 if (limit > MAX_ATTACHMENT_SUMMARIES) limit = MAX_ATTACHMENT_SUMMARIES;
570
571 const notesByRef = buildNotesByRef(vaultPath, query.vaultConfig);
572 const ctx = {
573 hubScope: query.hubScope ?? null,
574 notesByRef,
575 agentContext: query.agentContext === true,
576 agentVisibleOnly: query.agentVisibleOnly === true,
577 };
578
579 let candidates = deriveAllAttachments(vaultPath, dataDir, vaultId, {
580 mediaSubdir: query.mediaSubdir,
581 vaultConfig: query.vaultConfig,
582 }).filter((a) => attachmentVisible(a, filterScopes, ctx));
583
584 const noteRef = typeof query.noteRef === 'string' ? query.noteRef.trim() : '';
585 if (noteRef) {
586 candidates = candidates.filter((a) => a.linked_note_refs.includes(noteRef));
587 }
588 const sourceFilter = typeof query.source === 'string' ? query.source.trim() : '';
589 if (sourceFilter) {
590 candidates = candidates.filter((a) => a.source === sourceFilter);
591 }
592 const mimeClassFilter = typeof query.mimeClass === 'string' ? query.mimeClass.trim() : '';
593 if (mimeClassFilter) {
594 candidates = candidates.filter((a) => a.mime_class === mimeClassFilter);
595 }
596 const storageKindFilter = typeof query.storageKind === 'string' ? query.storageKind.trim() : '';
597 if (storageKindFilter) {
598 candidates = candidates.filter((a) => a.storage_kind === storageKindFilter);
599 }
600
601 candidates.sort(compareAttachmentsForList);
602
603 const totalMatching = candidates.length;
604 let truncated = totalMatching > limit;
605 if (candidates.length > limit) {
606 candidates = candidates.slice(0, limit);
607 }
608
609 return {
610 schema: 'knowtation.attachment_list/v0',
611 vault_id: vaultId,
612 effective_scope: query.effectiveScope,
613 attachments: candidates.map((a) => attachmentSummaryForClient({ ...a, truncated })),
614 truncated,
615 };
616 }
617
618 /**
619 * @param {string} dataDir
620 * @param {string} vaultPath
621 * @param {string} vaultId
622 * @param {string} attachmentId
623 * @param {{
624 * visibleScopes?: Set<AttachmentScope>,
625 * mediaSubdir?: string,
626 * hubScope?: object|null,
627 * vaultConfig?: object,
628 * agentContext?: boolean,
629 * }} query
630 * @returns {StoredAttachment|null}
631 */
632 export function getAttachment(dataDir, vaultPath, vaultId, attachmentId, query) {
633 if (!ATTACHMENT_ID_RE.test(attachmentId)) {
634 return null;
635 }
636
637 const filterScopes = query.visibleScopes ?? new Set(['personal']);
638 const notesByRef = buildNotesByRef(vaultPath, query.vaultConfig);
639 const ctx = {
640 hubScope: query.hubScope ?? null,
641 notesByRef,
642 agentContext: query.agentContext === true,
643 };
644
645 const row = deriveAllAttachments(vaultPath, dataDir, vaultId, {
646 mediaSubdir: query.mediaSubdir,
647 vaultConfig: query.vaultConfig,
648 }).find((a) => a.attachment_id === attachmentId);
649
650 if (!row) return null;
651 if (!attachmentVisible(row, filterScopes, ctx)) return null;
652 return row;
653 }
654
655 /**
656 * @param {Set<AttachmentScope>} visibleScopes
657 * @returns {AttachmentScope}
658 */
659 export function attachmentGetEffectiveScope(visibleScopes) {
660 return highestFlowScope(visibleScopes);
661 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 11 days ago