@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/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,13 +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));
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 mapped = await Promise.all(visible.map(async (row) => {
261
- const { title } = await this.decryptRow(key, row);
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;
270
+ // Only when there are any, so the usual case reads exactly as before.
271
+ if (shared > 0)
272
+ Object.assign(withheld, { shared });
273
+ const mapped = await Promise.all(visible.map(async ({ row, rowKey }) => {
274
+ // One note that cannot be decrypted here must not fail the whole page.
275
+ const title = await this.decryptRow(rowKey, row).then((fields) => fields.title, () => '(cannot be opened here)');
262
276
  return {
263
277
  id: row.id,
264
278
  title,
@@ -300,19 +314,23 @@ class NotesService {
300
314
  });
301
315
  const exposure = await this.exposure();
302
316
  const notHidden = notes.filter((row) => row.hidden !== true && !isSystemNote(row));
303
- const visible = notHidden.filter((row) => exposure.allows(row.folderId));
317
+ const exposed = notHidden.filter((row) => exposure.allows(row.folderId));
304
318
  const withheld = {
305
- inClosedFolders: notHidden.length - visible.length,
319
+ inClosedFolders: notHidden.length - exposed.length,
306
320
  };
307
- const rows = await Promise.all(visible.map(async (row) => {
308
- 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);
309
327
  const values = {};
310
328
  for (const def of defs) {
311
329
  values[def.id] = (0, attrs_js_1.formatProp)(def, attrs?.props?.[def.id]);
312
330
  }
313
331
  return {
314
332
  id: row.id,
315
- title: await this.decryptTitleFor(key, row),
333
+ title: await this.decryptTitleFor(rowKey, row),
316
334
  values,
317
335
  };
318
336
  }));
@@ -346,8 +364,9 @@ class NotesService {
346
364
  // not read this folder, and knowing the id does not change that.
347
365
  const exposure = await this.exposure();
348
366
  this.requireExposed(exposure, row.folderId, id);
349
- const { title, content } = await this.decryptRow(key, row);
350
- 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);
351
370
  return { id: row.id, title, content, folderId: row.folderId, attrs };
352
371
  }
353
372
  async createNote(title, content, folderId, type) {
@@ -355,8 +374,15 @@ class NotesService {
355
374
  // Same guard as updateNote(): captured before the encrypt await so a
356
375
  // lock landing mid-create is caught before the write, not after.
357
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
+ }
358
384
  const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
359
- 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);
360
386
  if (this.epoch !== startEpoch) {
361
387
  throw lockedError();
362
388
  }
@@ -372,6 +398,8 @@ class NotesService {
372
398
  body.folderId = folderId;
373
399
  if (type !== undefined)
374
400
  body.type = type;
401
+ if (choice.keyVersion !== null)
402
+ body.keyVersion = choice.keyVersion;
375
403
  const row = await this.api.createNote(body);
376
404
  this.invalidateVisibleRows();
377
405
  return { id: row.id };
@@ -389,10 +417,11 @@ class NotesService {
389
417
  }
390
418
  const exposure = await this.exposure();
391
419
  this.requireExposed(exposure, existingRow.folderId, id);
392
- const current = await this.decryptRow(key, existingRow);
420
+ const rowKey = await this.keyForRowOrThrow(key, existingRow, exposure);
421
+ const current = await this.decryptRow(rowKey, existingRow);
393
422
  const title = changes.title ?? current.title;
394
423
  const content = changes.content ?? current.content;
395
- 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)();
396
425
  const props = { ...prevAttrs.props };
397
426
  for (const [propKey, value] of Object.entries(changes.props ?? {})) {
398
427
  if (value === null) {
@@ -419,24 +448,18 @@ class NotesService {
419
448
  updated: new Date().toISOString(),
420
449
  ...(changes.aliases !== undefined ? { aliases: changes.aliases } : {}),
421
450
  };
422
- const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, id);
423
451
  // A props-or-aliases-only edit leaves the body exactly as it was, so it
424
452
  // sends only the attrs blob. Re-encrypting the unchanged title and
425
453
  // content would rewrite the row's ciphertext (and its IV and salt) for
426
454
  // no reason: pure work, a bigger request, a pointless revision, and one
427
455
  // more chance for an unrelated failure to corrupt a body nobody asked
428
- // to change.
429
- const body = { encryptedAttrs };
430
- if (changes.title !== undefined || changes.content !== undefined) {
431
- const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
432
- const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, title, content);
433
- body.title = '[ENCRYPTED]';
434
- body.content = '[ENCRYPTED]';
435
- body.encryptedTitle = encryptedTitle;
436
- body.encryptedContent = encryptedContent;
437
- body.iv = iv;
438
- body.salt = salt;
439
- }
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
+ });
440
463
  if (this.epoch !== startEpoch) {
441
464
  throw lockedError();
442
465
  }
@@ -459,14 +482,19 @@ class NotesService {
459
482
  async backlinks(id) {
460
483
  const key = this.requireSession();
461
484
  const startEpoch = this.epoch;
462
- const rows = await this.listVisibleRows();
485
+ const exposure = await this.exposure();
486
+ const rows = await this.listVisibleRows(exposure);
463
487
  const result = [];
464
488
  for (const row of rows) {
465
489
  if (row.id === id)
466
490
  continue;
467
- 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);
468
496
  if (attrs?.links.includes(id)) {
469
- result.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
497
+ result.push({ id: row.id, title: await this.decryptTitleFor(rowKey, row) });
470
498
  }
471
499
  }
472
500
  if (this.epoch !== startEpoch) {
@@ -522,10 +550,7 @@ class NotesService {
522
550
  ...schema,
523
551
  home: { ...schema.home, widgets: stamped, previous: kept },
524
552
  };
525
- const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
526
- if (this.epoch !== startEpoch)
527
- throw lockedError();
528
- await this.api.updateFolder(folderId, { encryptedSchema });
553
+ await this.writeSchema(key, folder, next, startEpoch);
529
554
  return stamped;
530
555
  });
531
556
  }
@@ -567,10 +592,7 @@ class NotesService {
567
592
  if (created === undefined) {
568
593
  throw new Error(`Could not define property "${name}" in folder ${folderId}.`);
569
594
  }
570
- const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
571
- if (this.epoch !== startEpoch)
572
- throw lockedError();
573
- await this.api.updateFolder(folderId, { encryptedSchema });
595
+ await this.writeSchema(key, folder, next, startEpoch);
574
596
  return created;
575
597
  });
576
598
  }
@@ -602,8 +624,9 @@ class NotesService {
602
624
  }
603
625
  const row = await this.api.getNote(noteId);
604
626
  const { folders } = await this.api.listFolders();
605
- const exposure = this.exposureOf(folders);
627
+ const exposure = await this.exposureOf(folders);
606
628
  this.requireExposed(exposure, row.folderId, noteId);
629
+ const rowKey = await this.keyForRowOrThrow(key, row, exposure);
607
630
  const folder = folders.find((f) => f.id === row.folderId);
608
631
  if (folder === undefined) {
609
632
  // Narrowing: requireExposed() above only passes for a folder that is
@@ -616,7 +639,7 @@ class NotesService {
616
639
  // The attrs blob is built and size-checked before anything is written,
617
640
  // so a note whose properties would not fit never costs the folder an
618
641
  // orphan option.
619
- 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)();
620
643
  const props = { ...prevAttrs.props };
621
644
  for (const [propId, value] of Object.entries(patch)) {
622
645
  if (value === null) {
@@ -629,7 +652,10 @@ class NotesService {
629
652
  // `v` and every unknown field come from prevAttrs, so a blob written by
630
653
  // a newer client survives this round trip (see updateNote()).
631
654
  const attrs = { ...prevAttrs, props, updated: new Date().toISOString() };
632
- 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
+ });
633
659
  if (newOptions.size > 0) {
634
660
  let nextSchema = schema;
635
661
  for (const [defId, added] of newOptions) {
@@ -638,14 +664,11 @@ class NotesService {
638
664
  options: [...(def?.options ?? []), ...added],
639
665
  });
640
666
  }
641
- const encryptedSchema = await this.encodeSchemaChecked(key, nextSchema, folder.id);
642
- if (this.epoch !== startEpoch)
643
- throw lockedError();
644
- await this.api.updateFolder(folder.id, { encryptedSchema });
667
+ await this.writeSchema(key, folder, nextSchema, startEpoch);
645
668
  }
646
669
  if (this.epoch !== startEpoch)
647
670
  throw lockedError();
648
- await this.api.updateNote(noteId, { encryptedAttrs });
671
+ await this.api.updateNote(noteId, body);
649
672
  this.invalidateVisibleRows();
650
673
  return { attrs, applied };
651
674
  });
@@ -672,7 +695,15 @@ class NotesService {
672
695
  if (scanned >= exports.SEARCH_SCAN_CAP)
673
696
  break scan;
674
697
  scanned++;
675
- const { title, content } = await this.decryptRow(key, row);
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);
704
+ if (fields === null)
705
+ continue;
706
+ const { title, content } = fields;
676
707
  const contentIdx = content.toLowerCase().indexOf(q);
677
708
  const titleMatch = title.toLowerCase().includes(q);
678
709
  if (titleMatch || contentIdx !== -1) {
@@ -704,6 +735,13 @@ class NotesService {
704
735
  if (folderId !== null) {
705
736
  this.requireExposed(exposure, folderId, id);
706
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
+ }
707
745
  await this.api.updateNote(id, { folderId });
708
746
  this.invalidateVisibleRows();
709
747
  }
@@ -732,14 +770,14 @@ class NotesService {
732
770
  this.invalidateVisibleRows();
733
771
  }
734
772
  async listFolders() {
735
- this.requireSession();
773
+ const key = this.requireSession();
736
774
  const { folders } = await this.api.listFolders();
737
- const exposure = (0, exposure_js_1.computeExposure)(folders);
775
+ const exposure = await this.exposureOf(folders);
738
776
  const visible = folders.filter((f) => exposure.allows(f.id));
739
777
  return {
740
778
  // Names are withheld along with contents: a folder the user has not
741
779
  // opened should not be inventoried either, only counted.
742
- 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 }))),
743
781
  withheld: folders.length - visible.length,
744
782
  };
745
783
  }
@@ -750,10 +788,19 @@ class NotesService {
750
788
  const { folders } = await this.api.listFolders();
751
789
  return this.exposureOf(folders);
752
790
  }
791
+ /** Who is unlocked: the account whose consent the exposure rule asks for. */
792
+ viewerId() {
793
+ return this.vault.session.userId;
794
+ }
753
795
  /** The same computation for a caller that already has the folder list and
754
796
  * needs a folder out of it as well as the exposure (the property tools do:
755
797
  * a folder's schema and its exposure come from the same row). */
756
- 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);
757
804
  // Everything computeExposure() actually reads, in list order. Any change
758
805
  // to it changes which notes are visible, so it invalidates the paged row
759
806
  // cache keyed on this string. The key travels on the exposure object
@@ -761,9 +808,16 @@ class NotesService {
761
808
  // and a field would let the second one's key be stamped onto rows the
762
809
  // first one filtered under a different exposure.
763
810
  const key = folders
764
- .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) ?? '') : ''}`)
765
813
  .join('|');
766
- 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
+ };
767
821
  }
768
822
  /** Throws unless `folderId` is exposed to MCP. Every method that reads or
769
823
  * mutates a specific note (by id) must call this — knowing a note's id is
@@ -774,10 +828,184 @@ class NotesService {
774
828
  }
775
829
  }
776
830
  async createFolder(name, parentId) {
777
- 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
+ }
778
853
  const folder = await this.api.createFolder(name, parentId);
779
854
  return { id: folder.id };
780
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
+ }
781
1009
  /** Decrypt (with per-unlock caching) or pass through a legacy plaintext
782
1010
  * row. Legacy rows (no encryptedTitle) carry their real content directly
783
1011
  * in the plaintext title/content columns. */
@@ -895,8 +1123,17 @@ class NotesService {
895
1123
  for (const row of rows) {
896
1124
  if (row.id === excludeId)
897
1125
  continue;
898
- entries.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
899
- const attrs = await this.decodeAttrsFor(key, row);
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);
1133
+ if (title === null)
1134
+ continue;
1135
+ entries.push({ id: row.id, title });
1136
+ const attrs = await this.decodeAttrsFor(rowKey, row).catch(() => null);
900
1137
  for (const alias of attrs?.aliases ?? []) {
901
1138
  entries.push({ id: row.id, title: alias });
902
1139
  }
@@ -912,7 +1149,7 @@ class NotesService {
912
1149
  if (folder === undefined) {
913
1150
  throw new Error(`There is no folder ${folderId}. Run list_folders to see the folders exposed to MCP.`);
914
1151
  }
915
- if (!this.exposureOf(folders).allows(folder.id)) {
1152
+ if (!(await this.exposureOf(folders)).allows(folder.id)) {
916
1153
  throw new Error(`Folder ${folderId} is not exposed to MCP. Turn on "Expose to MCP" in that folder's ` +
917
1154
  'settings in the Virlow app to access it here.');
918
1155
  }
@@ -921,7 +1158,7 @@ class NotesService {
921
1158
  /** Decode (with per-unlock caching) a folder's property definitions. A
922
1159
  * folder that has never had any decodes to an empty schema rather than
923
1160
  * null, so every caller can treat the two the same way. */
924
- async decodeSchemaFor(key, folder) {
1161
+ async decodeSchemaFor(accountKey, folder) {
925
1162
  if (!folder.encryptedSchema)
926
1163
  return (0, attrs_js_1.emptySchema)();
927
1164
  const cacheKey = `${folder.id}:${folder.encryptedSchema}`;
@@ -929,6 +1166,15 @@ class NotesService {
929
1166
  if (cached)
930
1167
  return cached;
931
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
+ });
932
1178
  const schema = (await (0, attrs_js_1.decodeSchema)(key, folder.encryptedSchema)) ?? (0, attrs_js_1.emptySchema)();
933
1179
  // Same guard as decryptRow(): a decode still in flight when the vault
934
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;