@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/api.d.ts CHANGED
@@ -48,6 +48,15 @@ export interface ApiNote {
48
48
  updatedAt: string;
49
49
  /** Encrypted v2 blob of the note's structured attrs (props, links, mentions). */
50
50
  encryptedAttrs: string | null;
51
+ /** Set on a note in a shared workspace: the version of that workspace's
52
+ * key its ciphertext (title, content, attrs) is under. Null or absent means
53
+ * the account key. See keyring.ts. */
54
+ keyVersion?: number | null;
55
+ /** List and get send the note's folder along; its workspaceId says which
56
+ * shared workspace (if any) the note is in. Absent from older servers. */
57
+ folder?: {
58
+ workspaceId?: string | null;
59
+ } | null;
51
60
  }
52
61
  export interface NoteWrite {
53
62
  title: string;
@@ -63,6 +72,11 @@ export interface NoteWrite {
63
72
  archived?: boolean;
64
73
  deleted?: boolean;
65
74
  encryptedAttrs?: string | null;
75
+ /** In a shared workspace: the workspace key version every ciphertext in
76
+ * this write is under. The API requires it, equal to the workspace's
77
+ * current version, on any write that carries ciphertext there, and
78
+ * refuses it on a personal note. Never set for the account key. */
79
+ keyVersion?: number;
66
80
  /** The version the write is based on; the API answers 409 NOTE_CONFLICT
67
81
  * when the note has moved on since. */
68
82
  expectedUpdatedAt?: string;
@@ -82,6 +96,58 @@ export interface ApiFolder {
82
96
  memoryPaused?: boolean;
83
97
  /** Encrypted v2 blob of the folder's property and view definitions. */
84
98
  encryptedSchema: string | null;
99
+ /** The account the row belongs to: in a shared workspace, its owner. */
100
+ userId?: string | null;
101
+ /** In a shared workspace this account does not own: whether this member
102
+ * opened it to AI tools. Only this, never `mcpEnabled` (one value every
103
+ * editor can set), opens such a folder; absent means closed. */
104
+ mcpEnabledForMember?: boolean;
105
+ /** The shared workspace this folder belongs to; null or absent = personal. */
106
+ workspaceId?: string | null;
107
+ /** In a shared workspace: the name, under the workspace key (`name` is "[ENCRYPTED]"). */
108
+ encryptedName?: string | null;
109
+ /** The workspace key version of encryptedName and encryptedSchema; null = the account key. */
110
+ keyVersion?: number | null;
111
+ }
112
+ /** An account's sharing keys as the API stores them (GET /api/users/me/identity). */
113
+ export interface ApiIdentity {
114
+ box: string;
115
+ sign: string;
116
+ /** "v2:" blob of the private halves, under the account key. */
117
+ sealedPrivate: string;
118
+ }
119
+ /** One shared workspace this account belongs to (GET /api/workspaces). */
120
+ export interface ApiWorkspaceSummary {
121
+ id: string;
122
+ rootFolderId: string;
123
+ role: 'owner' | 'editor' | 'viewer';
124
+ ownerId: string;
125
+ /** The current key version: every new ciphertext in the workspace uses it. */
126
+ keyVersion: number;
127
+ state: 'sharing' | 'shared';
128
+ }
129
+ /** One of this account's grants in a workspace, as GET /api/workspaces/:id sends it. */
130
+ export interface ApiWorkspaceGrant {
131
+ keyVersion: number;
132
+ sealed: string;
133
+ signature: string;
134
+ granterId: string;
135
+ /** The granter's signing key as the server has it now; null if they have none. */
136
+ granterSign: string | null;
137
+ }
138
+ /** One person in a workspace, as GET /api/workspaces/:id sends them. */
139
+ export interface ApiWorkspaceMember {
140
+ userId: string;
141
+ role: 'owner' | 'editor' | 'viewer';
142
+ identity: {
143
+ box: string;
144
+ sign: string;
145
+ } | null;
146
+ }
147
+ export interface ApiWorkspaceDetail {
148
+ workspace: ApiWorkspaceSummary;
149
+ members: ApiWorkspaceMember[];
150
+ grants: ApiWorkspaceGrant[];
85
151
  }
86
152
  /** A reminder on a note, sent to its owner by email or Slack. */
87
153
  export interface ApiReminder {
@@ -111,6 +177,8 @@ export interface ApiFile {
111
177
  /** Set for a large file, whose content is in object storage in encrypted
112
178
  * chunks rather than in the row. Absent or null for every other file. */
113
179
  chunkSize?: number | null;
180
+ /** The workspace key version the name and content use; null = the account key. */
181
+ keyVersion?: number | null;
114
182
  }
115
183
  export interface ApiFileWithData extends ApiFile {
116
184
  /** "v2:<iv>:<ciphertext>"; anything else is legacy v1. */
@@ -124,6 +192,8 @@ export interface FileUpload {
124
192
  salt: string;
125
193
  mimeType: string;
126
194
  size: number;
195
+ /** In a shared workspace: the workspace key version it is encrypted under. */
196
+ keyVersion?: number;
127
197
  }
128
198
  export declare class VirlowApi {
129
199
  private readonly baseUrl;
@@ -209,7 +279,13 @@ export declare class VirlowApi {
209
279
  listFolders(): Promise<{
210
280
  folders: ApiFolder[];
211
281
  }>;
212
- createFolder(name: string, parentId?: string | null, kind?: 'memories'): Promise<ApiFolder>;
282
+ createFolder(name: string, parentId?: string | null, kind?: 'memories',
283
+ /** In a shared workspace: the name under the workspace key, and that key's
284
+ * version. `name` is then the "[ENCRYPTED]" placeholder. */
285
+ sealed?: {
286
+ encryptedName: string;
287
+ keyVersion: number;
288
+ }): Promise<ApiFolder>;
213
289
  /** Partial update of a folder. Only `encryptedSchema` is used from here so
214
290
  * far, and it is forwarded as an already-encrypted blob: nothing in this
215
291
  * client encrypts, and nothing on the server can read it. */
@@ -218,6 +294,11 @@ export declare class VirlowApi {
218
294
  parentId?: string | null;
219
295
  mcpEnabled?: boolean;
220
296
  encryptedSchema?: string | null;
297
+ /** In a shared workspace: the name re-encrypted with the schema, when
298
+ * the folder's ciphertext was under an older key version. */
299
+ encryptedName?: string;
300
+ /** In a shared workspace: the key version of the ciphertext sent. */
301
+ keyVersion?: number;
221
302
  }): Promise<ApiFolder>;
222
303
  listFiles(noteId: string, opts?: {
223
304
  trashed?: boolean;
@@ -231,11 +312,22 @@ export declare class VirlowApi {
231
312
  encryptedTitle?: string;
232
313
  iv?: string;
233
314
  noteId?: string;
315
+ /** A re-key: the content (or a large file's "v3:" header) under another key. */
316
+ encryptedData?: string;
317
+ /** In a shared workspace: the key version of the ciphertext sent. */
318
+ keyVersion?: number;
234
319
  }): Promise<ApiFile>;
235
320
  /** Moves the file to the note's Recently deleted. There is deliberately no
236
321
  * way to hard-delete from here: Claude's deletes can always be undone. */
237
322
  deleteFile(id: string): Promise<void>;
238
323
  restoreFile(id: string): Promise<ApiFile>;
324
+ /** This account's sharing identity: public keys and the sealed private
325
+ * halves. Null until some device of this account has published one. */
326
+ getIdentity(): Promise<ApiIdentity | null>;
327
+ /** The shared workspaces this account belongs to. */
328
+ listWorkspaces(): Promise<ApiWorkspaceSummary[]>;
329
+ /** One shared workspace, with this account's grants (every key version it was given). */
330
+ getWorkspace(id: string): Promise<ApiWorkspaceDetail>;
239
331
  /** This person's reminders on one note (a to-do is a note): pending ones
240
332
  * and those from the last week. */
241
333
  listReminders(noteId: string): Promise<ApiReminder[]>;
package/dist/cjs/api.js CHANGED
@@ -207,7 +207,10 @@ class VirlowApi {
207
207
  }
208
208
  return { folders };
209
209
  }
210
- createFolder(name, parentId, kind) {
210
+ createFolder(name, parentId, kind,
211
+ /** In a shared workspace: the name under the workspace key, and that key's
212
+ * version. `name` is then the "[ENCRYPTED]" placeholder. */
213
+ sealed) {
211
214
  // `kind` is omitted rather than sent as null: the API rejects unknown kinds
212
215
  // and a namespace subfolder is an ordinary folder.
213
216
  const body = {
@@ -216,6 +219,10 @@ class VirlowApi {
216
219
  };
217
220
  if (kind !== undefined)
218
221
  body.kind = kind;
222
+ if (sealed !== undefined) {
223
+ body.encryptedName = sealed.encryptedName;
224
+ body.keyVersion = sealed.keyVersion;
225
+ }
219
226
  return this.request('POST', '/api/folders', body);
220
227
  }
221
228
  /** Partial update of a folder. Only `encryptedSchema` is used from here so
@@ -250,6 +257,21 @@ class VirlowApi {
250
257
  restoreFile(id) {
251
258
  return this.request('POST', `/api/files/${encodeURIComponent(id)}/restore`);
252
259
  }
260
+ /** This account's sharing identity: public keys and the sealed private
261
+ * halves. Null until some device of this account has published one. */
262
+ async getIdentity() {
263
+ const body = await this.request('GET', '/api/users/me/identity');
264
+ return body.identity ?? null;
265
+ }
266
+ /** The shared workspaces this account belongs to. */
267
+ async listWorkspaces() {
268
+ const body = await this.request('GET', '/api/workspaces');
269
+ return body.workspaces;
270
+ }
271
+ /** One shared workspace, with this account's grants (every key version it was given). */
272
+ getWorkspace(id) {
273
+ return this.request('GET', `/api/workspaces/${encodeURIComponent(id)}`);
274
+ }
253
275
  /** This person's reminders on one note (a to-do is a note): pending ones
254
276
  * and those from the last week. */
255
277
  async listReminders(noteId) {
@@ -1,5 +1,6 @@
1
1
  import { type VirlowApi } from './api.js';
2
- import type { Vault } from './vault.js';
2
+ import { type Vault } from './vault.js';
3
+ import { WorkspaceKeyring } from './keyring.js';
3
4
  export interface AttachmentInfo {
4
5
  id: string;
5
6
  noteId: string;
@@ -13,7 +14,15 @@ export declare function largeFileMessage(size: number): string;
13
14
  export declare class AttachmentsService {
14
15
  private readonly api;
15
16
  private readonly vault;
16
- constructor(api: VirlowApi, vault: Vault);
17
+ private readonly keyring;
18
+ /** Bumped when the vault locks. add() and rename() capture it before their
19
+ * first await and check it right before the upload or patch, so nothing is
20
+ * sent encrypted under a key from a session that has since locked. */
21
+ private epoch;
22
+ constructor(api: VirlowApi, vault: Vault,
23
+ /** Shared with NotesService (its `keyring`) so keys open once per unlock. */
24
+ keyring?: WorkspaceKeyring);
25
+ private requireSameSession;
17
26
  list(noteId: string, includeTrashed: boolean): Promise<AttachmentInfo[]>;
18
27
  add(input: {
19
28
  noteId: string;
@@ -34,6 +43,15 @@ export declare class AttachmentsService {
34
43
  /** Metadata with the filename decrypted. A legacy row's placeholder title
35
44
  * does not decrypt; it falls back to its plaintext name, then a generic one. */
36
45
  private describe;
46
+ private requirePlace;
47
+ /** Every note must be exposed. Returns where each one is, in order. */
37
48
  private requireExposed;
49
+ /** The key a file is under: its own version (null = the account key) in its note's workspace. */
50
+ private readKey;
51
+ /** The key a new file, name or content in this note goes under. Never the
52
+ * account key in a shared workspace; a key that does not open is refused. */
53
+ private writeKey;
54
+ /** A file's content (inline) or file key (large file header) under `toKey`. */
55
+ private rekeyContent;
38
56
  private requireSession;
39
57
  }
@@ -4,7 +4,10 @@ exports.AttachmentsService = void 0;
4
4
  exports.largeFileMessage = largeFileMessage;
5
5
  const virlow_crypto_1 = require("@batalabs/virlow-crypto");
6
6
  const exposure_js_1 = require("./exposure.js");
7
+ const keyring_js_1 = require("./keyring.js");
7
8
  const api_js_1 = require("./api.js");
9
+ const vault_js_1 = require("./vault.js");
10
+ const keyring_js_2 = require("./keyring.js");
8
11
  const attachment_format_js_1 = require("./attachment-format.js");
9
12
  /** A file this vault's key cannot open: encrypted before the account had a
10
13
  * master password, under the web app's old per-device key. */
@@ -48,16 +51,32 @@ function inferredMimeType(filename, fromText) {
48
51
  class AttachmentsService {
49
52
  api;
50
53
  vault;
51
- constructor(api, vault) {
54
+ keyring;
55
+ /** Bumped when the vault locks. add() and rename() capture it before their
56
+ * first await and check it right before the upload or patch, so nothing is
57
+ * sent encrypted under a key from a session that has since locked. */
58
+ epoch = 0;
59
+ constructor(api, vault,
60
+ /** Shared with NotesService (its `keyring`) so keys open once per unlock. */
61
+ keyring) {
52
62
  this.api = api;
53
63
  this.vault = vault;
64
+ this.keyring = keyring ?? new keyring_js_2.WorkspaceKeyring(api, vault);
65
+ this.vault.onLock = () => {
66
+ this.epoch++;
67
+ };
68
+ }
69
+ requireSameSession(startEpoch) {
70
+ if (this.epoch !== startEpoch) {
71
+ throw new vault_js_1.LockedError('Vault is locked. Call this server\'s unlock tool and ask the user to finish unlocking their Virlow vault.');
72
+ }
54
73
  }
55
74
  async list(noteId, includeTrashed) {
56
75
  const key = this.requireSession();
57
- await this.requireExposed(noteId);
76
+ const place = await this.requirePlace(noteId);
58
77
  const live = await this.api.listFiles(noteId);
59
78
  const trashed = includeTrashed ? await this.api.listFiles(noteId, { trashed: true }) : [];
60
- return Promise.all([...live, ...trashed].map((file) => this.describe(key, file)));
79
+ return Promise.all([...live, ...trashed].map((file) => this.describe(key, file, place)));
61
80
  }
62
81
  async add(input) {
63
82
  // A backstop under the tools' own per-source caps, so no caller can encrypt
@@ -68,15 +87,21 @@ class AttachmentsService {
68
87
  throw new Error(`The file is ${(0, attachment_format_js_1.formatSize)(input.bytes.byteLength)}; the most that can be attached is ${(0, attachment_format_js_1.formatSize)(attachment_format_js_1.PATH_MAX_BYTES)}.`);
69
88
  }
70
89
  const key = this.requireSession();
71
- await this.requireExposed(input.noteId);
90
+ const startEpoch = this.epoch;
91
+ const place = await this.requirePlace(input.noteId);
72
92
  const filename = (0, attachment_format_js_1.sanitizeFilename)(input.filename);
73
- const encrypted = await (0, virlow_crypto_1.encryptAttachment)(key, filename, input.bytes);
93
+ // In a shared workspace, under its current key, sent with that version;
94
+ // never older than the version the note itself is under.
95
+ const choice = await this.writeKey(key, place, place.row.keyVersion ?? null);
96
+ const encrypted = await (0, virlow_crypto_1.encryptAttachment)(choice.key, filename, input.bytes);
97
+ this.requireSameSession(startEpoch);
74
98
  const row = await this.api.uploadFile(input.noteId, {
75
99
  ...encrypted,
76
100
  // Stored for the web app's legacy per-salt key; unused under a master key.
77
101
  salt: (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32))),
78
102
  mimeType: input.mimeType ?? inferredMimeType(filename, input.fromText === true),
79
103
  size: input.bytes.byteLength,
104
+ ...(choice.keyVersion !== null ? { keyVersion: choice.keyVersion } : {}),
80
105
  });
81
106
  return { ...infoOf(row), filename };
82
107
  }
@@ -84,7 +109,7 @@ class AttachmentsService {
84
109
  const key = this.requireSession();
85
110
  // Metadata first, so a file in a closed folder is never downloaded at all.
86
111
  const meta = await this.api.getFileMeta(id);
87
- await this.requireExposed(meta.noteId);
112
+ let place = await this.requirePlace(meta.noteId);
88
113
  // A large file lives in object storage in chunks, far past what a tool
89
114
  // result can carry. Say so before fetching anything.
90
115
  if (meta.chunkSize)
@@ -92,9 +117,10 @@ class AttachmentsService {
92
117
  const file = await this.api.getFile(id);
93
118
  // Moved between the two calls: check where it is now, too.
94
119
  if (file.noteId !== meta.noteId)
95
- await this.requireExposed(file.noteId);
120
+ place = await this.requirePlace(file.noteId);
121
+ const fileKey = await this.readKey(key, file, place);
96
122
  try {
97
- const { filename, bytes } = await (0, virlow_crypto_1.decryptAttachment)(key, file);
123
+ const { filename, bytes } = await (0, virlow_crypto_1.decryptAttachment)(fileKey, file);
98
124
  return { info: { ...infoOf(file), filename: (0, attachment_format_js_1.sanitizeFilename)(filename) }, bytes };
99
125
  }
100
126
  catch (err) {
@@ -103,12 +129,30 @@ class AttachmentsService {
103
129
  }
104
130
  async rename(id, newFilename) {
105
131
  const key = this.requireSession();
132
+ const startEpoch = this.epoch;
106
133
  const file = await this.api.getFileMeta(id);
107
- await this.requireExposed(file.noteId);
134
+ const place = await this.requirePlace(file.noteId);
108
135
  const filename = (0, attachment_format_js_1.sanitizeFilename)(newFilename);
109
- const row = await this.api
110
- .patchFile(id, await (0, virlow_crypto_1.encryptAttachmentName)(key, filename))
111
- .catch((err) => {
136
+ const choice = await this.writeKey(key, place, file.keyVersion ?? null);
137
+ let patch = await (0, virlow_crypto_1.encryptAttachmentName)(choice.key, filename);
138
+ if (choice.keyVersion !== null) {
139
+ patch.keyVersion = choice.keyVersion;
140
+ // The row has one key version for its name and its content, and the
141
+ // API sets it from this write. A file still under an older version of
142
+ // the workspace key has its content (or, for a large file, the file key
143
+ // in its header) re-encrypted in the same request.
144
+ if ((file.keyVersion ?? null) !== choice.keyVersion) {
145
+ // From the account key into a workspace's is the owner's sharing run,
146
+ // never this (see NotesService.requireOwnedForRekey).
147
+ if ((file.keyVersion ?? null) === null && choice.ownerId !== this.vault.session.userId) {
148
+ throw new Error('This file is still under your own key but the server places it in a shared workspace you do not ' +
149
+ 'own, so it was not re-encrypted. Open it in the Virlow app.');
150
+ }
151
+ patch = { ...patch, encryptedData: await this.rekeyContent(key, id, place, choice.key) };
152
+ }
153
+ }
154
+ this.requireSameSession(startEpoch);
155
+ const row = await this.api.patchFile(id, patch).catch((err) => {
112
156
  throw renameFailure(err);
113
157
  });
114
158
  return { ...infoOf(row), filename };
@@ -118,8 +162,13 @@ class AttachmentsService {
118
162
  const file = await this.api.getFileMeta(id);
119
163
  // Both ends: out of a closed folder would reveal it, into one would hide
120
164
  // a file where the user cannot expect Claude to have put it.
121
- await this.requireExposed(file.noteId, toNoteId);
122
- return this.describe(key, await this.api.patchFile(id, { noteId: toNoteId }));
165
+ const [from, to] = await this.requireExposed(file.noteId, toNoteId);
166
+ // A file's key belongs to its workspace; the API refuses a move across.
167
+ if (from && to && from.workspaceId !== to.workspaceId) {
168
+ throw new Error('This file cannot be moved in or out of a shared workspace: that changes its encryption. ' +
169
+ 'Download it and attach it again instead.');
170
+ }
171
+ return this.describe(key, await this.api.patchFile(id, { noteId: toNoteId }), to);
123
172
  }
124
173
  async trash(id) {
125
174
  this.requireSession();
@@ -130,13 +179,14 @@ class AttachmentsService {
130
179
  async restore(id) {
131
180
  const key = this.requireSession();
132
181
  const file = await this.api.getFileMeta(id);
133
- await this.requireExposed(file.noteId);
134
- return this.describe(key, await this.api.restoreFile(id));
182
+ const place = await this.requirePlace(file.noteId);
183
+ return this.describe(key, await this.api.restoreFile(id), place);
135
184
  }
136
185
  /** Metadata with the filename decrypted. A legacy row's placeholder title
137
186
  * does not decrypt; it falls back to its plaintext name, then a generic one. */
138
- async describe(key, file) {
187
+ async describe(accountKey, file, place) {
139
188
  try {
189
+ const key = await this.readKey(accountKey, file, place);
140
190
  const filename = await (0, virlow_crypto_1.decryptTitleField)(key, file.encryptedTitle, file.iv);
141
191
  // Names reach the model; one with a newline in it must not add a line.
142
192
  return { ...infoOf(file), filename: (0, attachment_format_js_1.sanitizeFilename)(filename) };
@@ -145,16 +195,81 @@ class AttachmentsService {
145
195
  return infoOf(file);
146
196
  }
147
197
  }
198
+ async requirePlace(noteId) {
199
+ const [place] = await this.requireExposed(noteId);
200
+ if (!place)
201
+ throw new Error(`Note ${noteId} was not found.`);
202
+ return place;
203
+ }
204
+ /** Every note must be exposed. Returns where each one is, in order. */
148
205
  async requireExposed(...noteIds) {
149
206
  const [{ folders }, ...rows] = await Promise.all([
150
207
  this.api.listFolders(),
151
208
  ...noteIds.map((id) => this.api.getNote(id)),
152
209
  ]);
153
- const exposure = (0, exposure_js_1.computeExposure)(folders);
154
- for (const row of rows) {
210
+ // Same rule as NotesService: a folder in a workspace someone else owns is
211
+ // open only by this member's own switch (exposure.ts, isForeignFolder).
212
+ const exposure = (0, exposure_js_1.computeExposure)(folders, {
213
+ viewerId: this.vault.session.userId,
214
+ workspaceOwners: await this.keyring.workspaceOwners(folders),
215
+ });
216
+ const workspaces = new Map(folders.map((f) => [f.id, f.workspaceId ?? null]));
217
+ return rows.map((row) => {
155
218
  if (!exposure.allows(row.folderId)) {
156
219
  throw new Error(`Note ${row.id} is in a folder that is not exposed to MCP. Turn on "Expose to MCP" in that folder's settings in the Virlow app to access it here.`);
157
220
  }
221
+ const listed = row.folderId === null ? null : (workspaces.get(row.folderId) ?? null);
222
+ const sent = row.folder?.workspaceId;
223
+ // The two must agree, as in NotesService.workspaceOfRow.
224
+ if (sent !== undefined && row.folderId !== null && workspaces.has(row.folderId) && (sent ?? null) !== listed) {
225
+ throw new Error(`Note ${row.id}: the server gave two different workspaces for it, so it was not opened. ` +
226
+ 'Open it in the Virlow app.');
227
+ }
228
+ const workspaceId = sent ?? listed;
229
+ if ((0, exposure_js_1.isSharedNote)(row) && !workspaceId) {
230
+ throw (0, exposure_js_1.sharedNoteError)(row.id, new keyring_js_1.NoWorkspaceKeyError());
231
+ }
232
+ return { row, workspaceId };
233
+ });
234
+ }
235
+ /** The key a file is under: its own version (null = the account key) in its note's workspace. */
236
+ async readKey(accountKey, file, place) {
237
+ if (file.keyVersion === null || file.keyVersion === undefined)
238
+ return accountKey;
239
+ try {
240
+ return await this.keyring.forRead(accountKey, file.keyVersion, place?.workspaceId);
241
+ }
242
+ catch (err) {
243
+ if (err instanceof vault_js_1.LockedError)
244
+ throw err;
245
+ throw (0, exposure_js_1.sharedNoteError)(file.noteId, err);
246
+ }
247
+ }
248
+ /** The key a new file, name or content in this note goes under. Never the
249
+ * account key in a shared workspace; a key that does not open is refused. */
250
+ async writeKey(accountKey, place, atLeast) {
251
+ try {
252
+ return await this.keyring.forWrite(accountKey, place?.workspaceId ?? null, { atLeast });
253
+ }
254
+ catch (err) {
255
+ // Only fixed text reaches the caller (see describeKeyFailure).
256
+ if (err instanceof vault_js_1.LockedError || err instanceof keyring_js_1.WriteRefusedError || !place?.workspaceId)
257
+ throw err;
258
+ throw (0, exposure_js_1.sharedNoteError)(place.row.id, err);
259
+ }
260
+ }
261
+ /** A file's content (inline) or file key (large file header) under `toKey`. */
262
+ async rekeyContent(accountKey, id, place, toKey) {
263
+ const full = await this.api.getFile(id);
264
+ const fromKey = await this.readKey(accountKey, full, place);
265
+ try {
266
+ if ((0, virlow_crypto_1.isV3Attachment)(full.encryptedData))
267
+ return await (0, virlow_crypto_1.rewrapFileKey)(fromKey, toKey, full.encryptedData);
268
+ const { filename, bytes } = await (0, virlow_crypto_1.decryptAttachment)(fromKey, full);
269
+ return (await (0, virlow_crypto_1.encryptAttachment)(toKey, filename, bytes)).encryptedData;
270
+ }
271
+ catch (err) {
272
+ throw decryptFailure(err);
158
273
  }
159
274
  }
160
275
  requireSession() {
@@ -1,4 +1,15 @@
1
- import type { ApiFolder } from './api.js';
1
+ import type { ApiFolder, ApiNote } from './api.js';
2
+ /**
3
+ * A note in a shared workspace is encrypted under that workspace's key at the
4
+ * note's version, not the account key. The keyring (keyring.ts) opens that key
5
+ * from this account's grant; when it cannot (no grant yet, a grant that fails
6
+ * its checks, a granter whose keys changed) the note is skipped in every scan
7
+ * and refused by id, never opened or written with the account key.
8
+ */
9
+ export declare function isSharedNote(row: Pick<ApiNote, 'keyVersion'>): boolean;
10
+ /** The reason is one of a fixed set (keyring.ts, describeKeyFailure), never
11
+ * the cause's own message, which can be server text or decrypted bytes. */
12
+ export declare function sharedNoteError(id: string, cause?: unknown): Error;
2
13
  /**
3
14
  * Which folders the user has actually opened to AI tools.
4
15
  *
@@ -28,7 +39,29 @@ export interface ExposureOptions {
28
39
  * tools, so requiring the user to opt it in would be asking them to
29
40
  * authorise a folder they never made for a purpose it already has. */
30
41
  memoriesRootId?: string | null;
42
+ /**
43
+ * The unlocked account. When given, a folder that belongs to someone else's
44
+ * shared workspace (its row is not this account's, or its workspace's owner
45
+ * is someone else) is judged by `mcpEnabledForMember` alone: the row's own
46
+ * `mcpEnabled` is one value every editor of the workspace can set, so it is
47
+ * not this member's consent. See `isForeignFolder`.
48
+ */
49
+ viewerId?: string;
50
+ /** Workspace id to its owner, for the folders that carry a workspaceId.
51
+ * A workspace missing here counts as someone else's. */
52
+ workspaceOwners?: ReadonlyMap<string, string>;
31
53
  }
54
+ /**
55
+ * Whether a folder is in a shared workspace this account does not own.
56
+ *
57
+ * Such a folder is closed to this account's AI tools unless the server reports
58
+ * it open for this member (`mcpEnabledForMember`). The hosted connector's
59
+ * folder list carries that per-member value. The local CLI has no per-member
60
+ * switch yet, so there a workspace shared with this account stays closed to
61
+ * AI tools; the workspaces this account owns follow their own switch as
62
+ * before.
63
+ */
64
+ export declare function isForeignFolder(folder: Pick<ApiFolder, 'userId' | 'workspaceId'>, viewerId: string, workspaceOwners?: ReadonlyMap<string, string>): boolean;
32
65
  export declare function computeExposure(folders: ReadonlyArray<ApiFolder>, opts?: ExposureOptions): Exposure;
33
66
  /**
34
67
  * The sentence appended to any result that was filtered. Never omitted when
@@ -38,4 +71,5 @@ export declare function computeExposure(folders: ReadonlyArray<ApiFolder>, opts?
38
71
  export declare function filteredNotice(hidden: {
39
72
  inClosedFolders: number;
40
73
  unfiled: number;
74
+ shared?: number;
41
75
  }, exposure: Exposure): string;
@@ -1,10 +1,53 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isSharedNote = isSharedNote;
4
+ exports.sharedNoteError = sharedNoteError;
5
+ exports.isForeignFolder = isForeignFolder;
3
6
  exports.computeExposure = computeExposure;
4
7
  exports.filteredNotice = filteredNotice;
8
+ const keyring_js_1 = require("./keyring.js");
9
+ /**
10
+ * A note in a shared workspace is encrypted under that workspace's key at the
11
+ * note's version, not the account key. The keyring (keyring.ts) opens that key
12
+ * from this account's grant; when it cannot (no grant yet, a grant that fails
13
+ * its checks, a granter whose keys changed) the note is skipped in every scan
14
+ * and refused by id, never opened or written with the account key.
15
+ */
16
+ function isSharedNote(row) {
17
+ return row.keyVersion !== undefined && row.keyVersion !== null;
18
+ }
19
+ /** The reason is one of a fixed set (keyring.ts, describeKeyFailure), never
20
+ * the cause's own message, which can be server text or decrypted bytes. */
21
+ function sharedNoteError(id, cause) {
22
+ return new Error(`Note ${id} is in a shared workspace whose key could not be opened here (${(0, keyring_js_1.describeKeyFailure)(cause)}). ` +
23
+ 'Open it in the Virlow app instead.');
24
+ }
25
+ /**
26
+ * Whether a folder is in a shared workspace this account does not own.
27
+ *
28
+ * Such a folder is closed to this account's AI tools unless the server reports
29
+ * it open for this member (`mcpEnabledForMember`). The hosted connector's
30
+ * folder list carries that per-member value. The local CLI has no per-member
31
+ * switch yet, so there a workspace shared with this account stays closed to
32
+ * AI tools; the workspaces this account owns follow their own switch as
33
+ * before.
34
+ */
35
+ function isForeignFolder(folder, viewerId, workspaceOwners = new Map()) {
36
+ if (folder.userId !== undefined && folder.userId !== null && folder.userId !== viewerId)
37
+ return true;
38
+ if (folder.workspaceId)
39
+ return workspaceOwners.get(folder.workspaceId) !== viewerId;
40
+ return false;
41
+ }
5
42
  function computeExposure(folders, opts = {}) {
6
43
  const byId = new Map(folders.map((f) => [f.id, f]));
7
- const memoriesRootId = opts.memoriesRootId ?? folders.find((f) => f.kind === 'memories')?.id ?? null;
44
+ const viewerId = opts.viewerId;
45
+ const foreign = (folder) => viewerId !== undefined && isForeignFolder(folder, viewerId, opts.workspaceOwners);
46
+ // Memories are never shared: a "memories" folder in someone else's
47
+ // workspace is not this account's memories root, whatever its kind says.
48
+ const candidateRootId = opts.memoriesRootId ?? folders.find((f) => f.kind === 'memories' && !foreign(f))?.id ?? null;
49
+ const candidateRoot = candidateRootId === null ? undefined : byId.get(candidateRootId);
50
+ const memoriesRootId = candidateRoot !== undefined && foreign(candidateRoot) ? null : candidateRootId;
8
51
  const resolved = new Map();
9
52
  const isExposed = (folderId) => {
10
53
  const cached = resolved.get(folderId);
@@ -17,13 +60,22 @@ function computeExposure(folders, opts = {}) {
17
60
  const seen = new Set();
18
61
  let current = folderId;
19
62
  let exposed = false;
63
+ const start = byId.get(folderId);
64
+ const startForeign = start !== undefined && foreign(start);
20
65
  while (current !== null && !seen.has(current)) {
21
66
  seen.add(current);
22
67
  const folder = byId.get(current);
23
68
  if (folder === undefined)
24
69
  break;
70
+ // A shared workspace is a top-level tree, so a walk never legitimately
71
+ // crosses between this account's folders and someone else's. One that
72
+ // would (a parent the server put there) stops, closed, so neither side's
73
+ // switch can open the other.
74
+ if (foreign(folder) !== startForeign)
75
+ break;
25
76
  chain.push(folder.id);
26
- if (folder.mcpEnabled === true || folder.id === memoriesRootId) {
77
+ const switchedOn = startForeign ? folder.mcpEnabledForMember === true : folder.mcpEnabled === true;
78
+ if (switchedOn || folder.id === memoriesRootId) {
27
79
  exposed = true;
28
80
  break;
29
81
  }
@@ -73,5 +125,10 @@ function filteredNotice(hidden, exposure) {
73
125
  `${n === 1 ? 'it' : 'them'} into a folder that is exposed to MCP to include ` +
74
126
  `${n === 1 ? 'it' : 'them'}.`);
75
127
  }
128
+ if (hidden.shared && hidden.shared > 0) {
129
+ const n = hidden.shared;
130
+ parts.push(`${n} note${n === 1 ? '' : 's'} in shared workspaces left out: the workspace key did not ` +
131
+ 'open or did not check out here. Open the workspace in the Virlow app to see them.');
132
+ }
76
133
  return parts.length === 0 ? '' : `\n\n(${parts.join(' ')})`;
77
134
  }
@@ -1,9 +1,12 @@
1
1
  export { ApiError, VirlowApi } from './api.js';
2
- export type { ApiFile, ApiFileWithData, ApiFolder, ApiNote, FileUpload, AuthTokens, LoginResult, NoteWrite, RefreshBody } from './api.js';
2
+ export type { ApiFile, ApiFileWithData, ApiFolder, ApiIdentity, ApiNote, ApiWorkspaceDetail, ApiWorkspaceGrant, ApiWorkspaceSummary, FileUpload, AuthTokens, LoginResult, NoteWrite, RefreshBody, } from './api.js';
3
3
  export { LockedError, Vault } from './vault.js';
4
4
  export type { UnlockedSession } from './vault.js';
5
5
  export { NotesService, SEARCH_SCAN_CAP } from './notes.js';
6
6
  export { AttachmentsService } from './attachments.js';
7
+ export { KeyChangedError, NoWorkspaceKeyError, WorkspaceKeyring, memorySignerMemory } from './keyring.js';
8
+ export type { KeyChoice, SignerMemory } from './keyring.js';
9
+ export { fileSignerMemory } from './signer-file.js';
7
10
  export type { AttachmentInfo } from './attachments.js';
8
11
  export { MemoryStore, SYNC_PAGE_LIMIT } from './memories.js';
9
12
  export type { CachedMemory, SimilarMemory, SyncResult } from './memories.js';