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