attachment-handlers.mjs
226 lines 6.8 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days ago
1 /**
2 * Shared Attachment list/get handlers — CLI = MCP = Hub REST parity (Phase 2F-b-b).
3 *
4 * @see docs/ATTACHMENT-STORE-CONTRACT-2F-b.md §4
5 */
6
7 import {
8 listAttachments,
9 getAttachment,
10 attachmentForClient,
11 attachmentGetEffectiveScope,
12 ATTACHMENT_ID_RE,
13 NOTE_REF_RE,
14 MAX_ATTACHMENT_SUMMARIES,
15 ATTACHMENT_SOURCES,
16 MIME_CLASSES,
17 STORAGE_KINDS,
18 } from './attachment-store.mjs';
19 import { resolveFlowScopeQuery } from '../flow/flow-scope.mjs';
20 import { resolveHandlerVisibleScopes } from '../flow/flow-handlers.mjs';
21 import { loadConfig } from '../config.mjs';
22
23 /**
24 * Resolve vault path for attachment derivation from data_dir config.
25 *
26 * @param {string} dataDir
27 * @param {string} [vaultPathOverride]
28 * @returns {string}
29 */
30 export function resolveAttachmentVaultPath(dataDir, vaultPathOverride) {
31 if (typeof vaultPathOverride === 'string' && vaultPathOverride.trim()) {
32 return vaultPathOverride.trim();
33 }
34 try {
35 const config = loadConfig();
36 if (config.vault_path) return config.vault_path;
37 } catch {
38 /* fall through */
39 }
40 return dataDir;
41 }
42
43 /**
44 * @param {{
45 * dataDir: string,
46 * vaultPath?: string,
47 * vaultId: string,
48 * userId?: string,
49 * role?: string,
50 * cliScopes?: import('../flow/flow-scope.mjs').FlowScope[],
51 * visibleScopes?: Set<import('../flow/flow-scope.mjs').FlowScope>,
52 * ambiguous?: boolean,
53 * scope?: string,
54 * note_ref?: string,
55 * noteRef?: string,
56 * source?: string,
57 * mime_class?: string,
58 * mimeClass?: string,
59 * storage_kind?: string,
60 * storageKind?: string,
61 * agent_visible?: boolean|string,
62 * agentVisible?: boolean,
63 * agentContext?: boolean,
64 * limit?: number,
65 * mediaSubdir?: string,
66 * hubScope?: { projects?: string[], folders?: string[] }|null,
67 * vaultConfig?: object,
68 * }} input
69 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
70 */
71 export function handleAttachmentListRequest(input) {
72 const resolved = resolveHandlerVisibleScopes(input);
73 if (resolved.ambiguous) {
74 return {
75 ok: false,
76 status: 400,
77 error: 'Ambiguous attachment scope',
78 code: 'ATTACHMENT_SCOPE_AMBIGUOUS',
79 };
80 }
81
82 const scopeQuery = resolveFlowScopeQuery(resolved.visibleScopes, input.scope);
83 if (!scopeQuery.ok) {
84 const code =
85 scopeQuery.code === 'FLOW_SCOPE_DENIED' ? 'ATTACHMENT_SCOPE_DENIED' : scopeQuery.code;
86 const error =
87 scopeQuery.code === 'FLOW_SCOPE_DENIED' ? 'Attachment scope not authorized' : scopeQuery.error;
88 return { ok: false, status: scopeQuery.status, error, code };
89 }
90
91 const noteRefRaw =
92 (typeof input.noteRef === 'string' ? input.noteRef : input.note_ref)?.trim() ?? '';
93 if (noteRefRaw && !NOTE_REF_RE.test(noteRefRaw)) {
94 return { ok: false, status: 400, error: 'Invalid note_ref', code: 'BAD_REQUEST' };
95 }
96
97 const source = typeof input.source === 'string' ? input.source.trim() : '';
98 if (source && !ATTACHMENT_SOURCES.includes(/** @type {typeof ATTACHMENT_SOURCES[number]} */ (source))) {
99 return { ok: false, status: 400, error: 'Invalid source', code: 'BAD_REQUEST' };
100 }
101
102 const mimeClass =
103 (typeof input.mimeClass === 'string' ? input.mimeClass : input.mime_class)?.trim() ?? '';
104 if (mimeClass && !MIME_CLASSES.includes(/** @type {typeof MIME_CLASSES[number]} */ (mimeClass))) {
105 return { ok: false, status: 400, error: 'Invalid mime_class', code: 'BAD_REQUEST' };
106 }
107
108 const storageKind =
109 (typeof input.storageKind === 'string' ? input.storageKind : input.storage_kind)?.trim() ?? '';
110 if (
111 storageKind &&
112 !STORAGE_KINDS.includes(/** @type {typeof STORAGE_KINDS[number]} */ (storageKind))
113 ) {
114 return { ok: false, status: 400, error: 'Invalid storage_kind', code: 'BAD_REQUEST' };
115 }
116
117 let limit = input.limit;
118 if (limit !== undefined && limit !== null) {
119 if (!Number.isInteger(limit) || limit < 1 || limit > MAX_ATTACHMENT_SUMMARIES) {
120 return {
121 ok: false,
122 status: 400,
123 error: `limit must be an integer between 1 and ${MAX_ATTACHMENT_SUMMARIES}`,
124 code: 'BAD_REQUEST',
125 };
126 }
127 }
128
129 const agentVisibleOnly =
130 input.agentVisible === true ||
131 input.agent_visible === true ||
132 input.agent_visible === 'true';
133
134 const vaultPath = resolveAttachmentVaultPath(input.dataDir, input.vaultPath);
135
136 const payload = listAttachments(input.dataDir, vaultPath, input.vaultId, {
137 visibleScopes: resolved.visibleScopes,
138 filterScopes: scopeQuery.filterScopes,
139 effectiveScope: scopeQuery.effectiveScope,
140 noteRef: noteRefRaw || undefined,
141 source: source || undefined,
142 mimeClass: mimeClass || undefined,
143 storageKind: storageKind || undefined,
144 agentVisibleOnly,
145 agentContext: input.agentContext === true,
146 limit,
147 mediaSubdir: input.mediaSubdir,
148 hubScope: input.hubScope ?? null,
149 vaultConfig: input.vaultConfig,
150 });
151
152 return { ok: true, payload };
153 }
154
155 /**
156 * @param {{
157 * dataDir: string,
158 * vaultPath?: string,
159 * vaultId: string,
160 * attachmentId: string,
161 * userId?: string,
162 * role?: string,
163 * cliScopes?: import('../flow/flow-scope.mjs').FlowScope[],
164 * visibleScopes?: Set<import('../flow/flow-scope.mjs').FlowScope>,
165 * ambiguous?: boolean,
166 * mediaSubdir?: string,
167 * hubScope?: object|null,
168 * vaultConfig?: object,
169 * agentContext?: boolean,
170 * }} input
171 * @returns {{ ok: true, payload: object } | { ok: false, status: number, error: string, code: string }}
172 */
173 export function handleAttachmentGetRequest(input) {
174 const attachmentId =
175 typeof input.attachmentId === 'string' ? input.attachmentId.trim() : '';
176 if (!attachmentId || !ATTACHMENT_ID_RE.test(attachmentId)) {
177 return { ok: false, status: 400, error: 'Invalid attachment id', code: 'BAD_REQUEST' };
178 }
179
180 const resolved = resolveHandlerVisibleScopes(input);
181 if (resolved.ambiguous) {
182 return {
183 ok: false,
184 status: 400,
185 error: 'Ambiguous attachment scope',
186 code: 'ATTACHMENT_SCOPE_AMBIGUOUS',
187 };
188 }
189
190 const vaultPath = resolveAttachmentVaultPath(input.dataDir, input.vaultPath);
191
192 const stored = getAttachment(input.dataDir, vaultPath, input.vaultId, attachmentId, {
193 visibleScopes: resolved.visibleScopes,
194 mediaSubdir: input.mediaSubdir,
195 hubScope: input.hubScope ?? null,
196 vaultConfig: input.vaultConfig,
197 agentContext: input.agentContext === true,
198 });
199
200 if (!stored) {
201 return {
202 ok: false,
203 status: 404,
204 error: 'unknown_attachment',
205 code: 'unknown_attachment',
206 };
207 }
208
209 return {
210 ok: true,
211 payload: {
212 schema: 'knowtation.attachment_get/v0',
213 vault_id: input.vaultId,
214 effective_scope: attachmentGetEffectiveScope(resolved.visibleScopes),
215 attachment: attachmentForClient(stored),
216 },
217 };
218 }
219
220 /**
221 * @param {object} payload
222 * @returns {string}
223 */
224 export function serializeAttachmentPayload(payload) {
225 return JSON.stringify(payload);
226 }
File History 1 commit
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago