@batalabs/virlow-mcp-core 3.116.1 → 3.117.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 +92 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +23 -1
- package/dist/api.js.map +1 -1
- package/dist/attachments.d.ts +20 -2
- package/dist/attachments.d.ts.map +1 -1
- package/dist/attachments.js +135 -22
- package/dist/attachments.js.map +1 -1
- package/dist/cjs/api.d.ts +92 -3
- package/dist/cjs/api.js +23 -1
- package/dist/cjs/attachments.d.ts +20 -2
- package/dist/cjs/attachments.js +134 -21
- package/dist/cjs/exposure.d.ts +30 -4
- package/dist/cjs/exposure.js +48 -9
- package/dist/cjs/index.d.ts +4 -1
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/keyring.d.ts +155 -0
- package/dist/cjs/keyring.js +319 -0
- package/dist/cjs/memories.d.ts +2 -2
- package/dist/cjs/notes.d.ts +52 -1
- package/dist/cjs/notes.js +296 -78
- package/dist/cjs/signer-file.d.ts +21 -0
- package/dist/cjs/signer-file.js +117 -0
- package/dist/exposure.d.ts +30 -4
- package/dist/exposure.d.ts.map +1 -1
- package/dist/exposure.js +47 -9
- package/dist/exposure.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/keyring.d.ts +156 -0
- package/dist/keyring.d.ts.map +1 -0
- package/dist/keyring.js +308 -0
- package/dist/keyring.js.map +1 -0
- package/dist/memories.d.ts +2 -2
- package/dist/notes.d.ts +52 -1
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +297 -79
- package/dist/notes.js.map +1 -1
- package/dist/signer-file.d.ts +22 -0
- package/dist/signer-file.d.ts.map +1 -0
- package/dist/signer-file.js +112 -0
- package/dist/signer-file.js.map +1 -0
- package/dist/tools.d.ts +10 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +6 -2
- package/dist/tools.js.map +1 -1
- package/package.json +3 -3
package/dist/cjs/notes.js
CHANGED
|
@@ -7,6 +7,7 @@ const attrs_js_1 = require("./attrs.js");
|
|
|
7
7
|
const attrs_extract_js_1 = require("./attrs-extract.js");
|
|
8
8
|
const entity_registry_js_1 = require("./entity-registry.js");
|
|
9
9
|
const vault_js_1 = require("./vault.js");
|
|
10
|
+
const keyring_js_1 = require("./keyring.js");
|
|
10
11
|
/** Hard ceiling on how many notes searchNotes() will decrypt+scan in a
|
|
11
12
|
* single call. Keeps a single search bounded even for very large accounts;
|
|
12
13
|
* `truncated` tells the caller when the account holds more than this. Also
|
|
@@ -222,10 +223,14 @@ class NotesService {
|
|
|
222
223
|
* the same pre-edit row and the second write would silently clobber the
|
|
223
224
|
* first's change. Mirrors MemoryStore's enqueue()/mutationChain pattern. */
|
|
224
225
|
mutationChain = Promise.resolve();
|
|
225
|
-
|
|
226
|
+
/** Opens shared workspaces' keys for this unlock. Public so the attachment
|
|
227
|
+
* service built next to this one shares it rather than opening them twice. */
|
|
228
|
+
keyring;
|
|
229
|
+
constructor(api, vault, keyring) {
|
|
226
230
|
this.api = api;
|
|
227
231
|
this.vault = vault;
|
|
228
232
|
this.vault.onLock = () => this.handleLock();
|
|
233
|
+
this.keyring = keyring ?? new keyring_js_1.WorkspaceKeyring(api, vault);
|
|
229
234
|
}
|
|
230
235
|
handleLock() {
|
|
231
236
|
this.decryptCache.clear();
|
|
@@ -252,18 +257,22 @@ class NotesService {
|
|
|
252
257
|
const notHidden = notes.filter((row) => row.hidden !== true && !isSystemNote(row));
|
|
253
258
|
// Folders the user has not opened to AI tools are filtered here rather
|
|
254
259
|
// than at the API, so the count of what was withheld can be reported.
|
|
255
|
-
const
|
|
260
|
+
const exposed = notHidden.filter((row) => exposure.allows(row.folderId));
|
|
256
261
|
const withheld = {
|
|
257
262
|
inClosedFolders: notHidden.filter((row) => row.folderId !== null && !exposure.allows(row.folderId)).length,
|
|
258
263
|
unfiled: notHidden.filter((row) => row.folderId === null).length,
|
|
259
264
|
};
|
|
260
|
-
|
|
265
|
+
// A note in a shared workspace whose key does not open here is left out
|
|
266
|
+
// and counted, never opened with the account key.
|
|
267
|
+
const keyed = await Promise.all(exposed.map(async (row) => ({ row, rowKey: await this.keyForRowOrNull(key, row, exposure) })));
|
|
268
|
+
const visible = keyed.filter((entry) => entry.rowKey !== null);
|
|
269
|
+
const shared = keyed.length - visible.length;
|
|
261
270
|
// Only when there are any, so the usual case reads exactly as before.
|
|
262
271
|
if (shared > 0)
|
|
263
272
|
Object.assign(withheld, { shared });
|
|
264
|
-
const mapped = await Promise.all(visible.map(async (row) => {
|
|
273
|
+
const mapped = await Promise.all(visible.map(async ({ row, rowKey }) => {
|
|
265
274
|
// One note that cannot be decrypted here must not fail the whole page.
|
|
266
|
-
const title = await this.decryptRow(
|
|
275
|
+
const title = await this.decryptRow(rowKey, row).then((fields) => fields.title, () => '(cannot be opened here)');
|
|
267
276
|
return {
|
|
268
277
|
id: row.id,
|
|
269
278
|
title,
|
|
@@ -305,19 +314,23 @@ class NotesService {
|
|
|
305
314
|
});
|
|
306
315
|
const exposure = await this.exposure();
|
|
307
316
|
const notHidden = notes.filter((row) => row.hidden !== true && !isSystemNote(row));
|
|
308
|
-
const
|
|
317
|
+
const exposed = notHidden.filter((row) => exposure.allows(row.folderId));
|
|
309
318
|
const withheld = {
|
|
310
|
-
inClosedFolders: notHidden.length -
|
|
319
|
+
inClosedFolders: notHidden.length - exposed.length,
|
|
311
320
|
};
|
|
312
|
-
const
|
|
313
|
-
|
|
321
|
+
const keyed = await Promise.all(exposed.map(async (row) => ({ row, rowKey: await this.keyForRowOrNull(key, row, exposure) })));
|
|
322
|
+
const visible = keyed.filter((entry) => entry.rowKey !== null);
|
|
323
|
+
if (keyed.length > visible.length)
|
|
324
|
+
withheld.shared = keyed.length - visible.length;
|
|
325
|
+
const rows = await Promise.all(visible.map(async ({ row, rowKey }) => {
|
|
326
|
+
const attrs = await this.decodeAttrsFor(rowKey, row);
|
|
314
327
|
const values = {};
|
|
315
328
|
for (const def of defs) {
|
|
316
329
|
values[def.id] = (0, attrs_js_1.formatProp)(def, attrs?.props?.[def.id]);
|
|
317
330
|
}
|
|
318
331
|
return {
|
|
319
332
|
id: row.id,
|
|
320
|
-
title: await this.decryptTitleFor(
|
|
333
|
+
title: await this.decryptTitleFor(rowKey, row),
|
|
321
334
|
values,
|
|
322
335
|
};
|
|
323
336
|
}));
|
|
@@ -351,10 +364,9 @@ class NotesService {
|
|
|
351
364
|
// not read this folder, and knowing the id does not change that.
|
|
352
365
|
const exposure = await this.exposure();
|
|
353
366
|
this.requireExposed(exposure, row.folderId, id);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
const attrs = await this.decodeAttrsFor(key, row);
|
|
367
|
+
const rowKey = await this.keyForRowOrThrow(key, row, exposure);
|
|
368
|
+
const { title, content } = await this.decryptRow(rowKey, row);
|
|
369
|
+
const attrs = await this.decodeAttrsFor(rowKey, row);
|
|
358
370
|
return { id: row.id, title, content, folderId: row.folderId, attrs };
|
|
359
371
|
}
|
|
360
372
|
async createNote(title, content, folderId, type) {
|
|
@@ -362,8 +374,15 @@ class NotesService {
|
|
|
362
374
|
// Same guard as updateNote(): captured before the encrypt await so a
|
|
363
375
|
// lock landing mid-create is caught before the write, not after.
|
|
364
376
|
const startEpoch = this.epoch;
|
|
377
|
+
// A note made in a shared workspace is under the workspace's current key,
|
|
378
|
+
// as the API requires; everywhere else under the account key.
|
|
379
|
+
let choice = { key, keyVersion: null, ownerId: null };
|
|
380
|
+
if (folderId) {
|
|
381
|
+
const { folders } = await this.api.listFolders();
|
|
382
|
+
choice = await this.keyring.forWrite(key, (await this.exposureOf(folders)).workspaceOf(folderId));
|
|
383
|
+
}
|
|
365
384
|
const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
|
|
366
|
-
const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, title, content);
|
|
385
|
+
const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(choice.key, title, content);
|
|
367
386
|
if (this.epoch !== startEpoch) {
|
|
368
387
|
throw lockedError();
|
|
369
388
|
}
|
|
@@ -379,6 +398,8 @@ class NotesService {
|
|
|
379
398
|
body.folderId = folderId;
|
|
380
399
|
if (type !== undefined)
|
|
381
400
|
body.type = type;
|
|
401
|
+
if (choice.keyVersion !== null)
|
|
402
|
+
body.keyVersion = choice.keyVersion;
|
|
382
403
|
const row = await this.api.createNote(body);
|
|
383
404
|
this.invalidateVisibleRows();
|
|
384
405
|
return { id: row.id };
|
|
@@ -396,12 +417,11 @@ class NotesService {
|
|
|
396
417
|
}
|
|
397
418
|
const exposure = await this.exposure();
|
|
398
419
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const current = await this.decryptRow(key, existingRow);
|
|
420
|
+
const rowKey = await this.keyForRowOrThrow(key, existingRow, exposure);
|
|
421
|
+
const current = await this.decryptRow(rowKey, existingRow);
|
|
402
422
|
const title = changes.title ?? current.title;
|
|
403
423
|
const content = changes.content ?? current.content;
|
|
404
|
-
const prevAttrs = (await this.decodeAttrsFor(
|
|
424
|
+
const prevAttrs = (await this.decodeAttrsFor(rowKey, existingRow)) ?? (0, attrs_js_1.emptyAttrs)();
|
|
405
425
|
const props = { ...prevAttrs.props };
|
|
406
426
|
for (const [propKey, value] of Object.entries(changes.props ?? {})) {
|
|
407
427
|
if (value === null) {
|
|
@@ -428,24 +448,18 @@ class NotesService {
|
|
|
428
448
|
updated: new Date().toISOString(),
|
|
429
449
|
...(changes.aliases !== undefined ? { aliases: changes.aliases } : {}),
|
|
430
450
|
};
|
|
431
|
-
const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, id);
|
|
432
451
|
// A props-or-aliases-only edit leaves the body exactly as it was, so it
|
|
433
452
|
// sends only the attrs blob. Re-encrypting the unchanged title and
|
|
434
453
|
// content would rewrite the row's ciphertext (and its IV and salt) for
|
|
435
454
|
// no reason: pure work, a bigger request, a pointless revision, and one
|
|
436
455
|
// more chance for an unrelated failure to corrupt a body nobody asked
|
|
437
|
-
// to change.
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
body.encryptedTitle = encryptedTitle;
|
|
445
|
-
body.encryptedContent = encryptedContent;
|
|
446
|
-
body.iv = iv;
|
|
447
|
-
body.salt = salt;
|
|
448
|
-
}
|
|
456
|
+
// to change. The exception is a shared note under an older key version:
|
|
457
|
+
// see sealNoteWrite().
|
|
458
|
+
const body = await this.sealNoteWrite(key, existingRow, exposure, {
|
|
459
|
+
attrs,
|
|
460
|
+
body: changes.title !== undefined || changes.content !== undefined ? { title, content } : undefined,
|
|
461
|
+
current,
|
|
462
|
+
});
|
|
449
463
|
if (this.epoch !== startEpoch) {
|
|
450
464
|
throw lockedError();
|
|
451
465
|
}
|
|
@@ -468,14 +482,19 @@ class NotesService {
|
|
|
468
482
|
async backlinks(id) {
|
|
469
483
|
const key = this.requireSession();
|
|
470
484
|
const startEpoch = this.epoch;
|
|
471
|
-
const
|
|
485
|
+
const exposure = await this.exposure();
|
|
486
|
+
const rows = await this.listVisibleRows(exposure);
|
|
472
487
|
const result = [];
|
|
473
488
|
for (const row of rows) {
|
|
474
489
|
if (row.id === id)
|
|
475
490
|
continue;
|
|
476
|
-
|
|
491
|
+
// A shared note whose key does not open here is not a source.
|
|
492
|
+
const rowKey = await this.keyForRowOrNull(key, row, exposure);
|
|
493
|
+
if (rowKey === null)
|
|
494
|
+
continue;
|
|
495
|
+
const attrs = await this.decodeAttrsFor(rowKey, row);
|
|
477
496
|
if (attrs?.links.includes(id)) {
|
|
478
|
-
result.push({ id: row.id, title: await this.decryptTitleFor(
|
|
497
|
+
result.push({ id: row.id, title: await this.decryptTitleFor(rowKey, row) });
|
|
479
498
|
}
|
|
480
499
|
}
|
|
481
500
|
if (this.epoch !== startEpoch) {
|
|
@@ -531,10 +550,7 @@ class NotesService {
|
|
|
531
550
|
...schema,
|
|
532
551
|
home: { ...schema.home, widgets: stamped, previous: kept },
|
|
533
552
|
};
|
|
534
|
-
|
|
535
|
-
if (this.epoch !== startEpoch)
|
|
536
|
-
throw lockedError();
|
|
537
|
-
await this.api.updateFolder(folderId, { encryptedSchema });
|
|
553
|
+
await this.writeSchema(key, folder, next, startEpoch);
|
|
538
554
|
return stamped;
|
|
539
555
|
});
|
|
540
556
|
}
|
|
@@ -576,10 +592,7 @@ class NotesService {
|
|
|
576
592
|
if (created === undefined) {
|
|
577
593
|
throw new Error(`Could not define property "${name}" in folder ${folderId}.`);
|
|
578
594
|
}
|
|
579
|
-
|
|
580
|
-
if (this.epoch !== startEpoch)
|
|
581
|
-
throw lockedError();
|
|
582
|
-
await this.api.updateFolder(folderId, { encryptedSchema });
|
|
595
|
+
await this.writeSchema(key, folder, next, startEpoch);
|
|
583
596
|
return created;
|
|
584
597
|
});
|
|
585
598
|
}
|
|
@@ -611,10 +624,9 @@ class NotesService {
|
|
|
611
624
|
}
|
|
612
625
|
const row = await this.api.getNote(noteId);
|
|
613
626
|
const { folders } = await this.api.listFolders();
|
|
614
|
-
const exposure = this.exposureOf(folders);
|
|
627
|
+
const exposure = await this.exposureOf(folders);
|
|
615
628
|
this.requireExposed(exposure, row.folderId, noteId);
|
|
616
|
-
|
|
617
|
-
throw (0, exposure_js_1.sharedNoteError)(noteId);
|
|
629
|
+
const rowKey = await this.keyForRowOrThrow(key, row, exposure);
|
|
618
630
|
const folder = folders.find((f) => f.id === row.folderId);
|
|
619
631
|
if (folder === undefined) {
|
|
620
632
|
// Narrowing: requireExposed() above only passes for a folder that is
|
|
@@ -627,7 +639,7 @@ class NotesService {
|
|
|
627
639
|
// The attrs blob is built and size-checked before anything is written,
|
|
628
640
|
// so a note whose properties would not fit never costs the folder an
|
|
629
641
|
// orphan option.
|
|
630
|
-
const prevAttrs = (await this.decodeAttrsFor(
|
|
642
|
+
const prevAttrs = (await this.decodeAttrsFor(rowKey, row)) ?? (0, attrs_js_1.emptyAttrs)();
|
|
631
643
|
const props = { ...prevAttrs.props };
|
|
632
644
|
for (const [propId, value] of Object.entries(patch)) {
|
|
633
645
|
if (value === null) {
|
|
@@ -640,7 +652,10 @@ class NotesService {
|
|
|
640
652
|
// `v` and every unknown field come from prevAttrs, so a blob written by
|
|
641
653
|
// a newer client survives this round trip (see updateNote()).
|
|
642
654
|
const attrs = { ...prevAttrs, props, updated: new Date().toISOString() };
|
|
643
|
-
const
|
|
655
|
+
const body = await this.sealNoteWrite(key, row, exposure, {
|
|
656
|
+
attrs,
|
|
657
|
+
current: () => this.decryptRow(rowKey, row),
|
|
658
|
+
});
|
|
644
659
|
if (newOptions.size > 0) {
|
|
645
660
|
let nextSchema = schema;
|
|
646
661
|
for (const [defId, added] of newOptions) {
|
|
@@ -649,14 +664,11 @@ class NotesService {
|
|
|
649
664
|
options: [...(def?.options ?? []), ...added],
|
|
650
665
|
});
|
|
651
666
|
}
|
|
652
|
-
|
|
653
|
-
if (this.epoch !== startEpoch)
|
|
654
|
-
throw lockedError();
|
|
655
|
-
await this.api.updateFolder(folder.id, { encryptedSchema });
|
|
667
|
+
await this.writeSchema(key, folder, nextSchema, startEpoch);
|
|
656
668
|
}
|
|
657
669
|
if (this.epoch !== startEpoch)
|
|
658
670
|
throw lockedError();
|
|
659
|
-
await this.api.updateNote(noteId,
|
|
671
|
+
await this.api.updateNote(noteId, body);
|
|
660
672
|
this.invalidateVisibleRows();
|
|
661
673
|
return { attrs, applied };
|
|
662
674
|
});
|
|
@@ -678,13 +690,17 @@ class NotesService {
|
|
|
678
690
|
for (const row of notes) {
|
|
679
691
|
if (row.hidden === true || isSystemNote(row))
|
|
680
692
|
continue;
|
|
681
|
-
if (!exposure.allows(row.folderId)
|
|
693
|
+
if (!exposure.allows(row.folderId))
|
|
682
694
|
continue;
|
|
683
695
|
if (scanned >= exports.SEARCH_SCAN_CAP)
|
|
684
696
|
break scan;
|
|
685
697
|
scanned++;
|
|
686
|
-
// A note that cannot be decrypted here
|
|
687
|
-
|
|
698
|
+
// A note that cannot be decrypted here (a shared note whose key does
|
|
699
|
+
// not open, a corrupt row) is skipped, not fatal to the search.
|
|
700
|
+
const rowKey = await this.keyForRowOrNull(key, row, exposure);
|
|
701
|
+
if (rowKey === null)
|
|
702
|
+
continue;
|
|
703
|
+
const fields = await this.decryptRow(rowKey, row).catch(() => null);
|
|
688
704
|
if (fields === null)
|
|
689
705
|
continue;
|
|
690
706
|
const { title, content } = fields;
|
|
@@ -716,11 +732,16 @@ class NotesService {
|
|
|
716
732
|
// exempt from the destination check: root is never exposed either way,
|
|
717
733
|
// so it can only ever reduce what's visible, never leak it.
|
|
718
734
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
719
|
-
if ((0, exposure_js_1.isSharedNote)(existingRow))
|
|
720
|
-
throw (0, exposure_js_1.sharedNoteError)(id);
|
|
721
735
|
if (folderId !== null) {
|
|
722
736
|
this.requireExposed(exposure, folderId, id);
|
|
723
737
|
}
|
|
738
|
+
// In or out of a shared workspace, or between two, changes which key the
|
|
739
|
+
// note is under; the API refuses it, and only the app's sharing flow
|
|
740
|
+
// re-encrypts. Said here, before the request, in words that name it.
|
|
741
|
+
if (this.workspaceOfRow(existingRow, exposure) !== exposure.workspaceOf(folderId)) {
|
|
742
|
+
throw new Error(`Note ${id} cannot be moved in or out of a shared workspace here: that changes its encryption. ` +
|
|
743
|
+
'Use Move to workspace in the Virlow app.');
|
|
744
|
+
}
|
|
724
745
|
await this.api.updateNote(id, { folderId });
|
|
725
746
|
this.invalidateVisibleRows();
|
|
726
747
|
}
|
|
@@ -729,8 +750,6 @@ class NotesService {
|
|
|
729
750
|
const existingRow = await this.api.getNote(id);
|
|
730
751
|
const exposure = await this.exposure();
|
|
731
752
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
732
|
-
if ((0, exposure_js_1.isSharedNote)(existingRow))
|
|
733
|
-
throw (0, exposure_js_1.sharedNoteError)(id);
|
|
734
753
|
await this.api.updateNote(id, { starred });
|
|
735
754
|
this.invalidateVisibleRows();
|
|
736
755
|
}
|
|
@@ -739,8 +758,6 @@ class NotesService {
|
|
|
739
758
|
const existingRow = await this.api.getNote(id);
|
|
740
759
|
const exposure = await this.exposure();
|
|
741
760
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
742
|
-
if ((0, exposure_js_1.isSharedNote)(existingRow))
|
|
743
|
-
throw (0, exposure_js_1.sharedNoteError)(id);
|
|
744
761
|
await this.api.updateNote(id, { archived });
|
|
745
762
|
this.invalidateVisibleRows();
|
|
746
763
|
}
|
|
@@ -749,20 +766,18 @@ class NotesService {
|
|
|
749
766
|
const existingRow = await this.api.getNote(id);
|
|
750
767
|
const exposure = await this.exposure();
|
|
751
768
|
this.requireExposed(exposure, existingRow.folderId, id);
|
|
752
|
-
if ((0, exposure_js_1.isSharedNote)(existingRow))
|
|
753
|
-
throw (0, exposure_js_1.sharedNoteError)(id);
|
|
754
769
|
await this.api.updateNote(id, { deleted: true });
|
|
755
770
|
this.invalidateVisibleRows();
|
|
756
771
|
}
|
|
757
772
|
async listFolders() {
|
|
758
|
-
this.requireSession();
|
|
773
|
+
const key = this.requireSession();
|
|
759
774
|
const { folders } = await this.api.listFolders();
|
|
760
|
-
const exposure =
|
|
775
|
+
const exposure = await this.exposureOf(folders);
|
|
761
776
|
const visible = folders.filter((f) => exposure.allows(f.id));
|
|
762
777
|
return {
|
|
763
778
|
// Names are withheld along with contents: a folder the user has not
|
|
764
779
|
// opened should not be inventoried either, only counted.
|
|
765
|
-
folders: visible.map((f) => ({ id: f.id, name:
|
|
780
|
+
folders: await Promise.all(visible.map(async (f) => ({ id: f.id, name: await this.folderName(key, f), parentId: f.parentId }))),
|
|
766
781
|
withheld: folders.length - visible.length,
|
|
767
782
|
};
|
|
768
783
|
}
|
|
@@ -773,10 +788,19 @@ class NotesService {
|
|
|
773
788
|
const { folders } = await this.api.listFolders();
|
|
774
789
|
return this.exposureOf(folders);
|
|
775
790
|
}
|
|
791
|
+
/** Who is unlocked: the account whose consent the exposure rule asks for. */
|
|
792
|
+
viewerId() {
|
|
793
|
+
return this.vault.session.userId;
|
|
794
|
+
}
|
|
776
795
|
/** The same computation for a caller that already has the folder list and
|
|
777
796
|
* needs a folder out of it as well as the exposure (the property tools do:
|
|
778
797
|
* a folder's schema and its exposure come from the same row). */
|
|
779
|
-
exposureOf(folders) {
|
|
798
|
+
async exposureOf(folders) {
|
|
799
|
+
// A folder in a shared workspace someone else owns is open only by this
|
|
800
|
+
// member's own switch, never the row's shared one (exposure.ts,
|
|
801
|
+
// isForeignFolder); that needs to know who owns each workspace.
|
|
802
|
+
const viewerId = this.viewerId();
|
|
803
|
+
const workspaceOwners = await this.keyring.workspaceOwners(folders);
|
|
780
804
|
// Everything computeExposure() actually reads, in list order. Any change
|
|
781
805
|
// to it changes which notes are visible, so it invalidates the paged row
|
|
782
806
|
// cache keyed on this string. The key travels on the exposure object
|
|
@@ -784,9 +808,16 @@ class NotesService {
|
|
|
784
808
|
// and a field would let the second one's key be stamped onto rows the
|
|
785
809
|
// first one filtered under a different exposure.
|
|
786
810
|
const key = folders
|
|
787
|
-
.map((f) => `${f.id}/${f.parentId ?? ''}/${f.mcpEnabled ? 1 : 0}/${f.kind ?? ''}`
|
|
811
|
+
.map((f) => `${f.id}/${f.parentId ?? ''}/${f.mcpEnabled ? 1 : 0}/${f.mcpEnabledForMember ? 1 : 0}/${f.kind ?? ''}` +
|
|
812
|
+
`/${f.userId ?? ''}/${f.workspaceId ?? ''}/${f.workspaceId ? (workspaceOwners.get(f.workspaceId) ?? '') : ''}`)
|
|
788
813
|
.join('|');
|
|
789
|
-
|
|
814
|
+
const workspaces = new Map(folders.map((f) => [f.id, f.workspaceId ?? null]));
|
|
815
|
+
return {
|
|
816
|
+
...(0, exposure_js_1.computeExposure)(folders, { viewerId, workspaceOwners }),
|
|
817
|
+
key: `${viewerId}#${key}`,
|
|
818
|
+
workspaceOf: (folderId) => (folderId === null ? null : (workspaces.get(folderId) ?? null)),
|
|
819
|
+
hasFolder: (folderId) => workspaces.has(folderId),
|
|
820
|
+
};
|
|
790
821
|
}
|
|
791
822
|
/** Throws unless `folderId` is exposed to MCP. Every method that reads or
|
|
792
823
|
* mutates a specific note (by id) must call this — knowing a note's id is
|
|
@@ -797,10 +828,184 @@ class NotesService {
|
|
|
797
828
|
}
|
|
798
829
|
}
|
|
799
830
|
async createFolder(name, parentId) {
|
|
800
|
-
this.requireSession();
|
|
831
|
+
const key = this.requireSession();
|
|
832
|
+
const startEpoch = this.epoch;
|
|
833
|
+
if (parentId) {
|
|
834
|
+
// Inside a shared workspace a folder's name is under the workspace key;
|
|
835
|
+
// the server only ever sees the placeholder.
|
|
836
|
+
const { folders } = await this.api.listFolders();
|
|
837
|
+
const parent = folders.find((f) => f.id === parentId);
|
|
838
|
+
const workspaceId = parent?.workspaceId ?? null;
|
|
839
|
+
if (workspaceId) {
|
|
840
|
+
const choice = await this.keyring.forWrite(key, workspaceId, { atLeast: parent?.keyVersion ?? null });
|
|
841
|
+
if (choice.keyVersion === null)
|
|
842
|
+
throw new Error('A shared workspace folder needs its workspace key.');
|
|
843
|
+
const encryptedName = await (0, virlow_crypto_1.encryptStringBlob)(choice.key, name);
|
|
844
|
+
if (this.epoch !== startEpoch)
|
|
845
|
+
throw lockedError();
|
|
846
|
+
const folder = await this.api.createFolder('[ENCRYPTED]', parentId, undefined, {
|
|
847
|
+
encryptedName,
|
|
848
|
+
keyVersion: choice.keyVersion,
|
|
849
|
+
});
|
|
850
|
+
return { id: folder.id };
|
|
851
|
+
}
|
|
852
|
+
}
|
|
801
853
|
const folder = await this.api.createFolder(name, parentId);
|
|
802
854
|
return { id: folder.id };
|
|
803
855
|
}
|
|
856
|
+
/** A folder's name as the person sees it: a shared folder's is under the
|
|
857
|
+
* workspace key (its `name` column only says "[ENCRYPTED]"). One that does
|
|
858
|
+
* not open is called what the app calls it. */
|
|
859
|
+
async folderName(accountKey, folder) {
|
|
860
|
+
if (folder.keyVersion === null || folder.keyVersion === undefined || !folder.encryptedName) {
|
|
861
|
+
return folder.name;
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
return await (0, virlow_crypto_1.decryptStringBlob)(await this.keyForFolder(accountKey, folder), folder.encryptedName);
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
return 'Shared workspace';
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/** The shared workspace a note is in: from its own folder field when the
|
|
871
|
+
* server sent one, else from the folder list. Null for a personal note. */
|
|
872
|
+
workspaceOfRow(row, exposure) {
|
|
873
|
+
const listed = exposure.workspaceOf(row.folderId);
|
|
874
|
+
const sent = row.folder?.workspaceId;
|
|
875
|
+
// The note's own folder field and the folder list come from the same
|
|
876
|
+
// server and must agree. One that disagrees is what pointing a note at
|
|
877
|
+
// another workspace's key would look like, so the note is not opened.
|
|
878
|
+
if (sent !== undefined &&
|
|
879
|
+
row.folderId !== null &&
|
|
880
|
+
exposure.hasFolder(row.folderId) &&
|
|
881
|
+
(sent ?? null) !== listed) {
|
|
882
|
+
throw new Error(`Note ${row.id}: the server gave two different workspaces for it, so it was not opened. ` +
|
|
883
|
+
'Open it in the Virlow app.');
|
|
884
|
+
}
|
|
885
|
+
return sent ?? listed;
|
|
886
|
+
}
|
|
887
|
+
/** The key a note's ciphertext is under: the account key for a personal
|
|
888
|
+
* note, the workspace key at the note's version for a shared one. Throws
|
|
889
|
+
* when a shared note's key does not open; never falls back. */
|
|
890
|
+
keyForRowOrThrow(accountKey, row, exposure) {
|
|
891
|
+
if (!(0, exposure_js_1.isSharedNote)(row))
|
|
892
|
+
return Promise.resolve(accountKey);
|
|
893
|
+
return Promise.resolve()
|
|
894
|
+
.then(() => this.keyring.forRead(accountKey, row.keyVersion, this.workspaceOfRow(row, exposure)))
|
|
895
|
+
.catch((err) => {
|
|
896
|
+
if (err instanceof vault_js_1.LockedError)
|
|
897
|
+
throw err;
|
|
898
|
+
throw (0, exposure_js_1.sharedNoteError)(row.id, err);
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
/** The same, as null instead of a throw, for scans that skip what they
|
|
902
|
+
* cannot open. A lock is not a note that cannot be opened: it propagates,
|
|
903
|
+
* so a scan cut short by one fails rather than reporting skipped notes. */
|
|
904
|
+
keyForRowOrNull(accountKey, row, exposure) {
|
|
905
|
+
return this.keyForRowOrThrow(accountKey, row, exposure).catch((err) => {
|
|
906
|
+
if (err instanceof vault_js_1.LockedError)
|
|
907
|
+
throw err;
|
|
908
|
+
return null;
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
/** The key a folder's name and schema are under. */
|
|
912
|
+
keyForFolder(accountKey, folder) {
|
|
913
|
+
if (folder.keyVersion === null || folder.keyVersion === undefined)
|
|
914
|
+
return Promise.resolve(accountKey);
|
|
915
|
+
return this.keyring.forRead(accountKey, folder.keyVersion, folder.workspaceId);
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* The ciphertext half of a note write: attrs, plus title and content when
|
|
919
|
+
* they changed, all under the key the note's place uses now (a shared
|
|
920
|
+
* workspace's current version, with that version sent along) and never the
|
|
921
|
+
* account key for a note in a shared workspace.
|
|
922
|
+
*
|
|
923
|
+
* The row has one key version for all of its ciphertext, and the API sets it
|
|
924
|
+
* from the write. So when the version changes (a rotation since the note was
|
|
925
|
+
* last saved, or a note the owner's sharing run has not reached yet), the
|
|
926
|
+
* unchanged title and content are re-encrypted as well; sending only the
|
|
927
|
+
* attrs would leave them under a key the row no longer names.
|
|
928
|
+
*/
|
|
929
|
+
async sealNoteWrite(accountKey, row, exposure, input) {
|
|
930
|
+
const workspaceId = this.workspaceOfRow(row, exposure);
|
|
931
|
+
if ((0, exposure_js_1.isSharedNote)(row) && !workspaceId) {
|
|
932
|
+
throw (0, exposure_js_1.sharedNoteError)(row.id, new keyring_js_1.NoWorkspaceKeyError());
|
|
933
|
+
}
|
|
934
|
+
// Never under an older version than the row already is (a rollback).
|
|
935
|
+
const choice = await this.keyring
|
|
936
|
+
.forWrite(accountKey, workspaceId, { atLeast: row.keyVersion ?? null })
|
|
937
|
+
.catch((err) => {
|
|
938
|
+
// Only fixed text reaches the caller (see describeKeyFailure).
|
|
939
|
+
if (err instanceof vault_js_1.LockedError || err instanceof keyring_js_1.WriteRefusedError || !workspaceId)
|
|
940
|
+
throw err;
|
|
941
|
+
throw (0, exposure_js_1.sharedNoteError)(row.id, err);
|
|
942
|
+
});
|
|
943
|
+
if ((0, exposure_js_1.isSharedNote)(row) && (choice.keyVersion === null || choice.key === accountKey)) {
|
|
944
|
+
// Unreachable through forWrite(); kept so no future path can do it.
|
|
945
|
+
throw new Error(`Note ${row.id} is in a shared workspace and is never written with the account key.`);
|
|
946
|
+
}
|
|
947
|
+
if (!(0, exposure_js_1.isSharedNote)(row) && choice.keyVersion !== null) {
|
|
948
|
+
this.requireOwnedForRekey(choice, `Note ${row.id}`);
|
|
949
|
+
}
|
|
950
|
+
const body = {
|
|
951
|
+
encryptedAttrs: await this.encodeAttrsChecked(choice.key, input.attrs, row.id),
|
|
952
|
+
};
|
|
953
|
+
const rekey = choice.keyVersion !== (row.keyVersion ?? null);
|
|
954
|
+
const fields = input.body ??
|
|
955
|
+
(rekey ? (typeof input.current === 'function' ? await input.current() : input.current) : undefined);
|
|
956
|
+
if (fields !== undefined) {
|
|
957
|
+
const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
|
|
958
|
+
const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(choice.key, fields.title, fields.content);
|
|
959
|
+
body.title = '[ENCRYPTED]';
|
|
960
|
+
body.content = '[ENCRYPTED]';
|
|
961
|
+
body.encryptedTitle = encryptedTitle;
|
|
962
|
+
body.encryptedContent = encryptedContent;
|
|
963
|
+
body.iv = iv;
|
|
964
|
+
body.salt = salt;
|
|
965
|
+
}
|
|
966
|
+
if (choice.keyVersion !== null)
|
|
967
|
+
body.keyVersion = choice.keyVersion;
|
|
968
|
+
return body;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Moving ciphertext from the account key to a workspace key is what the
|
|
972
|
+
* owner's sharing run does. For anyone else, a server that says a personal
|
|
973
|
+
* row is now in a workspace could otherwise have it re-encrypted under a key
|
|
974
|
+
* that server handed out, so it is refused unless this account owns the
|
|
975
|
+
* workspace.
|
|
976
|
+
*/
|
|
977
|
+
requireOwnedForRekey(choice, what) {
|
|
978
|
+
if (choice.ownerId !== this.viewerId()) {
|
|
979
|
+
throw new Error(`${what} is still under your own key but the server places it in a shared workspace you do not own, ` +
|
|
980
|
+
'so it was not re-encrypted. Open it in the Virlow app.');
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Writes a folder's schema under the key its place uses now. In a shared
|
|
985
|
+
* workspace the folder's name shares the row's key version, so a folder
|
|
986
|
+
* still under an older version has its name re-encrypted in the same write.
|
|
987
|
+
*/
|
|
988
|
+
async writeSchema(accountKey, folder, schema, startEpoch) {
|
|
989
|
+
const choice = await this.keyring.forWrite(accountKey, folder.workspaceId ?? null, {
|
|
990
|
+
atLeast: folder.keyVersion ?? null,
|
|
991
|
+
});
|
|
992
|
+
if (choice.keyVersion !== null && (folder.keyVersion ?? null) === null && (folder.encryptedSchema || folder.encryptedName)) {
|
|
993
|
+
this.requireOwnedForRekey(choice, `Folder ${folder.id}`);
|
|
994
|
+
}
|
|
995
|
+
const update = {
|
|
996
|
+
encryptedSchema: await this.encodeSchemaChecked(choice.key, schema, folder.id),
|
|
997
|
+
};
|
|
998
|
+
if (choice.keyVersion !== null) {
|
|
999
|
+
update.keyVersion = choice.keyVersion;
|
|
1000
|
+
if (folder.encryptedName && folder.keyVersion !== choice.keyVersion) {
|
|
1001
|
+
const name = await (0, virlow_crypto_1.decryptStringBlob)(await this.keyForFolder(accountKey, folder), folder.encryptedName);
|
|
1002
|
+
update.encryptedName = await (0, virlow_crypto_1.encryptStringBlob)(choice.key, name);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (this.epoch !== startEpoch)
|
|
1006
|
+
throw lockedError();
|
|
1007
|
+
await this.api.updateFolder(folder.id, update);
|
|
1008
|
+
}
|
|
804
1009
|
/** Decrypt (with per-unlock caching) or pass through a legacy plaintext
|
|
805
1010
|
* row. Legacy rows (no encryptedTitle) carry their real content directly
|
|
806
1011
|
* in the plaintext title/content columns. */
|
|
@@ -884,7 +1089,7 @@ class NotesService {
|
|
|
884
1089
|
// get_backlinks and the web disagreed about the same vault.
|
|
885
1090
|
if (row.archived === true)
|
|
886
1091
|
continue;
|
|
887
|
-
if (!exp.allows(row.folderId)
|
|
1092
|
+
if (!exp.allows(row.folderId))
|
|
888
1093
|
continue;
|
|
889
1094
|
rows.push(row);
|
|
890
1095
|
if (rows.length >= exports.SEARCH_SCAN_CAP) {
|
|
@@ -918,13 +1123,17 @@ class NotesService {
|
|
|
918
1123
|
for (const row of rows) {
|
|
919
1124
|
if (row.id === excludeId)
|
|
920
1125
|
continue;
|
|
921
|
-
// Mentions are best effort: a note that cannot be decrypted here
|
|
922
|
-
//
|
|
923
|
-
|
|
1126
|
+
// Mentions are best effort: a note that cannot be decrypted here (a
|
|
1127
|
+
// shared note whose key does not open, a corrupt row) is left out
|
|
1128
|
+
// rather than failing the save that asked for them.
|
|
1129
|
+
const rowKey = await this.keyForRowOrNull(key, row, exposure);
|
|
1130
|
+
if (rowKey === null)
|
|
1131
|
+
continue;
|
|
1132
|
+
const title = await this.decryptTitleFor(rowKey, row).catch(() => null);
|
|
924
1133
|
if (title === null)
|
|
925
1134
|
continue;
|
|
926
1135
|
entries.push({ id: row.id, title });
|
|
927
|
-
const attrs = await this.decodeAttrsFor(
|
|
1136
|
+
const attrs = await this.decodeAttrsFor(rowKey, row).catch(() => null);
|
|
928
1137
|
for (const alias of attrs?.aliases ?? []) {
|
|
929
1138
|
entries.push({ id: row.id, title: alias });
|
|
930
1139
|
}
|
|
@@ -940,7 +1149,7 @@ class NotesService {
|
|
|
940
1149
|
if (folder === undefined) {
|
|
941
1150
|
throw new Error(`There is no folder ${folderId}. Run list_folders to see the folders exposed to MCP.`);
|
|
942
1151
|
}
|
|
943
|
-
if (!this.exposureOf(folders).allows(folder.id)) {
|
|
1152
|
+
if (!(await this.exposureOf(folders)).allows(folder.id)) {
|
|
944
1153
|
throw new Error(`Folder ${folderId} is not exposed to MCP. Turn on "Expose to MCP" in that folder's ` +
|
|
945
1154
|
'settings in the Virlow app to access it here.');
|
|
946
1155
|
}
|
|
@@ -949,7 +1158,7 @@ class NotesService {
|
|
|
949
1158
|
/** Decode (with per-unlock caching) a folder's property definitions. A
|
|
950
1159
|
* folder that has never had any decodes to an empty schema rather than
|
|
951
1160
|
* null, so every caller can treat the two the same way. */
|
|
952
|
-
async decodeSchemaFor(
|
|
1161
|
+
async decodeSchemaFor(accountKey, folder) {
|
|
953
1162
|
if (!folder.encryptedSchema)
|
|
954
1163
|
return (0, attrs_js_1.emptySchema)();
|
|
955
1164
|
const cacheKey = `${folder.id}:${folder.encryptedSchema}`;
|
|
@@ -957,6 +1166,15 @@ class NotesService {
|
|
|
957
1166
|
if (cached)
|
|
958
1167
|
return cached;
|
|
959
1168
|
const startEpoch = this.epoch;
|
|
1169
|
+
// A shared folder's schema is under its workspace's key. One that does
|
|
1170
|
+
// not open is refused, not read as empty: writing an empty schema back
|
|
1171
|
+
// would erase the workspace's properties for everyone.
|
|
1172
|
+
const key = await this.keyForFolder(accountKey, folder).catch((err) => {
|
|
1173
|
+
if (err instanceof vault_js_1.LockedError)
|
|
1174
|
+
throw err;
|
|
1175
|
+
throw new Error(`Folder ${folder.id} is in a shared workspace whose key could not be opened here ` +
|
|
1176
|
+
`(${(0, keyring_js_1.describeKeyFailure)(err)}). Open it in the Virlow app instead.`);
|
|
1177
|
+
});
|
|
960
1178
|
const schema = (await (0, attrs_js_1.decodeSchema)(key, folder.encryptedSchema)) ?? (0, attrs_js_1.emptySchema)();
|
|
961
1179
|
// Same guard as decryptRow(): a decode still in flight when the vault
|
|
962
1180
|
// locks resolves for its own caller but never seeds the next session.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type SignerMemory } from './keyring.js';
|
|
2
|
+
/**
|
|
3
|
+
* Remembered signing keys in a small JSON file per account, for the local
|
|
4
|
+
* CLI: the connector's counterpart of the web app's `virlow.knownSigners`
|
|
5
|
+
* localStorage entry. The keys are public, so the file is not encrypted; what
|
|
6
|
+
* matters is that it outlives the process, so a granter's key that changes
|
|
7
|
+
* between runs is refused rather than trusted afresh. The file name is a hash
|
|
8
|
+
* of the account id, not the id itself.
|
|
9
|
+
*
|
|
10
|
+
* `get` reads the file each time, so a pin another process (a second CLI)
|
|
11
|
+
* wrote is seen. `remember` is a compare-and-set under a lock file: it reads
|
|
12
|
+
* the file again inside the lock and throws KeyChangedError if a different
|
|
13
|
+
* key is already pinned, never overwriting it.
|
|
14
|
+
*
|
|
15
|
+
* A missing or unreadable file starts empty (first use), and a failed write
|
|
16
|
+
* keeps the key in memory for this run, as the web does in private mode.
|
|
17
|
+
*/
|
|
18
|
+
export declare function fileSignerMemory(opts: {
|
|
19
|
+
dir: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
}): SignerMemory;
|