@batalabs/virlow-mcp-core 3.119.4 → 3.121.0

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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.AttachmentsService = void 0;
3
+ exports.AttachmentsService = exports.SMALL_FILE_BYTES = void 0;
4
4
  exports.largeFileMessage = largeFileMessage;
5
5
  const virlow_crypto_1 = require("@batalabs/virlow-crypto");
6
6
  const exposure_js_1 = require("./exposure.js");
@@ -9,6 +9,11 @@ const api_js_1 = require("./api.js");
9
9
  const vault_js_1 = require("./vault.js");
10
10
  const keyring_js_2 = require("./keyring.js");
11
11
  const attachment_format_js_1 = require("./attachment-format.js");
12
+ const large_files_js_1 = require("./large-files.js");
13
+ /** Above this a file goes as a large upload, in chunks. The web app's limit too. */
14
+ exports.SMALL_FILE_BYTES = 10 * 1024 * 1024;
15
+ /** How much of a small file's plaintext one `readRange` write carries. */
16
+ const SMALL_FILE_WRITE_BYTES = 1024 * 1024;
12
17
  /** A file this vault's key cannot open: encrypted before the account had a
13
18
  * master password, under the web app's old per-device key. */
14
19
  const WRONG_KEY_MESSAGE = 'Could not decrypt this file with your vault key. Open it in the Virlow app.';
@@ -51,6 +56,7 @@ function inferredMimeType(filename, fromText) {
51
56
  class AttachmentsService {
52
57
  api;
53
58
  vault;
59
+ options;
54
60
  keyring;
55
61
  /** Bumped when the vault locks. add() and rename() capture it before their
56
62
  * first await and check it right before the upload or patch, so nothing is
@@ -58,9 +64,14 @@ class AttachmentsService {
58
64
  epoch = 0;
59
65
  constructor(api, vault,
60
66
  /** Shared with NotesService (its `keyring`) so keys open once per unlock. */
61
- keyring) {
67
+ keyring,
68
+ /** For the account holder's own surfaces (the desktop drive), where "Expose
69
+ * to MCP" does not apply: that switch is consent for AI tools, and no AI
70
+ * tool is the caller. Never set this for anything serving tools. */
71
+ options = {}) {
62
72
  this.api = api;
63
73
  this.vault = vault;
74
+ this.options = options;
64
75
  this.keyring = keyring ?? new keyring_js_2.WorkspaceKeyring(api, vault);
65
76
  this.vault.onLock = () => {
66
77
  this.epoch++;
@@ -127,6 +138,93 @@ class AttachmentsService {
127
138
  throw decryptFailure(err);
128
139
  }
129
140
  }
141
+ /**
142
+ * A file's plaintext for bytes [start, end), written as it arrives, so a large
143
+ * file never sits in memory whole. May write more than was asked, from an
144
+ * earlier offset and on to the end of what it had to decrypt anyway: a small
145
+ * file is one ciphertext, a large one opens by the chunk. Offsets given to
146
+ * `write` are always chunk or 1 MiB boundaries.
147
+ */
148
+ async readRange(id, input) {
149
+ const key = this.requireSession();
150
+ const startEpoch = this.epoch;
151
+ const meta = await this.api.getFileMeta(id);
152
+ let place = await this.requirePlace(meta.noteId);
153
+ const file = await this.api.getFile(id);
154
+ // Moved between the two calls: check where it is now, too.
155
+ if (file.noteId !== meta.noteId)
156
+ place = await this.requirePlace(file.noteId);
157
+ const fileKey = await this.readKey(key, file, place);
158
+ this.requireSameSession(startEpoch);
159
+ const write = async (bytes, offset) => {
160
+ this.requireSameSession(startEpoch);
161
+ await input.write(bytes, offset);
162
+ };
163
+ try {
164
+ if (file.chunkSize) {
165
+ if (!file.download)
166
+ throw new Error('The file has no stored content to read');
167
+ await (0, large_files_js_1.downloadLargeFile)(this.api.largeFileTransport(), {
168
+ size: file.size,
169
+ chunkSize: file.chunkSize,
170
+ header: file.encryptedData,
171
+ url: file.download.url,
172
+ masterKey: fileKey,
173
+ refreshUrl: async () => {
174
+ const fresh = await this.api.getFile(id);
175
+ if (!fresh.download)
176
+ throw new Error('The file has no stored content to read');
177
+ return fresh.download.url;
178
+ },
179
+ range: { start: input.start, end: input.end },
180
+ write,
181
+ ...(input.signal ? { signal: input.signal } : {}),
182
+ });
183
+ return;
184
+ }
185
+ const { bytes } = await (0, virlow_crypto_1.decryptAttachment)(fileKey, file);
186
+ const from = Math.floor(input.start / SMALL_FILE_WRITE_BYTES) * SMALL_FILE_WRITE_BYTES;
187
+ for (let at = from; at < bytes.byteLength; at += SMALL_FILE_WRITE_BYTES) {
188
+ if (input.signal?.aborted)
189
+ throw new DOMException('Download cancelled', 'AbortError');
190
+ await write(bytes.subarray(at, Math.min(at + SMALL_FILE_WRITE_BYTES, bytes.byteLength)), at);
191
+ }
192
+ }
193
+ catch (err) {
194
+ throw err?.name === 'AbortError' || err instanceof vault_js_1.LockedError ? err : decryptFailure(err);
195
+ }
196
+ }
197
+ /** A file of any size from a Blob: one request when small, chunks when not. */
198
+ async addBlob(input) {
199
+ const { file } = input;
200
+ if (file.size <= exports.SMALL_FILE_BYTES) {
201
+ return this.add({
202
+ noteId: input.noteId,
203
+ filename: input.filename,
204
+ bytes: new Uint8Array(await file.arrayBuffer()),
205
+ ...(input.mimeType ? { mimeType: input.mimeType } : {}),
206
+ });
207
+ }
208
+ const key = this.requireSession();
209
+ const startEpoch = this.epoch;
210
+ const place = await this.requirePlace(input.noteId);
211
+ const filename = (0, attachment_format_js_1.sanitizeFilename)(input.filename);
212
+ const choice = await this.writeKey(key, place, place.row.keyVersion ?? null);
213
+ const name = await (0, virlow_crypto_1.encryptAttachmentName)(choice.key, filename);
214
+ this.requireSameSession(startEpoch);
215
+ const row = await (0, large_files_js_1.uploadLargeFile)(this.api.largeFileTransport(), {
216
+ noteId: input.noteId,
217
+ file,
218
+ mimeType: input.mimeType ?? inferredMimeType(filename, false),
219
+ masterKey: choice.key,
220
+ keyVersion: choice.keyVersion,
221
+ encryptedTitle: name.encryptedTitle,
222
+ iv: name.iv,
223
+ salt: (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32))),
224
+ ...(input.signal ? { signal: input.signal } : {}),
225
+ });
226
+ return { ...infoOf(row), filename };
227
+ }
130
228
  async rename(id, newFilename) {
131
229
  const key = this.requireSession();
132
230
  const startEpoch = this.epoch;
@@ -215,7 +313,7 @@ class AttachmentsService {
215
313
  });
216
314
  const workspaces = new Map(folders.map((f) => [f.id, f.workspaceId ?? null]));
217
315
  return rows.map((row) => {
218
- if (!exposure.allows(row.folderId)) {
316
+ if (this.options.allFolders !== true && !exposure.allows(row.folderId)) {
219
317
  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.`);
220
318
  }
221
319
  const listed = row.folderId === null ? null : (workspaces.get(row.folderId) ?? null);
@@ -3,7 +3,7 @@ export type { ApiFile, ApiFileWithData, ApiFolder, ApiIdentity, ApiNote, ApiWork
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';
6
+ export { AttachmentsService, SMALL_FILE_BYTES } from './attachments.js';
7
7
  export { KeyChangedError, NoWorkspaceKeyError, WorkspaceKeyring, memorySignerMemory } from './keyring.js';
8
8
  export type { KeyChoice, SignerMemory } from './keyring.js';
9
9
  export { fileSignerMemory } from './signer-file.js';
package/dist/cjs/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- 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.serializeRegistry = exports.registryKeyFor = exports.parseRegistry = exports.mergeRegistries = exports.emptyRegistry = exports.describedEntities = exports.describeInRegistry = exports.canWriteRegistry = exports.ENTITY_REGISTRY_VERSION = exports.ENTITY_REGISTRY_TITLE = exports.ENTITY_REGISTRY_NOTE_TYPE = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.fileSignerMemory = exports.memorySignerMemory = exports.WorkspaceKeyring = exports.NoWorkspaceKeyError = exports.KeyChangedError = exports.AttachmentsService = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
4
- exports.TodoService = exports.htmlToText = exports.extractLinkIds = exports.buildTitleMatcher = exports.withTags = exports.updateProp = exports.tagsFromAttrs = exports.sortedProps = void 0;
3
+ 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.serializeRegistry = exports.registryKeyFor = exports.parseRegistry = exports.mergeRegistries = exports.emptyRegistry = exports.describedEntities = exports.describeInRegistry = exports.canWriteRegistry = exports.ENTITY_REGISTRY_VERSION = exports.ENTITY_REGISTRY_TITLE = exports.ENTITY_REGISTRY_NOTE_TYPE = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.fileSignerMemory = exports.memorySignerMemory = exports.WorkspaceKeyring = exports.NoWorkspaceKeyError = exports.KeyChangedError = exports.SMALL_FILE_BYTES = exports.AttachmentsService = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
4
+ exports.TodoService = exports.htmlToText = exports.extractLinkIds = exports.buildTitleMatcher = exports.withTags = exports.updateProp = exports.tagsFromAttrs = exports.sortedProps = exports.reorderProps = void 0;
5
5
  var api_js_1 = require("./api.js");
6
6
  Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return api_js_1.ApiError; } });
7
7
  Object.defineProperty(exports, "VirlowApi", { enumerable: true, get: function () { return api_js_1.VirlowApi; } });
@@ -13,6 +13,7 @@ Object.defineProperty(exports, "NotesService", { enumerable: true, get: function
13
13
  Object.defineProperty(exports, "SEARCH_SCAN_CAP", { enumerable: true, get: function () { return notes_js_1.SEARCH_SCAN_CAP; } });
14
14
  var attachments_js_1 = require("./attachments.js");
15
15
  Object.defineProperty(exports, "AttachmentsService", { enumerable: true, get: function () { return attachments_js_1.AttachmentsService; } });
16
+ Object.defineProperty(exports, "SMALL_FILE_BYTES", { enumerable: true, get: function () { return attachments_js_1.SMALL_FILE_BYTES; } });
16
17
  var keyring_js_1 = require("./keyring.js");
17
18
  Object.defineProperty(exports, "KeyChangedError", { enumerable: true, get: function () { return keyring_js_1.KeyChangedError; } });
18
19
  Object.defineProperty(exports, "NoWorkspaceKeyError", { enumerable: true, get: function () { return keyring_js_1.NoWorkspaceKeyError; } });
@@ -0,0 +1,72 @@
1
+ export interface Transport {
2
+ /** A JSON call to the API with the session's auth. `path` starts with "/". */
3
+ api: <T>(path: string, init?: {
4
+ method?: string;
5
+ body?: unknown;
6
+ }) => Promise<T>;
7
+ /** PUT bytes to a signed URL, reporting bytes sent. Resolves with the HTTP status. */
8
+ put: (url: string, body: Uint8Array, onSent: (sent: number) => void, signal?: AbortSignal) => Promise<number>;
9
+ /** GET a byte range [start, end) of a signed URL. */
10
+ getRange: (url: string, start: number, end: number, signal?: AbortSignal) => Promise<Response>;
11
+ /** Wait before a retry. Replaced in tests. */
12
+ wait?: (ms: number) => Promise<void>;
13
+ }
14
+ /** The API's answer was a refusal it worded for a person. */
15
+ export interface ApiRefusalBody {
16
+ error?: string;
17
+ code?: string;
18
+ feature?: string;
19
+ missing?: number[];
20
+ }
21
+ export declare class ApiRefusal extends Error {
22
+ readonly status: number;
23
+ readonly body: ApiRefusalBody;
24
+ constructor(status: number, body: ApiRefusalBody);
25
+ }
26
+ export interface LargeUploadInput {
27
+ noteId: string;
28
+ file: Blob;
29
+ mimeType: string;
30
+ /** The key that wraps the file's own key: the account key, or a shared workspace's. */
31
+ masterKey: CryptoKey;
32
+ /** The workspace key version `masterKey` is, in a shared workspace. */
33
+ keyVersion?: number | null;
34
+ encryptedTitle: string;
35
+ iv: string;
36
+ salt: string;
37
+ onProgress?: (sentBytes: number) => void;
38
+ signal?: AbortSignal;
39
+ }
40
+ /**
41
+ * Sends `file` as a large upload and returns the attachment the API recorded.
42
+ * If anything fails, the upload is abandoned on the server so the space it
43
+ * reserved is released straight away rather than a day later.
44
+ */
45
+ export declare function uploadLargeFile<Row>(transport: Transport, input: LargeUploadInput): Promise<Row>;
46
+ export interface LargeDownloadInput {
47
+ /** The row from GET /files/:id. */
48
+ size: number;
49
+ chunkSize: number;
50
+ header: string;
51
+ url: string;
52
+ masterKey: CryptoKey;
53
+ /** Asked for a new signed URL when the one in hand stops working. */
54
+ refreshUrl: () => Promise<string>;
55
+ /** Only the chunks covering these plaintext bytes [start, end); the whole
56
+ * file when absent. A reader asked for one part of a file pays for that part. */
57
+ range?: {
58
+ start: number;
59
+ end: number;
60
+ };
61
+ /** Called with each chunk's plaintext and where it starts, in file order. */
62
+ write: (chunk: Uint8Array, offset: number) => Promise<void> | void;
63
+ onProgress?: (receivedBytes: number) => void;
64
+ signal?: AbortSignal;
65
+ }
66
+ /**
67
+ * Reads a large file back: fetches the sealed chunks by range, a few at a
68
+ * time, opens each and hands them to `write` in order. A chunk that is not
69
+ * the one stored at that place in that file fails its tag and the download
70
+ * with it, so what reaches `write` is the file or nothing.
71
+ */
72
+ export declare function downloadLargeFile(transport: Transport, input: LargeDownloadInput): Promise<void>;
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApiRefusal = void 0;
4
+ exports.uploadLargeFile = uploadLargeFile;
5
+ exports.downloadLargeFile = downloadLargeFile;
6
+ const virlow_crypto_1 = require("@batalabs/virlow-crypto");
7
+ /**
8
+ * Files too large to send as one request. The file is cut into chunks, each
9
+ * encrypted here under a key made for this file alone, and each sent straight
10
+ * to object storage at a URL the API signs. The API never carries the bytes,
11
+ * and neither end ever holds the whole file in memory.
12
+ *
13
+ * This module is the transfer only. Who may upload what, and which format a
14
+ * file takes, is fileService's decision.
15
+ */
16
+ /** Chunks in flight at once. Each holds a chunk and its ciphertext in memory. */
17
+ const CONCURRENCY = 3;
18
+ /** Tries per chunk before the transfer fails. */
19
+ const ATTEMPTS = 4;
20
+ /** Part URLs asked for at a time; the API signs at most 100. */
21
+ const URL_BATCH = 50;
22
+ class ApiRefusal extends Error {
23
+ status;
24
+ body;
25
+ constructor(status, body) {
26
+ super(body.error ?? `Request failed: ${status}`);
27
+ this.name = 'ApiRefusal';
28
+ this.status = status;
29
+ this.body = body;
30
+ }
31
+ }
32
+ exports.ApiRefusal = ApiRefusal;
33
+ const defaultWait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
34
+ function aborted(signal) {
35
+ return signal?.aborted === true;
36
+ }
37
+ /** Runs `job` over `items`, `limit` at a time, stopping at the first failure. */
38
+ async function pool(items, limit, job) {
39
+ let next = 0;
40
+ let failure;
41
+ let failed = false;
42
+ const worker = async () => {
43
+ while (!failed && next < items.length) {
44
+ const item = items[next++];
45
+ try {
46
+ await job(item);
47
+ }
48
+ catch (error) {
49
+ if (!failed) {
50
+ failed = true;
51
+ failure = error;
52
+ }
53
+ }
54
+ }
55
+ };
56
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
57
+ if (failed)
58
+ throw failure;
59
+ }
60
+ /**
61
+ * Sends `file` as a large upload and returns the attachment the API recorded.
62
+ * If anything fails, the upload is abandoned on the server so the space it
63
+ * reserved is released straight away rather than a day later.
64
+ */
65
+ async function uploadLargeFile(transport, input) {
66
+ const { file, signal } = input;
67
+ const wait = transport.wait ?? defaultWait;
68
+ const { header, fileKey } = await (0, virlow_crypto_1.createFileKey)(input.masterKey);
69
+ const upload = await transport.api(`/notes/${input.noteId}/files/uploads`, {
70
+ method: 'POST',
71
+ body: {
72
+ mimeType: input.mimeType,
73
+ size: file.size,
74
+ encryptedData: header,
75
+ encryptedTitle: input.encryptedTitle,
76
+ iv: input.iv,
77
+ salt: input.salt,
78
+ ...(input.keyVersion !== null && input.keyVersion !== undefined && { keyVersion: input.keyVersion }),
79
+ },
80
+ });
81
+ try {
82
+ const sentByPart = new Map();
83
+ const report = (partNumber, sent) => {
84
+ sentByPart.set(partNumber, sent);
85
+ if (!input.onProgress)
86
+ return;
87
+ let total = 0;
88
+ for (const value of sentByPart.values())
89
+ total += value;
90
+ input.onProgress(Math.min(total, file.size));
91
+ };
92
+ // Signed URLs, fetched a batch ahead of the parts that need them. A URL
93
+ // that has expired by the time its part is sent is signed again.
94
+ const urls = new Map();
95
+ let signing = Promise.resolve();
96
+ const urlFor = (partNumber, pending, fresh = false) => {
97
+ const ask = signing.then(async () => {
98
+ if (!fresh && urls.has(partNumber))
99
+ return;
100
+ // This part and whichever others still need one, so the batches do
101
+ // not depend on which worker happens to ask first.
102
+ const wanted = fresh
103
+ ? [partNumber]
104
+ : [partNumber, ...pending.filter((n) => n !== partNumber && !urls.has(n))].slice(0, URL_BATCH);
105
+ const signed = await transport.api(`/files/uploads/${upload.id}/parts`, { method: 'POST', body: { partNumbers: wanted } });
106
+ for (const entry of signed.urls)
107
+ urls.set(entry.partNumber, entry.url);
108
+ });
109
+ // One signing call at a time, and a failed one does not poison the next.
110
+ signing = ask.catch(() => { });
111
+ return ask.then(() => {
112
+ const url = urls.get(partNumber);
113
+ if (!url)
114
+ throw new Error(`No upload URL for part ${partNumber}`);
115
+ return url;
116
+ });
117
+ };
118
+ const sendPart = async (partNumber, pending) => {
119
+ const index = partNumber - 1;
120
+ const plain = new Uint8Array(await file.slice(index * upload.chunkSize, (index + 1) * upload.chunkSize).arrayBuffer());
121
+ const sealed = await (0, virlow_crypto_1.encryptFileChunk)(fileKey, index, partNumber === upload.partCount, plain);
122
+ for (let attempt = 1;; attempt++) {
123
+ if (aborted(signal))
124
+ throw new DOMException('Upload cancelled', 'AbortError');
125
+ let status = 0;
126
+ try {
127
+ const url = await urlFor(partNumber, pending, attempt > 1);
128
+ status = await transport.put(url, sealed, (sent) => report(partNumber, Math.min(sent, plain.byteLength)), signal);
129
+ }
130
+ catch (error) {
131
+ if (aborted(signal) || attempt >= ATTEMPTS)
132
+ throw error;
133
+ }
134
+ if (status >= 200 && status < 300) {
135
+ report(partNumber, plain.byteLength);
136
+ return;
137
+ }
138
+ if (attempt >= ATTEMPTS)
139
+ throw new Error(`Uploading part ${partNumber} failed (${status})`);
140
+ report(partNumber, 0);
141
+ await wait(500 * 2 ** (attempt - 1));
142
+ }
143
+ };
144
+ const all = Array.from({ length: upload.partCount }, (_, i) => i + 1);
145
+ await pool(all, CONCURRENCY, (partNumber) => sendPart(partNumber, all));
146
+ // The API checks what the store holds. If it says parts are missing (a
147
+ // PUT that reported success and was lost), send those and ask once more.
148
+ for (let round = 1;; round++) {
149
+ try {
150
+ return await transport.api(`/files/uploads/${upload.id}/complete`, { method: 'POST' });
151
+ }
152
+ catch (error) {
153
+ const missing = error instanceof ApiRefusal && error.body.code === 'UPLOAD_INCOMPLETE'
154
+ ? (error.body.missing ?? [])
155
+ : [];
156
+ if (missing.length === 0 || round >= 2)
157
+ throw error;
158
+ await pool(missing, CONCURRENCY, (partNumber) => sendPart(partNumber, missing));
159
+ }
160
+ }
161
+ }
162
+ catch (error) {
163
+ await transport.api(`/files/uploads/${upload.id}`, { method: 'DELETE' }).catch(() => { });
164
+ throw error;
165
+ }
166
+ }
167
+ /**
168
+ * Reads a large file back: fetches the sealed chunks by range, a few at a
169
+ * time, opens each and hands them to `write` in order. A chunk that is not
170
+ * the one stored at that place in that file fails its tag and the download
171
+ * with it, so what reaches `write` is the file or nothing.
172
+ */
173
+ async function downloadLargeFile(transport, input) {
174
+ const { size, chunkSize, signal } = input;
175
+ const wait = transport.wait ?? defaultWait;
176
+ const fileKey = await (0, virlow_crypto_1.openFileKey)(input.masterKey, input.header);
177
+ const count = (0, virlow_crypto_1.fileChunkCount)(size, chunkSize);
178
+ let url = input.url;
179
+ let refreshing = null;
180
+ const refresh = () => {
181
+ refreshing ??= input
182
+ .refreshUrl()
183
+ .then((fresh) => {
184
+ url = fresh;
185
+ })
186
+ .finally(() => {
187
+ refreshing = null;
188
+ });
189
+ return refreshing;
190
+ };
191
+ const fetchChunk = async (index) => {
192
+ const { start, end } = (0, virlow_crypto_1.sealedChunkRange)(size, chunkSize, index);
193
+ for (let attempt = 1;; attempt++) {
194
+ if (aborted(signal))
195
+ throw new DOMException('Download cancelled', 'AbortError');
196
+ try {
197
+ const response = await transport.getRange(url, start, end, signal);
198
+ if (response.ok) {
199
+ const sealed = new Uint8Array(await response.arrayBuffer());
200
+ if (sealed.byteLength !== end - start)
201
+ throw new Error('The stored file is incomplete');
202
+ return await (0, virlow_crypto_1.decryptFileChunk)(fileKey, index, index === count - 1, sealed);
203
+ }
204
+ if (attempt >= ATTEMPTS)
205
+ throw new Error(`Download failed (${response.status})`);
206
+ // A signed URL past its hour answers 403.
207
+ if (response.status === 403)
208
+ await refresh();
209
+ }
210
+ catch (error) {
211
+ // A chunk that fails its tag will fail it every time.
212
+ if (aborted(signal) || attempt >= ATTEMPTS || error?.name === 'OperationError') {
213
+ throw error;
214
+ }
215
+ }
216
+ await wait(500 * 2 ** (attempt - 1));
217
+ }
218
+ };
219
+ // A window of chunks is fetched together and written in order, so memory
220
+ // holds a few chunks however large the file is.
221
+ const first = input.range ? Math.floor(input.range.start / chunkSize) : 0;
222
+ const stop = input.range ? Math.min(count, Math.ceil(input.range.end / chunkSize)) : count;
223
+ let received = 0;
224
+ for (let from = first; from < stop; from += CONCURRENCY) {
225
+ const window = Array.from({ length: Math.min(CONCURRENCY, stop - from) }, (_, i) => from + i);
226
+ const chunks = await Promise.all(window.map(fetchChunk));
227
+ for (const [i, chunk] of chunks.entries()) {
228
+ await input.write(chunk, (from + i) * chunkSize);
229
+ received += chunk.byteLength;
230
+ input.onProgress?.(received);
231
+ }
232
+ }
233
+ if (!input.range && received !== size)
234
+ throw new Error('The stored file is incomplete');
235
+ }
@@ -20,6 +20,8 @@ export interface AppliedProp {
20
20
  export declare class NotesService {
21
21
  private readonly api;
22
22
  private readonly vault;
23
+ /** See AttachmentsService: for the account holder's own surfaces only. */
24
+ private readonly options;
23
25
  /** Per-unlock cache of decrypted (title, content) keyed by `id:updatedAt`
24
26
  * so re-reading/re-scanning the same note twice in one unlock doesn't
25
27
  * re-run AES-GCM decryption. Cleared whenever the vault locks. */
@@ -65,7 +67,11 @@ export declare class NotesService {
65
67
  /** Opens shared workspaces' keys for this unlock. Public so the attachment
66
68
  * service built next to this one shares it rather than opening them twice. */
67
69
  readonly keyring: WorkspaceKeyring;
68
- constructor(api: VirlowApi, vault: Vault, keyring?: WorkspaceKeyring);
70
+ constructor(api: VirlowApi, vault: Vault, keyring?: WorkspaceKeyring,
71
+ /** See AttachmentsService: for the account holder's own surfaces only. */
72
+ options?: {
73
+ allFolders?: boolean;
74
+ });
69
75
  private handleLock;
70
76
  /** Drops the paged row list after anything that could change which rows
71
77
  * exist, where they live, or what they are called. Called after the write
@@ -257,6 +263,33 @@ export declare class NotesService {
257
263
  }>;
258
264
  withheld: number;
259
265
  }>;
266
+ /**
267
+ * Every folder and every storage item (a note of type `file`), decrypted, for
268
+ * a surface that shows the account's own tree (the desktop drive). Refused
269
+ * unless this service was built with `allFolders`, so no tool path can reach it.
270
+ *
271
+ * Only storage items: files attached to an ordinary note belong to that note
272
+ * and stay out of the drive. An empty storage item is listed, so files can be
273
+ * dropped into it. The memories tree is left out: the MCP server makes it for
274
+ * AI tools and it holds no files. `attachmentStamp` changes when a note's attachments do, so
275
+ * a caller lists a note's files only when it has to. A note whose key does
276
+ * not open here is left out and counted, never opened with the account key.
277
+ */
278
+ listDriveCatalog(): Promise<{
279
+ folders: Array<{
280
+ id: string;
281
+ name: string;
282
+ parentId: string | null;
283
+ }>;
284
+ notes: Array<{
285
+ id: string;
286
+ title: string;
287
+ folderId: string | null;
288
+ updatedAt: string;
289
+ attachmentStamp: string;
290
+ }>;
291
+ withheld: number;
292
+ }>;
260
293
  /** Folder exposure for this request. Not cached across calls: the user can
261
294
  * flip the switch in the app at any moment and the next tool call must
262
295
  * respect it. */
package/dist/cjs/notes.js CHANGED
@@ -181,6 +181,7 @@ function resolveValues(defs, values, createOption) {
181
181
  class NotesService {
182
182
  api;
183
183
  vault;
184
+ options;
184
185
  /** Per-unlock cache of decrypted (title, content) keyed by `id:updatedAt`
185
186
  * so re-reading/re-scanning the same note twice in one unlock doesn't
186
187
  * re-run AES-GCM decryption. Cleared whenever the vault locks. */
@@ -226,9 +227,12 @@ class NotesService {
226
227
  /** Opens shared workspaces' keys for this unlock. Public so the attachment
227
228
  * service built next to this one shares it rather than opening them twice. */
228
229
  keyring;
229
- constructor(api, vault, keyring) {
230
+ constructor(api, vault, keyring,
231
+ /** See AttachmentsService: for the account holder's own surfaces only. */
232
+ options = {}) {
230
233
  this.api = api;
231
234
  this.vault = vault;
235
+ this.options = options;
232
236
  this.vault.onLock = () => this.handleLock();
233
237
  this.keyring = keyring ?? new keyring_js_1.WorkspaceKeyring(api, vault);
234
238
  }
@@ -781,6 +785,70 @@ class NotesService {
781
785
  withheld: folders.length - visible.length,
782
786
  };
783
787
  }
788
+ /**
789
+ * Every folder and every storage item (a note of type `file`), decrypted, for
790
+ * a surface that shows the account's own tree (the desktop drive). Refused
791
+ * unless this service was built with `allFolders`, so no tool path can reach it.
792
+ *
793
+ * Only storage items: files attached to an ordinary note belong to that note
794
+ * and stay out of the drive. An empty storage item is listed, so files can be
795
+ * dropped into it. The memories tree is left out: the MCP server makes it for
796
+ * AI tools and it holds no files. `attachmentStamp` changes when a note's attachments do, so
797
+ * a caller lists a note's files only when it has to. A note whose key does
798
+ * not open here is left out and counted, never opened with the account key.
799
+ */
800
+ async listDriveCatalog() {
801
+ if (this.options.allFolders !== true)
802
+ throw new Error('The drive catalog is not available here.');
803
+ const key = this.requireSession();
804
+ const { folders: rows } = await this.api.listFolders();
805
+ const exposure = await this.exposureOf(rows);
806
+ const byId = new Map(rows.map((f) => [f.id, f]));
807
+ const inMemories = (folder) => {
808
+ const seen = new Set();
809
+ let current = folder;
810
+ while (current && !seen.has(current.id)) {
811
+ if (current.kind === 'memories')
812
+ return true;
813
+ seen.add(current.id);
814
+ current = current.parentId === null ? undefined : byId.get(current.parentId);
815
+ }
816
+ return false;
817
+ };
818
+ const shown = rows.filter((f) => !inMemories(f));
819
+ const shownIds = new Set(shown.map((f) => f.id));
820
+ const folders = await Promise.all(shown.map(async (f) => ({ id: f.id, name: await this.folderName(key, f), parentId: f.parentId })));
821
+ const notes = [];
822
+ let withheld = 0;
823
+ for (let page = 1, pages = 1; page <= pages; page += 1) {
824
+ const result = await this.api.listNotes({ page, limit: 50, deleted: false, archived: false });
825
+ pages = result.pagination.pages;
826
+ const storage = result.notes.filter((row) => row.type === 'file' &&
827
+ row.hidden !== true &&
828
+ !isSystemNote(row) &&
829
+ (row.folderId === null || shownIds.has(row.folderId)));
830
+ const decrypted = await Promise.all(storage.map(async (row) => {
831
+ const rowKey = await this.keyForRowOrNull(key, row, exposure);
832
+ if (rowKey === null)
833
+ return null;
834
+ const title = await this.decryptRow(rowKey, row).then((fields) => fields.title, () => null);
835
+ if (title === null)
836
+ return null;
837
+ const stamp = [...(row.attachments ?? [])]
838
+ .map((a) => `${a.id}:${a.iv}:${a.encryptedTitle}`)
839
+ .sort()
840
+ .join('|');
841
+ return { id: row.id, title, folderId: row.folderId, updatedAt: row.updatedAt, attachmentStamp: stamp };
842
+ }));
843
+ for (const entry of decrypted) {
844
+ if (entry === null)
845
+ withheld += 1;
846
+ else
847
+ notes.push(entry);
848
+ }
849
+ }
850
+ return { folders, notes, withheld };
851
+ }
784
852
  /** Folder exposure for this request. Not cached across calls: the user can
785
853
  * flip the switch in the app at any moment and the next tool call must
786
854
  * respect it. */
@@ -812,8 +880,10 @@ class NotesService {
812
880
  `/${f.userId ?? ''}/${f.workspaceId ?? ''}/${f.workspaceId ? (workspaceOwners.get(f.workspaceId) ?? '') : ''}`)
813
881
  .join('|');
814
882
  const workspaces = new Map(folders.map((f) => [f.id, f.workspaceId ?? null]));
883
+ const computed = (0, exposure_js_1.computeExposure)(folders, { viewerId, workspaceOwners });
815
884
  return {
816
- ...(0, exposure_js_1.computeExposure)(folders, { viewerId, workspaceOwners }),
885
+ ...computed,
886
+ ...(this.options.allFolders === true ? { allows: () => true, hiddenFolderCount: 0, allHidden: false } : {}),
817
887
  key: `${viewerId}#${key}`,
818
888
  workspaceOf: (folderId) => (folderId === null ? null : (workspaces.get(folderId) ?? null)),
819
889
  hasFolder: (folderId) => workspaces.has(folderId),
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { ApiFile, ApiFileWithData, ApiFolder, ApiIdentity, ApiNote, ApiWork
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';
6
+ export { AttachmentsService, SMALL_FILE_BYTES } from './attachments.js';
7
7
  export { KeyChangedError, NoWorkspaceKeyError, WorkspaceKeyring, memorySignerMemory } from './keyring.js';
8
8
  export type { KeyChoice, SignerMemory } from './keyring.js';
9
9
  export { fileSignerMemory } from './signer-file.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC/C,YAAY,EACV,OAAO,EACP,eAAe,EACf,SAAS,EACT,WAAW,EACX,OAAO,EACP,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,UAAU,EACV,UAAU,EACV,WAAW,EACX,SAAS,EACT,WAAW,GACZ,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAChD,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAC1G,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,YAAY,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,EACL,gBAAgB,EAChB,SAAS,EACT,eAAe,EACf,mBAAmB,EACnB,cAAc,GACf,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EACrB,uBAAuB,EACvB,gBAAgB,EAChB,kBAAkB,EAClB,iBAAiB,EACjB,aAAa,EACb,eAAe,EACf,aAAa,EACb,cAAc,EACd,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACrF,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,iBAAiB,GAClB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAChE,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EACL,eAAe,EACf,SAAS,EACT,WAAW,EACX,YAAY,EACZ,UAAU,EACV,UAAU,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,SAAS,EACT,UAAU,EACV,YAAY,EACZ,WAAW,EACX,aAAa,EACb,UAAU,EACV,QAAQ,GACT,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,cAAc,EAAE,WAAW,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC1H,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACnF,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjG;;;;;;;;;;GAUG"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}