@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.
- package/README.md +4 -0
- package/dist/api.d.ts +93 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +23 -1
- package/dist/api.js.map +1 -1
- package/dist/attachments.d.ts +20 -2
- package/dist/attachments.d.ts.map +1 -1
- package/dist/attachments.js +136 -21
- package/dist/attachments.js.map +1 -1
- package/dist/cjs/api.d.ts +93 -1
- package/dist/cjs/api.js +23 -1
- package/dist/cjs/attachments.d.ts +20 -2
- package/dist/cjs/attachments.js +134 -19
- package/dist/cjs/exposure.d.ts +35 -1
- package/dist/cjs/exposure.js +59 -2
- package/dist/cjs/index.d.ts +4 -1
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/keyring.d.ts +155 -0
- package/dist/cjs/keyring.js +319 -0
- package/dist/cjs/memories.d.ts +2 -2
- package/dist/cjs/notes.d.ts +53 -1
- package/dist/cjs/notes.js +304 -58
- package/dist/cjs/signer-file.d.ts +21 -0
- package/dist/cjs/signer-file.js +117 -0
- package/dist/exposure.d.ts +35 -1
- package/dist/exposure.d.ts.map +1 -1
- package/dist/exposure.js +56 -2
- package/dist/exposure.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/keyring.d.ts +156 -0
- package/dist/keyring.d.ts.map +1 -0
- package/dist/keyring.js +308 -0
- package/dist/keyring.js.map +1 -0
- package/dist/memories.d.ts +2 -2
- package/dist/notes.d.ts +53 -1
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +306 -60
- package/dist/notes.js.map +1 -1
- package/dist/signer-file.d.ts +22 -0
- package/dist/signer-file.d.ts.map +1 -0
- package/dist/signer-file.js +112 -0
- package/dist/signer-file.js.map +1 -0
- package/dist/tools.d.ts +10 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +6 -2
- package/dist/tools.js.map +1 -1
- package/package.json +3 -3
package/dist/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'
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/cjs/attachments.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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)(
|
|
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.
|
|
134
|
+
const place = await this.requirePlace(file.noteId);
|
|
108
135
|
const filename = (0, attachment_format_js_1.sanitizeFilename)(newFilename);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
154
|
-
|
|
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() {
|
package/dist/cjs/exposure.d.ts
CHANGED
|
@@ -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;
|
package/dist/cjs/exposure.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -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';
|