@batalabs/virlow-mcp-core 3.116.0 → 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.
Files changed (50) hide show
  1. package/README.md +4 -0
  2. package/dist/api.d.ts +93 -1
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +23 -1
  5. package/dist/api.js.map +1 -1
  6. package/dist/attachments.d.ts +20 -2
  7. package/dist/attachments.d.ts.map +1 -1
  8. package/dist/attachments.js +136 -21
  9. package/dist/attachments.js.map +1 -1
  10. package/dist/cjs/api.d.ts +93 -1
  11. package/dist/cjs/api.js +23 -1
  12. package/dist/cjs/attachments.d.ts +20 -2
  13. package/dist/cjs/attachments.js +134 -19
  14. package/dist/cjs/exposure.d.ts +35 -1
  15. package/dist/cjs/exposure.js +59 -2
  16. package/dist/cjs/index.d.ts +4 -1
  17. package/dist/cjs/index.js +9 -2
  18. package/dist/cjs/keyring.d.ts +155 -0
  19. package/dist/cjs/keyring.js +319 -0
  20. package/dist/cjs/memories.d.ts +2 -2
  21. package/dist/cjs/notes.d.ts +53 -1
  22. package/dist/cjs/notes.js +304 -58
  23. package/dist/cjs/signer-file.d.ts +21 -0
  24. package/dist/cjs/signer-file.js +117 -0
  25. package/dist/exposure.d.ts +35 -1
  26. package/dist/exposure.d.ts.map +1 -1
  27. package/dist/exposure.js +56 -2
  28. package/dist/exposure.js.map +1 -1
  29. package/dist/index.d.ts +4 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/keyring.d.ts +156 -0
  34. package/dist/keyring.d.ts.map +1 -0
  35. package/dist/keyring.js +308 -0
  36. package/dist/keyring.js.map +1 -0
  37. package/dist/memories.d.ts +2 -2
  38. package/dist/notes.d.ts +53 -1
  39. package/dist/notes.d.ts.map +1 -1
  40. package/dist/notes.js +306 -60
  41. package/dist/notes.js.map +1 -1
  42. package/dist/signer-file.d.ts +22 -0
  43. package/dist/signer-file.d.ts.map +1 -0
  44. package/dist/signer-file.js +112 -0
  45. package/dist/signer-file.js.map +1 -0
  46. package/dist/tools.d.ts +10 -0
  47. package/dist/tools.d.ts.map +1 -1
  48. package/dist/tools.js +6 -2
  49. package/dist/tools.js.map +1 -1
  50. package/package.json +3 -3
package/dist/notes.js CHANGED
@@ -1,9 +1,10 @@
1
- import { bytesToBase64, decryptNoteFields, decryptTitleField, encryptNoteFields } from '@batalabs/virlow-crypto';
2
- import { computeExposure } from './exposure.js';
1
+ import { bytesToBase64, decryptNoteFields, decryptStringBlob, decryptTitleField, encryptNoteFields, encryptStringBlob, } from '@batalabs/virlow-crypto';
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
- constructor(api, vault) {
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,13 +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 visible = notHidden.filter((row) => exposure.allows(row.folderId));
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
- const mapped = await Promise.all(visible.map(async (row) => {
258
- const { title } = await this.decryptRow(key, row);
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;
267
+ // Only when there are any, so the usual case reads exactly as before.
268
+ if (shared > 0)
269
+ Object.assign(withheld, { shared });
270
+ const mapped = await Promise.all(visible.map(async ({ row, rowKey }) => {
271
+ // One note that cannot be decrypted here must not fail the whole page.
272
+ const title = await this.decryptRow(rowKey, row).then((fields) => fields.title, () => '(cannot be opened here)');
259
273
  return {
260
274
  id: row.id,
261
275
  title,
@@ -297,19 +311,23 @@ export class NotesService {
297
311
  });
298
312
  const exposure = await this.exposure();
299
313
  const notHidden = notes.filter((row) => row.hidden !== true && !isSystemNote(row));
300
- const visible = notHidden.filter((row) => exposure.allows(row.folderId));
314
+ const exposed = notHidden.filter((row) => exposure.allows(row.folderId));
301
315
  const withheld = {
302
- inClosedFolders: notHidden.length - visible.length,
316
+ inClosedFolders: notHidden.length - exposed.length,
303
317
  };
304
- const rows = await Promise.all(visible.map(async (row) => {
305
- const attrs = await this.decodeAttrsFor(key, row);
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);
306
324
  const values = {};
307
325
  for (const def of defs) {
308
326
  values[def.id] = formatProp(def, attrs?.props?.[def.id]);
309
327
  }
310
328
  return {
311
329
  id: row.id,
312
- title: await this.decryptTitleFor(key, row),
330
+ title: await this.decryptTitleFor(rowKey, row),
313
331
  values,
314
332
  };
315
333
  }));
@@ -343,8 +361,9 @@ export class NotesService {
343
361
  // not read this folder, and knowing the id does not change that.
344
362
  const exposure = await this.exposure();
345
363
  this.requireExposed(exposure, row.folderId, id);
346
- const { title, content } = await this.decryptRow(key, row);
347
- 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);
348
367
  return { id: row.id, title, content, folderId: row.folderId, attrs };
349
368
  }
350
369
  async createNote(title, content, folderId, type) {
@@ -352,8 +371,15 @@ export class NotesService {
352
371
  // Same guard as updateNote(): captured before the encrypt await so a
353
372
  // lock landing mid-create is caught before the write, not after.
354
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
+ }
355
381
  const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
356
- const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, title, content);
382
+ const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(choice.key, title, content);
357
383
  if (this.epoch !== startEpoch) {
358
384
  throw lockedError();
359
385
  }
@@ -369,6 +395,8 @@ export class NotesService {
369
395
  body.folderId = folderId;
370
396
  if (type !== undefined)
371
397
  body.type = type;
398
+ if (choice.keyVersion !== null)
399
+ body.keyVersion = choice.keyVersion;
372
400
  const row = await this.api.createNote(body);
373
401
  this.invalidateVisibleRows();
374
402
  return { id: row.id };
@@ -386,10 +414,11 @@ export class NotesService {
386
414
  }
387
415
  const exposure = await this.exposure();
388
416
  this.requireExposed(exposure, existingRow.folderId, id);
389
- const current = await this.decryptRow(key, existingRow);
417
+ const rowKey = await this.keyForRowOrThrow(key, existingRow, exposure);
418
+ const current = await this.decryptRow(rowKey, existingRow);
390
419
  const title = changes.title ?? current.title;
391
420
  const content = changes.content ?? current.content;
392
- const prevAttrs = (await this.decodeAttrsFor(key, existingRow)) ?? emptyAttrs();
421
+ const prevAttrs = (await this.decodeAttrsFor(rowKey, existingRow)) ?? emptyAttrs();
393
422
  const props = { ...prevAttrs.props };
394
423
  for (const [propKey, value] of Object.entries(changes.props ?? {})) {
395
424
  if (value === null) {
@@ -416,24 +445,18 @@ export class NotesService {
416
445
  updated: new Date().toISOString(),
417
446
  ...(changes.aliases !== undefined ? { aliases: changes.aliases } : {}),
418
447
  };
419
- const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, id);
420
448
  // A props-or-aliases-only edit leaves the body exactly as it was, so it
421
449
  // sends only the attrs blob. Re-encrypting the unchanged title and
422
450
  // content would rewrite the row's ciphertext (and its IV and salt) for
423
451
  // no reason: pure work, a bigger request, a pointless revision, and one
424
452
  // more chance for an unrelated failure to corrupt a body nobody asked
425
- // to change.
426
- const body = { encryptedAttrs };
427
- if (changes.title !== undefined || changes.content !== undefined) {
428
- const salt = bytesToBase64(crypto.getRandomValues(new Uint8Array(32)));
429
- const { encryptedTitle, encryptedContent, iv } = await encryptNoteFields(key, title, content);
430
- body.title = '[ENCRYPTED]';
431
- body.content = '[ENCRYPTED]';
432
- body.encryptedTitle = encryptedTitle;
433
- body.encryptedContent = encryptedContent;
434
- body.iv = iv;
435
- body.salt = salt;
436
- }
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
+ });
437
460
  if (this.epoch !== startEpoch) {
438
461
  throw lockedError();
439
462
  }
@@ -456,14 +479,19 @@ export class NotesService {
456
479
  async backlinks(id) {
457
480
  const key = this.requireSession();
458
481
  const startEpoch = this.epoch;
459
- const rows = await this.listVisibleRows();
482
+ const exposure = await this.exposure();
483
+ const rows = await this.listVisibleRows(exposure);
460
484
  const result = [];
461
485
  for (const row of rows) {
462
486
  if (row.id === id)
463
487
  continue;
464
- const attrs = await this.decodeAttrsFor(key, row);
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);
465
493
  if (attrs?.links.includes(id)) {
466
- result.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
494
+ result.push({ id: row.id, title: await this.decryptTitleFor(rowKey, row) });
467
495
  }
468
496
  }
469
497
  if (this.epoch !== startEpoch) {
@@ -519,10 +547,7 @@ export class NotesService {
519
547
  ...schema,
520
548
  home: { ...schema.home, widgets: stamped, previous: kept },
521
549
  };
522
- const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
523
- if (this.epoch !== startEpoch)
524
- throw lockedError();
525
- await this.api.updateFolder(folderId, { encryptedSchema });
550
+ await this.writeSchema(key, folder, next, startEpoch);
526
551
  return stamped;
527
552
  });
528
553
  }
@@ -564,10 +589,7 @@ export class NotesService {
564
589
  if (created === undefined) {
565
590
  throw new Error(`Could not define property "${name}" in folder ${folderId}.`);
566
591
  }
567
- const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
568
- if (this.epoch !== startEpoch)
569
- throw lockedError();
570
- await this.api.updateFolder(folderId, { encryptedSchema });
592
+ await this.writeSchema(key, folder, next, startEpoch);
571
593
  return created;
572
594
  });
573
595
  }
@@ -599,8 +621,9 @@ export class NotesService {
599
621
  }
600
622
  const row = await this.api.getNote(noteId);
601
623
  const { folders } = await this.api.listFolders();
602
- const exposure = this.exposureOf(folders);
624
+ const exposure = await this.exposureOf(folders);
603
625
  this.requireExposed(exposure, row.folderId, noteId);
626
+ const rowKey = await this.keyForRowOrThrow(key, row, exposure);
604
627
  const folder = folders.find((f) => f.id === row.folderId);
605
628
  if (folder === undefined) {
606
629
  // Narrowing: requireExposed() above only passes for a folder that is
@@ -613,7 +636,7 @@ export class NotesService {
613
636
  // The attrs blob is built and size-checked before anything is written,
614
637
  // so a note whose properties would not fit never costs the folder an
615
638
  // orphan option.
616
- const prevAttrs = (await this.decodeAttrsFor(key, row)) ?? emptyAttrs();
639
+ const prevAttrs = (await this.decodeAttrsFor(rowKey, row)) ?? emptyAttrs();
617
640
  const props = { ...prevAttrs.props };
618
641
  for (const [propId, value] of Object.entries(patch)) {
619
642
  if (value === null) {
@@ -626,7 +649,10 @@ export class NotesService {
626
649
  // `v` and every unknown field come from prevAttrs, so a blob written by
627
650
  // a newer client survives this round trip (see updateNote()).
628
651
  const attrs = { ...prevAttrs, props, updated: new Date().toISOString() };
629
- const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, noteId);
652
+ const body = await this.sealNoteWrite(key, row, exposure, {
653
+ attrs,
654
+ current: () => this.decryptRow(rowKey, row),
655
+ });
630
656
  if (newOptions.size > 0) {
631
657
  let nextSchema = schema;
632
658
  for (const [defId, added] of newOptions) {
@@ -635,14 +661,11 @@ export class NotesService {
635
661
  options: [...(def?.options ?? []), ...added],
636
662
  });
637
663
  }
638
- const encryptedSchema = await this.encodeSchemaChecked(key, nextSchema, folder.id);
639
- if (this.epoch !== startEpoch)
640
- throw lockedError();
641
- await this.api.updateFolder(folder.id, { encryptedSchema });
664
+ await this.writeSchema(key, folder, nextSchema, startEpoch);
642
665
  }
643
666
  if (this.epoch !== startEpoch)
644
667
  throw lockedError();
645
- await this.api.updateNote(noteId, { encryptedAttrs });
668
+ await this.api.updateNote(noteId, body);
646
669
  this.invalidateVisibleRows();
647
670
  return { attrs, applied };
648
671
  });
@@ -669,7 +692,15 @@ export class NotesService {
669
692
  if (scanned >= SEARCH_SCAN_CAP)
670
693
  break scan;
671
694
  scanned++;
672
- const { title, content } = await this.decryptRow(key, row);
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);
701
+ if (fields === null)
702
+ continue;
703
+ const { title, content } = fields;
673
704
  const contentIdx = content.toLowerCase().indexOf(q);
674
705
  const titleMatch = title.toLowerCase().includes(q);
675
706
  if (titleMatch || contentIdx !== -1) {
@@ -701,6 +732,13 @@ export class NotesService {
701
732
  if (folderId !== null) {
702
733
  this.requireExposed(exposure, folderId, id);
703
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
+ }
704
742
  await this.api.updateNote(id, { folderId });
705
743
  this.invalidateVisibleRows();
706
744
  }
@@ -729,14 +767,14 @@ export class NotesService {
729
767
  this.invalidateVisibleRows();
730
768
  }
731
769
  async listFolders() {
732
- this.requireSession();
770
+ const key = this.requireSession();
733
771
  const { folders } = await this.api.listFolders();
734
- const exposure = computeExposure(folders);
772
+ const exposure = await this.exposureOf(folders);
735
773
  const visible = folders.filter((f) => exposure.allows(f.id));
736
774
  return {
737
775
  // Names are withheld along with contents: a folder the user has not
738
776
  // opened should not be inventoried either, only counted.
739
- folders: visible.map((f) => ({ id: f.id, name: f.name, parentId: f.parentId })),
777
+ folders: await Promise.all(visible.map(async (f) => ({ id: f.id, name: await this.folderName(key, f), parentId: f.parentId }))),
740
778
  withheld: folders.length - visible.length,
741
779
  };
742
780
  }
@@ -747,10 +785,19 @@ export class NotesService {
747
785
  const { folders } = await this.api.listFolders();
748
786
  return this.exposureOf(folders);
749
787
  }
788
+ /** Who is unlocked: the account whose consent the exposure rule asks for. */
789
+ viewerId() {
790
+ return this.vault.session.userId;
791
+ }
750
792
  /** The same computation for a caller that already has the folder list and
751
793
  * needs a folder out of it as well as the exposure (the property tools do:
752
794
  * a folder's schema and its exposure come from the same row). */
753
- 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);
754
801
  // Everything computeExposure() actually reads, in list order. Any change
755
802
  // to it changes which notes are visible, so it invalidates the paged row
756
803
  // cache keyed on this string. The key travels on the exposure object
@@ -758,9 +805,16 @@ export class NotesService {
758
805
  // and a field would let the second one's key be stamped onto rows the
759
806
  // first one filtered under a different exposure.
760
807
  const key = folders
761
- .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) ?? '') : ''}`)
762
810
  .join('|');
763
- return { ...computeExposure(folders), key };
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
+ };
764
818
  }
765
819
  /** Throws unless `folderId` is exposed to MCP. Every method that reads or
766
820
  * mutates a specific note (by id) must call this — knowing a note's id is
@@ -771,10 +825,184 @@ export class NotesService {
771
825
  }
772
826
  }
773
827
  async createFolder(name, parentId) {
774
- 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
+ }
775
850
  const folder = await this.api.createFolder(name, parentId);
776
851
  return { id: folder.id };
777
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
+ }
778
1006
  /** Decrypt (with per-unlock caching) or pass through a legacy plaintext
779
1007
  * row. Legacy rows (no encryptedTitle) carry their real content directly
780
1008
  * in the plaintext title/content columns. */
@@ -892,8 +1120,17 @@ export class NotesService {
892
1120
  for (const row of rows) {
893
1121
  if (row.id === excludeId)
894
1122
  continue;
895
- entries.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
896
- const attrs = await this.decodeAttrsFor(key, row);
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);
1130
+ if (title === null)
1131
+ continue;
1132
+ entries.push({ id: row.id, title });
1133
+ const attrs = await this.decodeAttrsFor(rowKey, row).catch(() => null);
897
1134
  for (const alias of attrs?.aliases ?? []) {
898
1135
  entries.push({ id: row.id, title: alias });
899
1136
  }
@@ -909,7 +1146,7 @@ export class NotesService {
909
1146
  if (folder === undefined) {
910
1147
  throw new Error(`There is no folder ${folderId}. Run list_folders to see the folders exposed to MCP.`);
911
1148
  }
912
- if (!this.exposureOf(folders).allows(folder.id)) {
1149
+ if (!(await this.exposureOf(folders)).allows(folder.id)) {
913
1150
  throw new Error(`Folder ${folderId} is not exposed to MCP. Turn on "Expose to MCP" in that folder's ` +
914
1151
  'settings in the Virlow app to access it here.');
915
1152
  }
@@ -918,7 +1155,7 @@ export class NotesService {
918
1155
  /** Decode (with per-unlock caching) a folder's property definitions. A
919
1156
  * folder that has never had any decodes to an empty schema rather than
920
1157
  * null, so every caller can treat the two the same way. */
921
- async decodeSchemaFor(key, folder) {
1158
+ async decodeSchemaFor(accountKey, folder) {
922
1159
  if (!folder.encryptedSchema)
923
1160
  return emptySchema();
924
1161
  const cacheKey = `${folder.id}:${folder.encryptedSchema}`;
@@ -926,6 +1163,15 @@ export class NotesService {
926
1163
  if (cached)
927
1164
  return cached;
928
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
+ });
929
1175
  const schema = (await decodeSchema(key, folder.encryptedSchema)) ?? emptySchema();
930
1176
  // Same guard as decryptRow(): a decode still in flight when the vault
931
1177
  // locks resolves for its own caller but never seeds the next session.