@batalabs/virlow-mcp-core 3.80.0 → 3.81.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/api.d.ts +46 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +26 -0
  4. package/dist/api.js.map +1 -1
  5. package/dist/attachment-format.d.ts +42 -0
  6. package/dist/attachment-format.d.ts.map +1 -0
  7. package/dist/attachment-format.js +205 -0
  8. package/dist/attachment-format.js.map +1 -0
  9. package/dist/attachments.d.ts +39 -0
  10. package/dist/attachments.d.ts.map +1 -0
  11. package/dist/attachments.js +166 -0
  12. package/dist/attachments.js.map +1 -0
  13. package/dist/cjs/api.d.ts +46 -0
  14. package/dist/cjs/api.js +26 -0
  15. package/dist/cjs/attachment-format.d.ts +41 -0
  16. package/dist/cjs/attachment-format.js +214 -0
  17. package/dist/cjs/attachments.d.ts +38 -0
  18. package/dist/cjs/attachments.js +169 -0
  19. package/dist/cjs/index.d.ts +3 -1
  20. package/dist/cjs/index.js +3 -1
  21. package/dist/cjs/local-paths.d.ts +26 -0
  22. package/dist/cjs/local-paths.js +53 -0
  23. package/dist/http/index.d.ts +1 -1
  24. package/dist/http/index.d.ts.map +1 -1
  25. package/dist/http/index.js +1 -1
  26. package/dist/http/index.js.map +1 -1
  27. package/dist/http/local-origin.d.ts +9 -0
  28. package/dist/http/local-origin.d.ts.map +1 -1
  29. package/dist/http/local-origin.js +19 -4
  30. package/dist/http/local-origin.js.map +1 -1
  31. package/dist/http/server.js +2 -2
  32. package/dist/http/server.js.map +1 -1
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/local-paths.d.ts +27 -0
  38. package/dist/local-paths.d.ts.map +1 -0
  39. package/dist/local-paths.js +51 -0
  40. package/dist/local-paths.js.map +1 -0
  41. package/dist/tools.d.ts +11 -2
  42. package/dist/tools.d.ts.map +1 -1
  43. package/dist/tools.js +297 -2
  44. package/dist/tools.js.map +1 -1
  45. package/package.json +3 -3
package/dist/cjs/api.d.ts CHANGED
@@ -80,6 +80,35 @@ export interface ApiFolder {
80
80
  /** Encrypted v2 blob of the folder's property and view definitions. */
81
81
  encryptedSchema: string | null;
82
82
  }
83
+ /** One attachment's metadata. The filename is only ever in encryptedTitle. */
84
+ export interface ApiFile {
85
+ id: string;
86
+ noteId: string;
87
+ filename: string;
88
+ /** Legacy plaintext filename; null for anything a current client uploaded. */
89
+ originalName: string | null;
90
+ encryptedTitle: string;
91
+ iv: string;
92
+ salt: string;
93
+ mimeType: string;
94
+ size: number;
95
+ uploadedAt: string;
96
+ /** Set while the file is in the note's Recently deleted. */
97
+ deletedAt: string | null;
98
+ }
99
+ export interface ApiFileWithData extends ApiFile {
100
+ /** "v2:<iv>:<ciphertext>"; anything else is legacy v1. */
101
+ encryptedData: string;
102
+ noteSalt: string | null;
103
+ }
104
+ export interface FileUpload {
105
+ encryptedTitle: string;
106
+ encryptedData: string;
107
+ iv: string;
108
+ salt: string;
109
+ mimeType: string;
110
+ size: number;
111
+ }
83
112
  export declare class VirlowApi {
84
113
  private readonly baseUrl;
85
114
  private tokens;
@@ -171,4 +200,21 @@ export declare class VirlowApi {
171
200
  mcpEnabled?: boolean;
172
201
  encryptedSchema?: string | null;
173
202
  }): Promise<ApiFolder>;
203
+ listFiles(noteId: string, opts?: {
204
+ trashed?: boolean;
205
+ }): Promise<ApiFile[]>;
206
+ /** The full row, ciphertext included. Trashed files are served too. */
207
+ getFile(id: string): Promise<ApiFileWithData>;
208
+ /** Metadata only: what an exposure check needs, without up to 50 MB of ciphertext. */
209
+ getFileMeta(id: string): Promise<ApiFile>;
210
+ uploadFile(noteId: string, body: FileUpload): Promise<ApiFile>;
211
+ patchFile(id: string, body: {
212
+ encryptedTitle?: string;
213
+ iv?: string;
214
+ noteId?: string;
215
+ }): Promise<ApiFile>;
216
+ /** Moves the file to the note's Recently deleted. There is deliberately no
217
+ * way to hard-delete from here: Claude's deletes can always be undone. */
218
+ deleteFile(id: string): Promise<void>;
219
+ restoreFile(id: string): Promise<ApiFile>;
174
220
  }
package/dist/cjs/api.js CHANGED
@@ -222,5 +222,31 @@ class VirlowApi {
222
222
  updateFolder(id, body) {
223
223
  return this.request('PUT', `/api/folders/${id}`, body);
224
224
  }
225
+ listFiles(noteId, opts = {}) {
226
+ const qs = opts.trashed ? '?trashed=true' : '';
227
+ return this.request('GET', `/api/notes/${encodeURIComponent(noteId)}/files${qs}`);
228
+ }
229
+ /** The full row, ciphertext included. Trashed files are served too. */
230
+ getFile(id) {
231
+ return this.request('GET', `/api/files/${encodeURIComponent(id)}`);
232
+ }
233
+ /** Metadata only: what an exposure check needs, without up to 50 MB of ciphertext. */
234
+ getFileMeta(id) {
235
+ return this.request('GET', `/api/files/${encodeURIComponent(id)}?metadata=true`);
236
+ }
237
+ uploadFile(noteId, body) {
238
+ return this.request('POST', `/api/notes/${encodeURIComponent(noteId)}/files`, body);
239
+ }
240
+ patchFile(id, body) {
241
+ return this.request('PATCH', `/api/files/${encodeURIComponent(id)}`, body);
242
+ }
243
+ /** Moves the file to the note's Recently deleted. There is deliberately no
244
+ * way to hard-delete from here: Claude's deletes can always be undone. */
245
+ deleteFile(id) {
246
+ return this.request('DELETE', `/api/files/${encodeURIComponent(id)}`);
247
+ }
248
+ restoreFile(id) {
249
+ return this.request('POST', `/api/files/${encodeURIComponent(id)}/restore`);
250
+ }
225
251
  }
226
252
  exports.VirlowApi = VirlowApi;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure helpers for the attachment tools: names, types, sizes and how a file
3
+ * is shown to a model. No I/O, so every rule here is tested directly.
4
+ */
5
+ export declare const FILENAME_MAX = 255;
6
+ /** `text` and `content_base64`: what fits comfortably in one MCP message. */
7
+ export declare const INLINE_MAX_BYTES: number;
8
+ /** `path`: under the API's effective ceiling (~38 MB raw after the double base64). */
9
+ export declare const PATH_MAX_BYTES: number;
10
+ /** Largest non-text file read_attachment returns inline as base64. */
11
+ export declare const BINARY_INLINE_MAX_BYTES: number;
12
+ export declare const TEXT_MAX_CHARS = 200000;
13
+ /**
14
+ * The name shown and stored (encrypted). Basename only, so a model passing a
15
+ * full path does not leak the user's directory layout into the note; control
16
+ * characters out; capped at 255 with the extension kept.
17
+ */
18
+ export declare function sanitizeFilename(name: string): string;
19
+ export declare function inferMimeType(filename: string): string;
20
+ export declare function isTextLike(mimeType: string, filename: string): boolean;
21
+ export declare function formatSize(bytes: number): string;
22
+ /**
23
+ * atob accepts sloppy input on some runtimes; this does not. Bounded before
24
+ * anything is decoded: four characters carry three bytes, so input longer than
25
+ * ceil(max * 4 / 3) plus one padded group cannot fit in `max` and is refused
26
+ * without allocating its decoded form.
27
+ */
28
+ export declare function decodeBase64Strict(input: string, max?: number): Uint8Array;
29
+ /** Size gate shared by every add_attachment source. Returns the bytes so it can wrap a source inline. */
30
+ export declare function checkSize(bytes: Uint8Array, max: number, label: string): Uint8Array;
31
+ /**
32
+ * What read_attachment returns for a file it does not save: text as text,
33
+ * small binaries as base64 under a one-line header, and a clear refusal for
34
+ * anything bigger.
35
+ */
36
+ export declare function renderAttachment(info: {
37
+ filename: string;
38
+ mimeType: string;
39
+ }, bytes: Uint8Array, opts: {
40
+ canSave: boolean;
41
+ }): string;
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TEXT_MAX_CHARS = exports.BINARY_INLINE_MAX_BYTES = exports.PATH_MAX_BYTES = exports.INLINE_MAX_BYTES = exports.FILENAME_MAX = void 0;
4
+ exports.sanitizeFilename = sanitizeFilename;
5
+ exports.inferMimeType = inferMimeType;
6
+ exports.isTextLike = isTextLike;
7
+ exports.formatSize = formatSize;
8
+ exports.decodeBase64Strict = decodeBase64Strict;
9
+ exports.checkSize = checkSize;
10
+ exports.renderAttachment = renderAttachment;
11
+ const virlow_crypto_1 = require("@batalabs/virlow-crypto");
12
+ /**
13
+ * Pure helpers for the attachment tools: names, types, sizes and how a file
14
+ * is shown to a model. No I/O, so every rule here is tested directly.
15
+ */
16
+ exports.FILENAME_MAX = 255;
17
+ /** `text` and `content_base64`: what fits comfortably in one MCP message. */
18
+ exports.INLINE_MAX_BYTES = 2 * 1024 * 1024;
19
+ /** `path`: under the API's effective ceiling (~38 MB raw after the double base64). */
20
+ exports.PATH_MAX_BYTES = 35 * 1024 * 1024;
21
+ /** Largest non-text file read_attachment returns inline as base64. */
22
+ exports.BINARY_INLINE_MAX_BYTES = 2 * 1024 * 1024;
23
+ exports.TEXT_MAX_CHARS = 200_000;
24
+ const FALLBACK_NAME = 'attachment';
25
+ const FORMAT_CONTROL = /\p{Cf}/u;
26
+ /**
27
+ * The name shown and stored (encrypted). Basename only, so a model passing a
28
+ * full path does not leak the user's directory layout into the note; control
29
+ * characters out; capped at 255 with the extension kept.
30
+ */
31
+ function sanitizeFilename(name) {
32
+ const base = name.split(/[\\/]/).pop() ?? '';
33
+ const cleaned = Array.from(base)
34
+ .filter((ch) => {
35
+ const c = ch.codePointAt(0) ?? 0;
36
+ if (c < 0x20 || c === 0x7f || (c >= 0x80 && c <= 0x9f))
37
+ return false;
38
+ // Line and paragraph separators break lines like a newline does; format
39
+ // controls (bidi overrides, zero-width characters, BOM) make a name
40
+ // read differently from what it is.
41
+ return c !== 0x2028 && c !== 0x2029 && !FORMAT_CONTROL.test(ch);
42
+ })
43
+ .join('')
44
+ .trim();
45
+ if (!cleaned || cleaned === '.' || cleaned === '..')
46
+ return FALLBACK_NAME;
47
+ if (cleaned.length <= exports.FILENAME_MAX)
48
+ return cleaned;
49
+ const dot = cleaned.lastIndexOf('.');
50
+ const ext = dot > 0 && cleaned.length - dot <= 16 ? cleaned.slice(dot) : '';
51
+ return cleaned.slice(0, exports.FILENAME_MAX - ext.length) + ext;
52
+ }
53
+ /** Every value here is on the API's MIME allowlist (virlow-api src/lib/validation.ts). */
54
+ const MIME_BY_EXT = {
55
+ txt: 'text/plain',
56
+ log: 'text/plain',
57
+ md: 'text/markdown',
58
+ markdown: 'text/markdown',
59
+ csv: 'text/csv',
60
+ json: 'application/json',
61
+ xml: 'application/xml',
62
+ html: 'text/html',
63
+ htm: 'text/html',
64
+ rtf: 'application/rtf',
65
+ pdf: 'application/pdf',
66
+ png: 'image/png',
67
+ jpg: 'image/jpeg',
68
+ jpeg: 'image/jpeg',
69
+ gif: 'image/gif',
70
+ webp: 'image/webp',
71
+ svg: 'image/svg+xml',
72
+ bmp: 'image/bmp',
73
+ tif: 'image/tiff',
74
+ tiff: 'image/tiff',
75
+ avif: 'image/avif',
76
+ heic: 'image/heic',
77
+ heif: 'image/heif',
78
+ ico: 'image/x-icon',
79
+ doc: 'application/msword',
80
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
81
+ xls: 'application/vnd.ms-excel',
82
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
83
+ ppt: 'application/vnd.ms-powerpoint',
84
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
85
+ pages: 'application/vnd.apple.pages',
86
+ numbers: 'application/vnd.apple.numbers',
87
+ zip: 'application/zip',
88
+ gz: 'application/gzip',
89
+ tar: 'application/x-tar',
90
+ '7z': 'application/x-7z-compressed',
91
+ rar: 'application/vnd.rar',
92
+ mp3: 'audio/mpeg',
93
+ m4a: 'audio/x-m4a',
94
+ wav: 'audio/wav',
95
+ ogg: 'audio/ogg',
96
+ aac: 'audio/aac',
97
+ mp4: 'video/mp4',
98
+ webm: 'video/webm',
99
+ mov: 'video/quicktime',
100
+ avi: 'video/x-msvideo',
101
+ woff: 'font/woff',
102
+ woff2: 'font/woff2',
103
+ ttf: 'font/ttf',
104
+ otf: 'font/otf',
105
+ wasm: 'application/wasm',
106
+ };
107
+ /**
108
+ * Source code. Stored as text/plain, because text/javascript and friends are
109
+ * not on the API allowlist; read back as text by extension.
110
+ */
111
+ const CODE_EXTENSIONS = new Set([
112
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'kt', 'swift',
113
+ 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'sh', 'bash', 'zsh', 'ps1', 'sql', 'yaml', 'yml',
114
+ 'toml', 'ini', 'conf', 'env', 'css', 'scss', 'less', 'vue', 'svelte', 'lua', 'r', 'dart',
115
+ 'scala', 'gradle', 'dockerfile', 'makefile',
116
+ ]);
117
+ const TEXT_MIME = new Set([
118
+ 'application/json',
119
+ 'application/xml',
120
+ 'application/javascript',
121
+ 'application/x-javascript',
122
+ 'application/x-ndjson',
123
+ 'application/yaml',
124
+ 'application/x-yaml',
125
+ ]);
126
+ /** Lowercased extension, or the whole lowercased name when there is no dot (Dockerfile). */
127
+ function extOf(filename) {
128
+ const lower = filename.toLowerCase();
129
+ const dot = lower.lastIndexOf('.');
130
+ return dot === -1 ? lower : lower.slice(dot + 1);
131
+ }
132
+ function inferMimeType(filename) {
133
+ const ext = extOf(filename);
134
+ if (MIME_BY_EXT[ext])
135
+ return MIME_BY_EXT[ext];
136
+ if (CODE_EXTENSIONS.has(ext))
137
+ return 'text/plain';
138
+ return 'application/octet-stream';
139
+ }
140
+ function isTextLike(mimeType, filename) {
141
+ const mime = mimeType.split(';')[0].trim().toLowerCase();
142
+ if (mime.startsWith('text/') || TEXT_MIME.has(mime))
143
+ return true;
144
+ if (mime.endsWith('+json') || mime.endsWith('+xml'))
145
+ return true;
146
+ const ext = extOf(filename);
147
+ if (CODE_EXTENSIONS.has(ext))
148
+ return true;
149
+ const byExt = MIME_BY_EXT[ext];
150
+ return byExt !== undefined && (byExt.startsWith('text/') || TEXT_MIME.has(byExt));
151
+ }
152
+ function formatSize(bytes) {
153
+ if (bytes < 1024)
154
+ return `${bytes} B`;
155
+ if (bytes < 1024 * 1024)
156
+ return `${(bytes / 1024).toFixed(1)} KB`;
157
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
158
+ }
159
+ /**
160
+ * atob accepts sloppy input on some runtimes; this does not. Bounded before
161
+ * anything is decoded: four characters carry three bytes, so input longer than
162
+ * ceil(max * 4 / 3) plus one padded group cannot fit in `max` and is refused
163
+ * without allocating its decoded form.
164
+ */
165
+ function decodeBase64Strict(input, max = exports.INLINE_MAX_BYTES) {
166
+ const compact = input.replace(/\s+/g, '');
167
+ if (compact.length > Math.ceil((max * 4) / 3) + 4) {
168
+ throw new Error(`content_base64 is about ${formatSize(Math.floor((compact.length * 3) / 4))}; the limit for inline content is ${formatSize(max)}.`);
169
+ }
170
+ if (compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) {
171
+ throw new Error('content_base64 is not valid base64.');
172
+ }
173
+ return (0, virlow_crypto_1.base64ToBytes)(compact);
174
+ }
175
+ /** Size gate shared by every add_attachment source. Returns the bytes so it can wrap a source inline. */
176
+ function checkSize(bytes, max, label) {
177
+ if (bytes.byteLength === 0) {
178
+ throw new Error(`${label} is empty. There is nothing to attach.`);
179
+ }
180
+ if (bytes.byteLength > max) {
181
+ const where = max === exports.INLINE_MAX_BYTES
182
+ ? 'the limit for inline content is'
183
+ : 'the limit for attaching from disk is';
184
+ const hint = max === exports.INLINE_MAX_BYTES
185
+ ? ' Attach larger files from disk with path, using a local Virlow MCP server.'
186
+ : '';
187
+ throw new Error(`${label} is ${formatSize(bytes.byteLength)}; ${where} ${formatSize(max)}.${hint}`);
188
+ }
189
+ return bytes;
190
+ }
191
+ /**
192
+ * What read_attachment returns for a file it does not save: text as text,
193
+ * small binaries as base64 under a one-line header, and a clear refusal for
194
+ * anything bigger.
195
+ */
196
+ function renderAttachment(info, bytes, opts) {
197
+ // The name is the one part of the header a file's author controls, so it is
198
+ // cleaned here too: a newline in it must not start a line of its own.
199
+ const filename = sanitizeFilename(info.filename);
200
+ if (isTextLike(info.mimeType, filename)) {
201
+ const text = new TextDecoder('utf-8').decode(bytes);
202
+ if (text.length <= exports.TEXT_MAX_CHARS)
203
+ return text;
204
+ return (`${text.slice(0, exports.TEXT_MAX_CHARS)}\n\n` +
205
+ `[truncated: showing the first ${exports.TEXT_MAX_CHARS} of ${text.length} characters]`);
206
+ }
207
+ if (bytes.byteLength <= exports.BINARY_INLINE_MAX_BYTES) {
208
+ return `${filename} · ${info.mimeType} · ${formatSize(bytes.byteLength)} · base64\n${(0, virlow_crypto_1.bytesToBase64)(bytes)}`;
209
+ }
210
+ const size = `${filename} is ${formatSize(bytes.byteLength)}, larger than the ${formatSize(exports.BINARY_INLINE_MAX_BYTES)} that can be returned inline.`;
211
+ throw new Error(opts.canSave
212
+ ? `${size} Pass save_to with an absolute path to write it to disk.`
213
+ : `${size} Open it in the Virlow app, or read it through a local Virlow MCP server, which can save it to disk.`);
214
+ }
@@ -0,0 +1,38 @@
1
+ import { type VirlowApi } from './api.js';
2
+ import type { Vault } from './vault.js';
3
+ export interface AttachmentInfo {
4
+ id: string;
5
+ noteId: string;
6
+ filename: string;
7
+ mimeType: string;
8
+ size: number;
9
+ uploadedAt: string;
10
+ deletedAt: string | null;
11
+ }
12
+ export declare class AttachmentsService {
13
+ private readonly api;
14
+ private readonly vault;
15
+ constructor(api: VirlowApi, vault: Vault);
16
+ list(noteId: string, includeTrashed: boolean): Promise<AttachmentInfo[]>;
17
+ add(input: {
18
+ noteId: string;
19
+ filename: string;
20
+ mimeType?: string;
21
+ /** The bytes came from `text`: a name with no known extension is then text, not binary. */
22
+ fromText?: boolean;
23
+ bytes: Uint8Array;
24
+ }): Promise<AttachmentInfo>;
25
+ read(id: string): Promise<{
26
+ info: AttachmentInfo;
27
+ bytes: Uint8Array;
28
+ }>;
29
+ rename(id: string, newFilename: string): Promise<AttachmentInfo>;
30
+ move(id: string, toNoteId: string): Promise<AttachmentInfo>;
31
+ trash(id: string): Promise<void>;
32
+ restore(id: string): Promise<AttachmentInfo>;
33
+ /** Metadata with the filename decrypted. A legacy row's placeholder title
34
+ * does not decrypt; it falls back to its plaintext name, then a generic one. */
35
+ private describe;
36
+ private requireExposed;
37
+ private requireSession;
38
+ }
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AttachmentsService = void 0;
4
+ const virlow_crypto_1 = require("@batalabs/virlow-crypto");
5
+ const exposure_js_1 = require("./exposure.js");
6
+ const api_js_1 = require("./api.js");
7
+ const attachment_format_js_1 = require("./attachment-format.js");
8
+ /** A file this vault's key cannot open: encrypted before the account had a
9
+ * master password, under the web app's old per-device key. */
10
+ const WRONG_KEY_MESSAGE = 'Could not decrypt this file with your vault key. Open it in the Virlow app.';
11
+ /**
12
+ * The API's 409 for renaming a v1 file (virlow-api src/routes/files/crud.ts):
13
+ * its content shares the column IV with the filename, so the name cannot be
14
+ * re-encrypted on its own. Matched on the text, because the other 409 on that
15
+ * route ("Restore this file before changing it") must pass through as it is.
16
+ */
17
+ const API_LEGACY_RENAME_PREFIX = 'This file uses an older format.';
18
+ function renameFailure(err) {
19
+ if (err instanceof api_js_1.ApiError && err.status === 409 && err.message.startsWith(API_LEGACY_RENAME_PREFIX)) {
20
+ return new virlow_crypto_1.LegacyAttachmentError();
21
+ }
22
+ return err;
23
+ }
24
+ function decryptFailure(err) {
25
+ if (err instanceof virlow_crypto_1.LegacyAttachmentError)
26
+ return err;
27
+ if (err instanceof Error && err.name === 'OperationError')
28
+ return new Error(WRONG_KEY_MESSAGE);
29
+ return err instanceof Error ? err : new Error(String(err));
30
+ }
31
+ /**
32
+ * Attachments as the MCP tools see them. Holds no state of its own (no
33
+ * caches, no lock hook), so buildServer can make one per server without
34
+ * touching the shared ServerServices shape that virlow-api builds by hand.
35
+ *
36
+ * Every method checks exposure for every note it touches, the same rule as
37
+ * NotesService.requireExposed: knowing a file's id is not consent to reach
38
+ * into a folder the user has not opened to AI.
39
+ */
40
+ function inferredMimeType(filename, fromText) {
41
+ const inferred = (0, attachment_format_js_1.inferMimeType)(filename);
42
+ return fromText && inferred === 'application/octet-stream' ? 'text/plain' : inferred;
43
+ }
44
+ class AttachmentsService {
45
+ api;
46
+ vault;
47
+ constructor(api, vault) {
48
+ this.api = api;
49
+ this.vault = vault;
50
+ }
51
+ async list(noteId, includeTrashed) {
52
+ const key = this.requireSession();
53
+ await this.requireExposed(noteId);
54
+ const live = await this.api.listFiles(noteId);
55
+ const trashed = includeTrashed ? await this.api.listFiles(noteId, { trashed: true }) : [];
56
+ return Promise.all([...live, ...trashed].map((file) => this.describe(key, file)));
57
+ }
58
+ async add(input) {
59
+ // A backstop under the tools' own per-source caps, so no caller can encrypt
60
+ // and send nothing, or more than the API could ever accept.
61
+ if (input.bytes.byteLength === 0)
62
+ throw new Error('The file is empty. There is nothing to attach.');
63
+ if (input.bytes.byteLength > attachment_format_js_1.PATH_MAX_BYTES) {
64
+ throw new Error(`The file is ${(0, attachment_format_js_1.formatSize)(input.bytes.byteLength)}; the most that can be attached is ${(0, attachment_format_js_1.formatSize)(attachment_format_js_1.PATH_MAX_BYTES)}.`);
65
+ }
66
+ const key = this.requireSession();
67
+ await this.requireExposed(input.noteId);
68
+ const filename = (0, attachment_format_js_1.sanitizeFilename)(input.filename);
69
+ const encrypted = await (0, virlow_crypto_1.encryptAttachment)(key, filename, input.bytes);
70
+ const row = await this.api.uploadFile(input.noteId, {
71
+ ...encrypted,
72
+ // Stored for the web app's legacy per-salt key; unused under a master key.
73
+ salt: (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32))),
74
+ mimeType: input.mimeType ?? inferredMimeType(filename, input.fromText === true),
75
+ size: input.bytes.byteLength,
76
+ });
77
+ return { ...infoOf(row), filename };
78
+ }
79
+ async read(id) {
80
+ const key = this.requireSession();
81
+ // Metadata first, so a file in a closed folder is never downloaded at all.
82
+ const meta = await this.api.getFileMeta(id);
83
+ await this.requireExposed(meta.noteId);
84
+ const file = await this.api.getFile(id);
85
+ // Moved between the two calls: check where it is now, too.
86
+ if (file.noteId !== meta.noteId)
87
+ await this.requireExposed(file.noteId);
88
+ try {
89
+ const { filename, bytes } = await (0, virlow_crypto_1.decryptAttachment)(key, file);
90
+ return { info: { ...infoOf(file), filename: (0, attachment_format_js_1.sanitizeFilename)(filename) }, bytes };
91
+ }
92
+ catch (err) {
93
+ throw decryptFailure(err);
94
+ }
95
+ }
96
+ async rename(id, newFilename) {
97
+ const key = this.requireSession();
98
+ const file = await this.api.getFileMeta(id);
99
+ await this.requireExposed(file.noteId);
100
+ const filename = (0, attachment_format_js_1.sanitizeFilename)(newFilename);
101
+ const row = await this.api
102
+ .patchFile(id, await (0, virlow_crypto_1.encryptAttachmentName)(key, filename))
103
+ .catch((err) => {
104
+ throw renameFailure(err);
105
+ });
106
+ return { ...infoOf(row), filename };
107
+ }
108
+ async move(id, toNoteId) {
109
+ const key = this.requireSession();
110
+ const file = await this.api.getFileMeta(id);
111
+ // Both ends: out of a closed folder would reveal it, into one would hide
112
+ // a file where the user cannot expect Claude to have put it.
113
+ await this.requireExposed(file.noteId, toNoteId);
114
+ return this.describe(key, await this.api.patchFile(id, { noteId: toNoteId }));
115
+ }
116
+ async trash(id) {
117
+ this.requireSession();
118
+ const file = await this.api.getFileMeta(id);
119
+ await this.requireExposed(file.noteId);
120
+ await this.api.deleteFile(id);
121
+ }
122
+ async restore(id) {
123
+ const key = this.requireSession();
124
+ const file = await this.api.getFileMeta(id);
125
+ await this.requireExposed(file.noteId);
126
+ return this.describe(key, await this.api.restoreFile(id));
127
+ }
128
+ /** Metadata with the filename decrypted. A legacy row's placeholder title
129
+ * does not decrypt; it falls back to its plaintext name, then a generic one. */
130
+ async describe(key, file) {
131
+ try {
132
+ const filename = await (0, virlow_crypto_1.decryptTitleField)(key, file.encryptedTitle, file.iv);
133
+ // Names reach the model; one with a newline in it must not add a line.
134
+ return { ...infoOf(file), filename: (0, attachment_format_js_1.sanitizeFilename)(filename) };
135
+ }
136
+ catch {
137
+ return infoOf(file);
138
+ }
139
+ }
140
+ async requireExposed(...noteIds) {
141
+ const [{ folders }, ...rows] = await Promise.all([
142
+ this.api.listFolders(),
143
+ ...noteIds.map((id) => this.api.getNote(id)),
144
+ ]);
145
+ const exposure = (0, exposure_js_1.computeExposure)(folders);
146
+ for (const row of rows) {
147
+ if (!exposure.allows(row.folderId)) {
148
+ throw new Error(`Note ${row.id} is in a folder that is not exposed to MCP. Turn on "Expose to MCP" in that folder's settings in the Virlow app to access it here.`);
149
+ }
150
+ }
151
+ }
152
+ requireSession() {
153
+ const key = this.vault.session.key;
154
+ this.vault.touch();
155
+ return key;
156
+ }
157
+ }
158
+ exports.AttachmentsService = AttachmentsService;
159
+ function infoOf(file) {
160
+ return {
161
+ id: file.id,
162
+ noteId: file.noteId,
163
+ filename: (0, attachment_format_js_1.sanitizeFilename)(file.originalName ?? 'attachment'),
164
+ mimeType: file.mimeType,
165
+ size: file.size,
166
+ uploadedAt: file.uploadedAt,
167
+ deletedAt: file.deletedAt ?? null,
168
+ };
169
+ }
@@ -1,8 +1,10 @@
1
1
  export { ApiError, VirlowApi } from './api.js';
2
- export type { ApiFolder, ApiNote, AuthTokens, LoginResult, NoteWrite, RefreshBody } from './api.js';
2
+ export type { ApiFile, ApiFileWithData, ApiFolder, ApiNote, FileUpload, AuthTokens, LoginResult, NoteWrite, RefreshBody } from './api.js';
3
3
  export { LockedError, Vault } from './vault.js';
4
4
  export type { UnlockedSession } from './vault.js';
5
5
  export { NotesService, SEARCH_SCAN_CAP } from './notes.js';
6
+ export { AttachmentsService } from './attachments.js';
7
+ export type { AttachmentInfo } from './attachments.js';
6
8
  export { MemoryStore, SYNC_PAGE_LIMIT } from './memories.js';
7
9
  export type { CachedMemory, SimilarMemory, SyncResult } from './memories.js';
8
10
  export { MEMORY_NOTE_TYPE, mergeMeta, parseMemoryNote, serializeMemoryBody, toCodeEnvelope, } from './memory-note.js';
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.htmlToText = exports.extractLinkIds = exports.buildTitleMatcher = exports.withTags = exports.updateProp = exports.tagsFromAttrs = exports.sortedProps = exports.reorderProps = exports.removeProp = exports.newPropId = exports.encodeSchema = exports.encodeAttrs = exports.emptySchema = exports.emptyAttrs = exports.defineProp = exports.decodeSchema = exports.decodeAttrs = exports.TAGS_PROP = exports.ATTRS_MAX_BYTES = exports.memoriesAllowed = exports.MEMORIES_DISABLED_MESSAGE = exports.EmbeddingCache = exports.filteredNotice = exports.computeExposure = exports.resolveMemoryTree = exports.ensureNamespace = exports.MEMORIES_ROOT_NAME = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
3
+ exports.htmlToText = exports.extractLinkIds = exports.buildTitleMatcher = exports.withTags = exports.updateProp = exports.tagsFromAttrs = exports.sortedProps = exports.reorderProps = exports.removeProp = exports.newPropId = exports.encodeSchema = exports.encodeAttrs = exports.emptySchema = exports.emptyAttrs = exports.defineProp = exports.decodeSchema = exports.decodeAttrs = exports.TAGS_PROP = exports.ATTRS_MAX_BYTES = exports.memoriesAllowed = exports.MEMORIES_DISABLED_MESSAGE = exports.EmbeddingCache = exports.filteredNotice = exports.computeExposure = exports.resolveMemoryTree = exports.ensureNamespace = exports.MEMORIES_ROOT_NAME = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.AttachmentsService = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
4
4
  var api_js_1 = require("./api.js");
5
5
  Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return api_js_1.ApiError; } });
6
6
  Object.defineProperty(exports, "VirlowApi", { enumerable: true, get: function () { return api_js_1.VirlowApi; } });
@@ -10,6 +10,8 @@ Object.defineProperty(exports, "Vault", { enumerable: true, get: function () { r
10
10
  var notes_js_1 = require("./notes.js");
11
11
  Object.defineProperty(exports, "NotesService", { enumerable: true, get: function () { return notes_js_1.NotesService; } });
12
12
  Object.defineProperty(exports, "SEARCH_SCAN_CAP", { enumerable: true, get: function () { return notes_js_1.SEARCH_SCAN_CAP; } });
13
+ var attachments_js_1 = require("./attachments.js");
14
+ Object.defineProperty(exports, "AttachmentsService", { enumerable: true, get: function () { return attachments_js_1.AttachmentsService; } });
13
15
  var memories_js_1 = require("./memories.js");
14
16
  Object.defineProperty(exports, "MemoryStore", { enumerable: true, get: function () { return memories_js_1.MemoryStore; } });
15
17
  Object.defineProperty(exports, "SYNC_PAGE_LIMIT", { enumerable: true, get: function () { return memories_js_1.SYNC_PAGE_LIMIT; } });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The one gate for every disk path a model hands the attachment tools
3
+ * (`path` on add_attachment, `save_to` on read_attachment).
4
+ *
5
+ * "Absolute" is not enough on Windows. path.win32.isAbsolute accepts
6
+ * \\server\share\x, //server/share/x, \\?\UNC\..., \\.\pipe\x and a bare /x.
7
+ * Touching a UNC path makes Windows try SMB to that host, which hands it the
8
+ * user's NTLM hash; writing to one sends decrypted vault files off the
9
+ * machine; device paths reach pipes and drivers rather than files. A
10
+ * prompt-injected model can ask for any of these, so only a plain drive path
11
+ * is accepted.
12
+ */
13
+ export type PathPlatform = 'win32' | 'posix';
14
+ /**
15
+ * Returns the path to use, or throws a message the model can act on.
16
+ *
17
+ * Windows: only `X:\...` (either slash). `\\?\X:\...` is accepted and
18
+ * normalised to `X:\...`; every other `\\` or `//` form (UNC, `\\?\UNC\`,
19
+ * `\\.\` devices), a rooted path with no drive, a drive-relative path,
20
+ * alternate data streams and reserved device names are refused.
21
+ * POSIX: a leading `/`, but not `//`, which POSIX leaves
22
+ * implementation-defined and some systems treat as a network root.
23
+ *
24
+ * `platform` defaults to this process's; tests pass it to cover both.
25
+ */
26
+ export declare function requireLocalAbsolutePath(p: string, label: string, platform?: PathPlatform): string;
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ /**
3
+ * The one gate for every disk path a model hands the attachment tools
4
+ * (`path` on add_attachment, `save_to` on read_attachment).
5
+ *
6
+ * "Absolute" is not enough on Windows. path.win32.isAbsolute accepts
7
+ * \\server\share\x, //server/share/x, \\?\UNC\..., \\.\pipe\x and a bare /x.
8
+ * Touching a UNC path makes Windows try SMB to that host, which hands it the
9
+ * user's NTLM hash; writing to one sends decrypted vault files off the
10
+ * machine; device paths reach pipes and drivers rather than files. A
11
+ * prompt-injected model can ask for any of these, so only a plain drive path
12
+ * is accepted.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.requireLocalAbsolutePath = requireLocalAbsolutePath;
16
+ const currentPlatform = () => (process.platform === 'win32' ? 'win32' : 'posix');
17
+ /** Names Windows maps to devices in any folder, with or without an extension. */
18
+ const WIN_RESERVED = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)(\..*)?$/i;
19
+ function isLocalWindowsPath(p) {
20
+ if (!/^[A-Za-z]:[\\/]/.test(p))
21
+ return false;
22
+ // A colon past the drive is an alternate data stream (file.txt:stream).
23
+ if (p.indexOf(':', 2) !== -1)
24
+ return false;
25
+ return !p
26
+ .slice(3)
27
+ .split(/[\\/]/)
28
+ .some((segment) => WIN_RESERVED.test(segment.replace(/[ .]+$/, '')));
29
+ }
30
+ /**
31
+ * Returns the path to use, or throws a message the model can act on.
32
+ *
33
+ * Windows: only `X:\...` (either slash). `\\?\X:\...` is accepted and
34
+ * normalised to `X:\...`; every other `\\` or `//` form (UNC, `\\?\UNC\`,
35
+ * `\\.\` devices), a rooted path with no drive, a drive-relative path,
36
+ * alternate data streams and reserved device names are refused.
37
+ * POSIX: a leading `/`, but not `//`, which POSIX leaves
38
+ * implementation-defined and some systems treat as a network root.
39
+ *
40
+ * `platform` defaults to this process's; tests pass it to cover both.
41
+ */
42
+ function requireLocalAbsolutePath(p, label, platform = currentPlatform()) {
43
+ if (platform === 'win32') {
44
+ const candidate = /^\\\\\?\\[A-Za-z]:\\/.test(p) ? p.slice(4) : p;
45
+ if (isLocalWindowsPath(candidate))
46
+ return candidate;
47
+ throw new Error(`${label} must be a full local path like C:\\Users\\you\\file.pdf, got "${p}". ` +
48
+ 'Network and device paths are not allowed.');
49
+ }
50
+ if (p.startsWith('/') && !p.startsWith('//'))
51
+ return p;
52
+ throw new Error(`${label} must be an absolute path like /home/you/file.pdf, got "${p}".`);
53
+ }