@batalabs/virlow-mcp-core 3.119.4 → 3.120.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.
- package/dist/api.d.ts +23 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +30 -2
- package/dist/api.js.map +1 -1
- package/dist/attachments.d.ts +34 -1
- package/dist/attachments.d.ts.map +1 -1
- package/dist/attachments.js +100 -2
- package/dist/attachments.js.map +1 -1
- package/dist/cjs/api.d.ts +23 -1
- package/dist/cjs/api.js +30 -2
- package/dist/cjs/attachments.d.ts +34 -1
- package/dist/cjs/attachments.js +101 -3
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.js +3 -2
- package/dist/cjs/large-files.d.ts +72 -0
- package/dist/cjs/large-files.js +235 -0
- package/dist/cjs/notes.d.ts +32 -1
- package/dist/cjs/notes.js +70 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/large-files.d.ts +73 -0
- package/dist/large-files.d.ts.map +1 -0
- package/dist/large-files.js +230 -0
- package/dist/large-files.js.map +1 -0
- package/dist/notes.d.ts +32 -1
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +70 -2
- package/dist/notes.js.map +1 -1
- package/package.json +13 -3
package/dist/cjs/attachments.js
CHANGED
|
@@ -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);
|
package/dist/cjs/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';
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
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
|
+
}
|
package/dist/cjs/notes.d.ts
CHANGED
|
@@ -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,31 @@ export declare class NotesService {
|
|
|
257
263
|
}>;
|
|
258
264
|
withheld: number;
|
|
259
265
|
}>;
|
|
266
|
+
/**
|
|
267
|
+
* Every folder and every note that has attachments, decrypted, for a surface
|
|
268
|
+
* that shows the account's own tree (the desktop drive). Refused unless this
|
|
269
|
+
* service was built with `allFolders`, so no tool path can reach it.
|
|
270
|
+
*
|
|
271
|
+
* The memories tree is left out: the MCP server makes it for AI tools and it
|
|
272
|
+
* holds no files. `attachmentStamp` changes when a note's attachments do, so
|
|
273
|
+
* a caller lists a note's files only when it has to. A note whose key does
|
|
274
|
+
* not open here is left out and counted, never opened with the account key.
|
|
275
|
+
*/
|
|
276
|
+
listDriveCatalog(): Promise<{
|
|
277
|
+
folders: Array<{
|
|
278
|
+
id: string;
|
|
279
|
+
name: string;
|
|
280
|
+
parentId: string | null;
|
|
281
|
+
}>;
|
|
282
|
+
notes: Array<{
|
|
283
|
+
id: string;
|
|
284
|
+
title: string;
|
|
285
|
+
folderId: string | null;
|
|
286
|
+
updatedAt: string;
|
|
287
|
+
attachmentStamp: string;
|
|
288
|
+
}>;
|
|
289
|
+
withheld: number;
|
|
290
|
+
}>;
|
|
260
291
|
/** Folder exposure for this request. Not cached across calls: the user can
|
|
261
292
|
* flip the switch in the app at any moment and the next tool call must
|
|
262
293
|
* 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,68 @@ class NotesService {
|
|
|
781
785
|
withheld: folders.length - visible.length,
|
|
782
786
|
};
|
|
783
787
|
}
|
|
788
|
+
/**
|
|
789
|
+
* Every folder and every note that has attachments, decrypted, for a surface
|
|
790
|
+
* that shows the account's own tree (the desktop drive). Refused unless this
|
|
791
|
+
* service was built with `allFolders`, so no tool path can reach it.
|
|
792
|
+
*
|
|
793
|
+
* The memories tree is left out: the MCP server makes it for AI tools and it
|
|
794
|
+
* holds no files. `attachmentStamp` changes when a note's attachments do, so
|
|
795
|
+
* a caller lists a note's files only when it has to. A note whose key does
|
|
796
|
+
* not open here is left out and counted, never opened with the account key.
|
|
797
|
+
*/
|
|
798
|
+
async listDriveCatalog() {
|
|
799
|
+
if (this.options.allFolders !== true)
|
|
800
|
+
throw new Error('The drive catalog is not available here.');
|
|
801
|
+
const key = this.requireSession();
|
|
802
|
+
const { folders: rows } = await this.api.listFolders();
|
|
803
|
+
const exposure = await this.exposureOf(rows);
|
|
804
|
+
const byId = new Map(rows.map((f) => [f.id, f]));
|
|
805
|
+
const inMemories = (folder) => {
|
|
806
|
+
const seen = new Set();
|
|
807
|
+
let current = folder;
|
|
808
|
+
while (current && !seen.has(current.id)) {
|
|
809
|
+
if (current.kind === 'memories')
|
|
810
|
+
return true;
|
|
811
|
+
seen.add(current.id);
|
|
812
|
+
current = current.parentId === null ? undefined : byId.get(current.parentId);
|
|
813
|
+
}
|
|
814
|
+
return false;
|
|
815
|
+
};
|
|
816
|
+
const shown = rows.filter((f) => !inMemories(f));
|
|
817
|
+
const shownIds = new Set(shown.map((f) => f.id));
|
|
818
|
+
const folders = await Promise.all(shown.map(async (f) => ({ id: f.id, name: await this.folderName(key, f), parentId: f.parentId })));
|
|
819
|
+
const notes = [];
|
|
820
|
+
let withheld = 0;
|
|
821
|
+
for (let page = 1, pages = 1; page <= pages; page += 1) {
|
|
822
|
+
const result = await this.api.listNotes({ page, limit: 50, deleted: false, archived: false });
|
|
823
|
+
pages = result.pagination.pages;
|
|
824
|
+
const rowsWithFiles = result.notes.filter((row) => (row.attachmentCount ?? 0) > 0 &&
|
|
825
|
+
row.hidden !== true &&
|
|
826
|
+
!isSystemNote(row) &&
|
|
827
|
+
(row.folderId === null || shownIds.has(row.folderId)));
|
|
828
|
+
const decrypted = await Promise.all(rowsWithFiles.map(async (row) => {
|
|
829
|
+
const rowKey = await this.keyForRowOrNull(key, row, exposure);
|
|
830
|
+
if (rowKey === null)
|
|
831
|
+
return null;
|
|
832
|
+
const title = await this.decryptRow(rowKey, row).then((fields) => fields.title, () => null);
|
|
833
|
+
if (title === null)
|
|
834
|
+
return null;
|
|
835
|
+
const stamp = [...(row.attachments ?? [])]
|
|
836
|
+
.map((a) => `${a.id}:${a.iv}:${a.encryptedTitle}`)
|
|
837
|
+
.sort()
|
|
838
|
+
.join('|');
|
|
839
|
+
return { id: row.id, title, folderId: row.folderId, updatedAt: row.updatedAt, attachmentStamp: stamp };
|
|
840
|
+
}));
|
|
841
|
+
for (const entry of decrypted) {
|
|
842
|
+
if (entry === null)
|
|
843
|
+
withheld += 1;
|
|
844
|
+
else
|
|
845
|
+
notes.push(entry);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return { folders, notes, withheld };
|
|
849
|
+
}
|
|
784
850
|
/** Folder exposure for this request. Not cached across calls: the user can
|
|
785
851
|
* flip the switch in the app at any moment and the next tool call must
|
|
786
852
|
* respect it. */
|
|
@@ -812,8 +878,10 @@ class NotesService {
|
|
|
812
878
|
`/${f.userId ?? ''}/${f.workspaceId ?? ''}/${f.workspaceId ? (workspaceOwners.get(f.workspaceId) ?? '') : ''}`)
|
|
813
879
|
.join('|');
|
|
814
880
|
const workspaces = new Map(folders.map((f) => [f.id, f.workspaceId ?? null]));
|
|
881
|
+
const computed = (0, exposure_js_1.computeExposure)(folders, { viewerId, workspaceOwners });
|
|
815
882
|
return {
|
|
816
|
-
...
|
|
883
|
+
...computed,
|
|
884
|
+
...(this.options.allFolders === true ? { allows: () => true, hiddenFolderCount: 0, allHidden: false } : {}),
|
|
817
885
|
key: `${viewerId}#${key}`,
|
|
818
886
|
workspaceOf: (folderId) => (folderId === null ? null : (workspaces.get(folderId) ?? null)),
|
|
819
887
|
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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|