@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.
Files changed (49) hide show
  1. package/dist/api.d.ts +92 -3
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +23 -1
  4. package/dist/api.js.map +1 -1
  5. package/dist/attachments.d.ts +20 -2
  6. package/dist/attachments.d.ts.map +1 -1
  7. package/dist/attachments.js +135 -22
  8. package/dist/attachments.js.map +1 -1
  9. package/dist/cjs/api.d.ts +92 -3
  10. package/dist/cjs/api.js +23 -1
  11. package/dist/cjs/attachments.d.ts +20 -2
  12. package/dist/cjs/attachments.js +134 -21
  13. package/dist/cjs/exposure.d.ts +30 -4
  14. package/dist/cjs/exposure.js +48 -9
  15. package/dist/cjs/index.d.ts +4 -1
  16. package/dist/cjs/index.js +9 -2
  17. package/dist/cjs/keyring.d.ts +155 -0
  18. package/dist/cjs/keyring.js +319 -0
  19. package/dist/cjs/memories.d.ts +2 -2
  20. package/dist/cjs/notes.d.ts +52 -1
  21. package/dist/cjs/notes.js +296 -78
  22. package/dist/cjs/signer-file.d.ts +21 -0
  23. package/dist/cjs/signer-file.js +117 -0
  24. package/dist/exposure.d.ts +30 -4
  25. package/dist/exposure.d.ts.map +1 -1
  26. package/dist/exposure.js +47 -9
  27. package/dist/exposure.js.map +1 -1
  28. package/dist/index.d.ts +4 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/keyring.d.ts +156 -0
  33. package/dist/keyring.d.ts.map +1 -0
  34. package/dist/keyring.js +308 -0
  35. package/dist/keyring.js.map +1 -0
  36. package/dist/memories.d.ts +2 -2
  37. package/dist/notes.d.ts +52 -1
  38. package/dist/notes.d.ts.map +1 -1
  39. package/dist/notes.js +297 -79
  40. package/dist/notes.js.map +1 -1
  41. package/dist/signer-file.d.ts +22 -0
  42. package/dist/signer-file.d.ts.map +1 -0
  43. package/dist/signer-file.js +112 -0
  44. package/dist/signer-file.js.map +1 -0
  45. package/dist/tools.d.ts +10 -0
  46. package/dist/tools.d.ts.map +1 -1
  47. package/dist/tools.js +6 -2
  48. package/dist/tools.js.map +1 -1
  49. 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
- constructor(api, vault) {
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 visible = notHidden.filter((row) => exposure.allows(row.folderId) && !(0, exposure_js_1.isSharedNote)(row));
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
- const shared = notHidden.filter((row) => exposure.allows(row.folderId) && (0, exposure_js_1.isSharedNote)(row)).length;
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(key, row).then((fields) => fields.title, () => '(cannot be opened here)');
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 visible = notHidden.filter((row) => exposure.allows(row.folderId) && !(0, exposure_js_1.isSharedNote)(row));
317
+ const exposed = notHidden.filter((row) => exposure.allows(row.folderId));
309
318
  const withheld = {
310
- inClosedFolders: notHidden.length - visible.length,
319
+ inClosedFolders: notHidden.length - exposed.length,
311
320
  };
312
- const rows = await Promise.all(visible.map(async (row) => {
313
- const attrs = await this.decodeAttrsFor(key, row);
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(key, row),
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
- if ((0, exposure_js_1.isSharedNote)(row))
355
- throw (0, exposure_js_1.sharedNoteError)(id);
356
- const { title, content } = await this.decryptRow(key, row);
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
- if ((0, exposure_js_1.isSharedNote)(existingRow))
400
- throw (0, exposure_js_1.sharedNoteError)(id);
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(key, existingRow)) ?? (0, attrs_js_1.emptyAttrs)();
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
- const body = { encryptedAttrs };
439
- if (changes.title !== undefined || changes.content !== undefined) {
440
- const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
441
- const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, title, content);
442
- body.title = '[ENCRYPTED]';
443
- body.content = '[ENCRYPTED]';
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 rows = await this.listVisibleRows();
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
- const attrs = await this.decodeAttrsFor(key, row);
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(key, row) });
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
- const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
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
- const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
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
- if ((0, exposure_js_1.isSharedNote)(row))
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(key, row)) ?? (0, attrs_js_1.emptyAttrs)();
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 encryptedAttrs = await this.encodeAttrsChecked(key, attrs, noteId);
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
- const encryptedSchema = await this.encodeSchemaChecked(key, nextSchema, folder.id);
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, { encryptedAttrs });
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) || (0, exposure_js_1.isSharedNote)(row))
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 is skipped, not fatal to the search.
687
- const fields = await this.decryptRow(key, row).catch(() => null);
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 = (0, exposure_js_1.computeExposure)(folders);
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: f.name, parentId: f.parentId })),
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
- return { ...(0, exposure_js_1.computeExposure)(folders), key };
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) || (0, exposure_js_1.isSharedNote)(row))
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 is left
922
- // out rather than failing the save that asked for them.
923
- const title = await this.decryptTitleFor(key, row).catch(() => null);
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(key, row).catch(() => null);
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(key, folder) {
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;