@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/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.TodoService = exports.htmlToText = exports.extractLinkIds = void 0;
|
|
3
|
+
exports.reorderProps = exports.removeProp = exports.newPropId = exports.encodeSchema = exports.encodeAttrs = exports.emptySchema = exports.emptyAttrs = exports.defineProp = exports.decodeSchema = exports.decodeAttrs = exports.TAGS_PROP = exports.ATTRS_MAX_BYTES = exports.memoriesAllowed = exports.MEMORIES_DISABLED_MESSAGE = exports.EmbeddingCache = exports.filteredNotice = exports.computeExposure = exports.resolveMemoryTree = exports.ensureNamespace = exports.MEMORIES_ROOT_NAME = exports.serializeRegistry = exports.registryKeyFor = exports.parseRegistry = exports.mergeRegistries = exports.emptyRegistry = exports.describedEntities = exports.describeInRegistry = exports.canWriteRegistry = exports.ENTITY_REGISTRY_VERSION = exports.ENTITY_REGISTRY_TITLE = exports.ENTITY_REGISTRY_NOTE_TYPE = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.fileSignerMemory = exports.memorySignerMemory = exports.WorkspaceKeyring = exports.NoWorkspaceKeyError = exports.KeyChangedError = exports.AttachmentsService = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
|
|
4
|
+
exports.TodoService = exports.htmlToText = exports.extractLinkIds = exports.buildTitleMatcher = exports.withTags = exports.updateProp = exports.tagsFromAttrs = exports.sortedProps = void 0;
|
|
5
5
|
var api_js_1 = require("./api.js");
|
|
6
6
|
Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return api_js_1.ApiError; } });
|
|
7
7
|
Object.defineProperty(exports, "VirlowApi", { enumerable: true, get: function () { return api_js_1.VirlowApi; } });
|
|
@@ -13,6 +13,13 @@ Object.defineProperty(exports, "NotesService", { enumerable: true, get: function
|
|
|
13
13
|
Object.defineProperty(exports, "SEARCH_SCAN_CAP", { enumerable: true, get: function () { return notes_js_1.SEARCH_SCAN_CAP; } });
|
|
14
14
|
var attachments_js_1 = require("./attachments.js");
|
|
15
15
|
Object.defineProperty(exports, "AttachmentsService", { enumerable: true, get: function () { return attachments_js_1.AttachmentsService; } });
|
|
16
|
+
var keyring_js_1 = require("./keyring.js");
|
|
17
|
+
Object.defineProperty(exports, "KeyChangedError", { enumerable: true, get: function () { return keyring_js_1.KeyChangedError; } });
|
|
18
|
+
Object.defineProperty(exports, "NoWorkspaceKeyError", { enumerable: true, get: function () { return keyring_js_1.NoWorkspaceKeyError; } });
|
|
19
|
+
Object.defineProperty(exports, "WorkspaceKeyring", { enumerable: true, get: function () { return keyring_js_1.WorkspaceKeyring; } });
|
|
20
|
+
Object.defineProperty(exports, "memorySignerMemory", { enumerable: true, get: function () { return keyring_js_1.memorySignerMemory; } });
|
|
21
|
+
var signer_file_js_1 = require("./signer-file.js");
|
|
22
|
+
Object.defineProperty(exports, "fileSignerMemory", { enumerable: true, get: function () { return signer_file_js_1.fileSignerMemory; } });
|
|
16
23
|
var memories_js_1 = require("./memories.js");
|
|
17
24
|
Object.defineProperty(exports, "MemoryStore", { enumerable: true, get: function () { return memories_js_1.MemoryStore; } });
|
|
18
25
|
Object.defineProperty(exports, "SYNC_PAGE_LIMIT", { enumerable: true, get: function () { return memories_js_1.SYNC_PAGE_LIMIT; } });
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ApiFolder, ApiWorkspaceSummary, VirlowApi } from './api.js';
|
|
2
|
+
import { type Vault } from './vault.js';
|
|
3
|
+
/**
|
|
4
|
+
* Which key a row in a shared workspace is encrypted under, and the key
|
|
5
|
+
* itself: the connector's side of the web app's keyring
|
|
6
|
+
* (src/lib/sharing/keyring.ts in the app, KeyBook.kt on Android;
|
|
7
|
+
* docs/superpowers/specs/2026-10-14-shared-workspaces-design.md).
|
|
8
|
+
*
|
|
9
|
+
* A personal row (no key version) uses the account key. A row in a shared
|
|
10
|
+
* workspace uses that workspace's key at the row's version, opened from this
|
|
11
|
+
* account's grant. Before a key is used:
|
|
12
|
+
*
|
|
13
|
+
* - the grant must open with this account's identity (whose private halves
|
|
14
|
+
* are sealed under the account key), bound to this workspace, version and
|
|
15
|
+
* person;
|
|
16
|
+
* - the granter must be allowed to grant: the workspace's owner, or an owner
|
|
17
|
+
* or editor in its member list; a grant this account made itself must carry
|
|
18
|
+
* this account's own signing key;
|
|
19
|
+
* - the signature must check out against the signing key this connector
|
|
20
|
+
* first saw for the granter. A key that changed since is refused, because
|
|
21
|
+
* that is what a server swapping keys would look like.
|
|
22
|
+
*
|
|
23
|
+
* Writes never go under a key version older than one this account has
|
|
24
|
+
* already seen for the workspace, so a server cannot roll a workspace back to
|
|
25
|
+
* a key a removed member still holds.
|
|
26
|
+
*
|
|
27
|
+
* Everything opened is held for one unlock and dropped when the vault locks,
|
|
28
|
+
* with the same epoch guard NotesService uses, so a fetch still in flight at
|
|
29
|
+
* the lock never seeds the next session. The remembered signing keys are
|
|
30
|
+
* public and are kept across locks (they are what makes trust-on-first-use
|
|
31
|
+
* mean anything); a host can persist them with `signers`.
|
|
32
|
+
*
|
|
33
|
+
* Where the pins live: the local CLI keeps them in a file (signer-file.ts),
|
|
34
|
+
* so they survive restarts. The hosted connector keeps them in memory, per
|
|
35
|
+
* process: a restart or another machine starts trusting afresh. That host
|
|
36
|
+
* already holds the account key in memory, so its pins defend against
|
|
37
|
+
* nothing it could not do anyway; they are kept for parity, not as a
|
|
38
|
+
* boundary.
|
|
39
|
+
*/
|
|
40
|
+
/** Signing keys seen for each person, first one wins: the web's `virlow.knownSigners`. */
|
|
41
|
+
export interface SignerMemory {
|
|
42
|
+
get(userId: string): string | null;
|
|
43
|
+
/**
|
|
44
|
+
* Remember `sign` for `userId` unless a key is already remembered. Must be
|
|
45
|
+
* a compare-and-set: throws KeyChangedError when a different key got there
|
|
46
|
+
* first, never overwrites it.
|
|
47
|
+
*/
|
|
48
|
+
remember(userId: string, sign: string): void;
|
|
49
|
+
}
|
|
50
|
+
/** Remembered for as long as the process lives. The default, and what the
|
|
51
|
+
* hosted connector uses: pins there are per process (see above). */
|
|
52
|
+
export declare function memorySignerMemory(): SignerMemory;
|
|
53
|
+
/**
|
|
54
|
+
* Why a workspace key did not open, as one of a fixed set. The text an AI
|
|
55
|
+
* tool sees comes only from here, never from an error's own message: those
|
|
56
|
+
* can carry server-chosen text, or (a JSON parse error) decrypted bytes.
|
|
57
|
+
*/
|
|
58
|
+
export type KeyFailureReason = 'no-grant' | 'key-changed' | 'signature-rejected' | 'not-authorized' | 'locked';
|
|
59
|
+
export declare class SharedKeyError extends Error {
|
|
60
|
+
readonly reason: KeyFailureReason;
|
|
61
|
+
constructor(reason: KeyFailureReason);
|
|
62
|
+
}
|
|
63
|
+
/** Someone's signing key is not the one first seen for them. */
|
|
64
|
+
export declare class KeyChangedError extends SharedKeyError {
|
|
65
|
+
readonly userId: string;
|
|
66
|
+
constructor(userId: string);
|
|
67
|
+
}
|
|
68
|
+
/** No key for this workspace and version opens here. */
|
|
69
|
+
export declare class NoWorkspaceKeyError extends SharedKeyError {
|
|
70
|
+
constructor();
|
|
71
|
+
}
|
|
72
|
+
/** The grant came from someone the workspace does not let grant. */
|
|
73
|
+
export declare class GranterNotAuthorizedError extends SharedKeyError {
|
|
74
|
+
constructor();
|
|
75
|
+
}
|
|
76
|
+
/** A write refused for a reason this module states itself (a viewer, a
|
|
77
|
+
* rollback). Its message is fixed text, safe to show as it is. */
|
|
78
|
+
export declare class WriteRefusedError extends Error {
|
|
79
|
+
constructor(message: string);
|
|
80
|
+
}
|
|
81
|
+
export declare function keyFailureReason(err: unknown): KeyFailureReason;
|
|
82
|
+
/** The fixed sentence for a failure; see KeyFailureReason. */
|
|
83
|
+
export declare function describeKeyFailure(err: unknown): string;
|
|
84
|
+
export interface KeyChoice {
|
|
85
|
+
key: CryptoKey;
|
|
86
|
+
/** The version sent with the ciphertext; null = the account key. */
|
|
87
|
+
keyVersion: number | null;
|
|
88
|
+
/** The workspace's owner; null for the account key. */
|
|
89
|
+
ownerId: string | null;
|
|
90
|
+
}
|
|
91
|
+
export declare class WorkspaceKeyring {
|
|
92
|
+
private readonly api;
|
|
93
|
+
private readonly vault;
|
|
94
|
+
/** Where the remembered signing keys live, per account. In memory by default. */
|
|
95
|
+
private readonly signers;
|
|
96
|
+
private epoch;
|
|
97
|
+
private identityPending;
|
|
98
|
+
private readonly details;
|
|
99
|
+
private readonly keys;
|
|
100
|
+
private readonly signerMemories;
|
|
101
|
+
/** `userId:workspaceId` to the highest key version this account has seen
|
|
102
|
+
* granted there. Kept across locks: versions only ever go up. */
|
|
103
|
+
private readonly highestSeen;
|
|
104
|
+
constructor(api: VirlowApi, vault: Vault,
|
|
105
|
+
/** Where the remembered signing keys live, per account. In memory by default. */
|
|
106
|
+
signers?: (userId: string) => SignerMemory);
|
|
107
|
+
private handleLock;
|
|
108
|
+
/** The key to read a row with, from the version it was written under. */
|
|
109
|
+
forRead(accountKey: CryptoKey, keyVersion: number | null | undefined, workspaceId: string | null | undefined): Promise<CryptoKey>;
|
|
110
|
+
/**
|
|
111
|
+
* The key to write into a workspace with: its current version, as the API
|
|
112
|
+
* requires of every new ciphertext there. Never the account key: a null
|
|
113
|
+
* workspace is the only way to get that, and it is the caller's to ask.
|
|
114
|
+
* Asks the server for the current version every time, since a removal
|
|
115
|
+
* rotates the key and the connector hears no change signal.
|
|
116
|
+
*
|
|
117
|
+
* Refuses a version older than `atLeast` (the version the row being
|
|
118
|
+
* rewritten is already under) or than any this account has been granted
|
|
119
|
+
* for the workspace: writing under an older key would hand the new text to
|
|
120
|
+
* whoever a rotation meant to shut out.
|
|
121
|
+
*/
|
|
122
|
+
forWrite(accountKey: CryptoKey, workspaceId: string | null | undefined, opts?: {
|
|
123
|
+
atLeast?: number | null;
|
|
124
|
+
}): Promise<KeyChoice>;
|
|
125
|
+
/** This account's role and the current key version of one workspace. Not cached. */
|
|
126
|
+
summary(workspaceId: string): Promise<ApiWorkspaceSummary>;
|
|
127
|
+
/**
|
|
128
|
+
* Who owns each workspace these folders belong to, for the exposure rule
|
|
129
|
+
* (exposure.ts, isForeignFolder). No request when none of them is shared.
|
|
130
|
+
*/
|
|
131
|
+
workspaceOwners(folders: ReadonlyArray<ApiFolder>): Promise<Map<string, string>>;
|
|
132
|
+
/** A workspace key version, opened and checked. Kept until the vault locks. */
|
|
133
|
+
workspaceKey(workspaceId: string, keyVersion: number): Promise<CryptoKey>;
|
|
134
|
+
private versionKey;
|
|
135
|
+
private highestVersion;
|
|
136
|
+
private noteVersion;
|
|
137
|
+
private openKey;
|
|
138
|
+
/**
|
|
139
|
+
* The granter must be allowed to grant (the owner, or an owner or editor in
|
|
140
|
+
* the member list), and a grant this account made must carry its own
|
|
141
|
+
* signing key: one the server substituted would otherwise be pinned for
|
|
142
|
+
* this account itself on first use.
|
|
143
|
+
*/
|
|
144
|
+
private requireAuthorizedGranter;
|
|
145
|
+
private detail;
|
|
146
|
+
/**
|
|
147
|
+
* This account's identity, opened with the unlocked account key. Never made
|
|
148
|
+
* here: an account without one has been sealed no workspace keys, and the
|
|
149
|
+
* app makes it on the next unlock there. Any failure to open it is reported
|
|
150
|
+
* as "no grant", without its cause: a parse error would quote the bytes it
|
|
151
|
+
* choked on, and those are decrypted.
|
|
152
|
+
*/
|
|
153
|
+
private identity;
|
|
154
|
+
private signerMemory;
|
|
155
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WorkspaceKeyring = exports.WriteRefusedError = exports.GranterNotAuthorizedError = exports.NoWorkspaceKeyError = exports.KeyChangedError = exports.SharedKeyError = void 0;
|
|
4
|
+
exports.memorySignerMemory = memorySignerMemory;
|
|
5
|
+
exports.keyFailureReason = keyFailureReason;
|
|
6
|
+
exports.describeKeyFailure = describeKeyFailure;
|
|
7
|
+
const virlow_crypto_1 = require("@batalabs/virlow-crypto");
|
|
8
|
+
const vault_js_1 = require("./vault.js");
|
|
9
|
+
/** Remembered for as long as the process lives. The default, and what the
|
|
10
|
+
* hosted connector uses: pins there are per process (see above). */
|
|
11
|
+
function memorySignerMemory() {
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
return {
|
|
14
|
+
get: (id) => seen.get(id) ?? null,
|
|
15
|
+
remember: (id, sign) => {
|
|
16
|
+
const known = seen.get(id);
|
|
17
|
+
if (known !== undefined && known !== sign)
|
|
18
|
+
throw new KeyChangedError(id);
|
|
19
|
+
seen.set(id, sign);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const REASON_TEXT = {
|
|
24
|
+
'no-grant': 'no usable key for it was shared with you',
|
|
25
|
+
'key-changed': 'the keys of the person who shared it changed since this connector first saw them',
|
|
26
|
+
'signature-rejected': 'its key did not carry a valid signature from the person who shared it',
|
|
27
|
+
'not-authorized': 'its key was shared by someone not allowed to share it',
|
|
28
|
+
locked: 'the vault locked',
|
|
29
|
+
};
|
|
30
|
+
class SharedKeyError extends Error {
|
|
31
|
+
reason;
|
|
32
|
+
constructor(reason) {
|
|
33
|
+
super(REASON_TEXT[reason]);
|
|
34
|
+
this.reason = reason;
|
|
35
|
+
this.name = 'SharedKeyError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.SharedKeyError = SharedKeyError;
|
|
39
|
+
/** Someone's signing key is not the one first seen for them. */
|
|
40
|
+
class KeyChangedError extends SharedKeyError {
|
|
41
|
+
userId;
|
|
42
|
+
constructor(userId) {
|
|
43
|
+
super('key-changed');
|
|
44
|
+
this.name = 'KeyChangedError';
|
|
45
|
+
this.userId = userId;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
exports.KeyChangedError = KeyChangedError;
|
|
49
|
+
/** No key for this workspace and version opens here. */
|
|
50
|
+
class NoWorkspaceKeyError extends SharedKeyError {
|
|
51
|
+
constructor() {
|
|
52
|
+
super('no-grant');
|
|
53
|
+
this.name = 'NoWorkspaceKeyError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.NoWorkspaceKeyError = NoWorkspaceKeyError;
|
|
57
|
+
/** The grant came from someone the workspace does not let grant. */
|
|
58
|
+
class GranterNotAuthorizedError extends SharedKeyError {
|
|
59
|
+
constructor() {
|
|
60
|
+
super('not-authorized');
|
|
61
|
+
this.name = 'GranterNotAuthorizedError';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.GranterNotAuthorizedError = GranterNotAuthorizedError;
|
|
65
|
+
/** A write refused for a reason this module states itself (a viewer, a
|
|
66
|
+
* rollback). Its message is fixed text, safe to show as it is. */
|
|
67
|
+
class WriteRefusedError extends Error {
|
|
68
|
+
constructor(message) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'WriteRefusedError';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
exports.WriteRefusedError = WriteRefusedError;
|
|
74
|
+
function keyFailureReason(err) {
|
|
75
|
+
if (err instanceof vault_js_1.LockedError)
|
|
76
|
+
return 'locked';
|
|
77
|
+
if (err instanceof SharedKeyError)
|
|
78
|
+
return err.reason;
|
|
79
|
+
if (err instanceof virlow_crypto_1.GrantRejectedError)
|
|
80
|
+
return 'signature-rejected';
|
|
81
|
+
return 'no-grant';
|
|
82
|
+
}
|
|
83
|
+
/** The fixed sentence for a failure; see KeyFailureReason. */
|
|
84
|
+
function describeKeyFailure(err) {
|
|
85
|
+
return REASON_TEXT[keyFailureReason(err)];
|
|
86
|
+
}
|
|
87
|
+
function lockedError() {
|
|
88
|
+
return new vault_js_1.LockedError('Vault is locked. Call this server\'s unlock tool and ask the user to finish unlocking their Virlow vault.');
|
|
89
|
+
}
|
|
90
|
+
const SELF_CHECK = new TextEncoder().encode('virlow-self-grant-check-v1');
|
|
91
|
+
class WorkspaceKeyring {
|
|
92
|
+
api;
|
|
93
|
+
vault;
|
|
94
|
+
signers;
|
|
95
|
+
epoch = 0;
|
|
96
|
+
identityPending = null;
|
|
97
|
+
details = new Map();
|
|
98
|
+
keys = new Map();
|
|
99
|
+
signerMemories = new Map();
|
|
100
|
+
/** `userId:workspaceId` to the highest key version this account has seen
|
|
101
|
+
* granted there. Kept across locks: versions only ever go up. */
|
|
102
|
+
highestSeen = new Map();
|
|
103
|
+
constructor(api, vault,
|
|
104
|
+
/** Where the remembered signing keys live, per account. In memory by default. */
|
|
105
|
+
signers = () => memorySignerMemory()) {
|
|
106
|
+
this.api = api;
|
|
107
|
+
this.vault = vault;
|
|
108
|
+
this.signers = signers;
|
|
109
|
+
// The setter adds a listener (see Vault.onLock); it never replaces one.
|
|
110
|
+
this.vault.onLock = () => this.handleLock();
|
|
111
|
+
}
|
|
112
|
+
handleLock() {
|
|
113
|
+
this.identityPending = null;
|
|
114
|
+
this.details.clear();
|
|
115
|
+
this.keys.clear();
|
|
116
|
+
this.epoch++;
|
|
117
|
+
}
|
|
118
|
+
/** The key to read a row with, from the version it was written under. */
|
|
119
|
+
async forRead(accountKey, keyVersion, workspaceId) {
|
|
120
|
+
if (keyVersion === null || keyVersion === undefined)
|
|
121
|
+
return accountKey;
|
|
122
|
+
if (!workspaceId)
|
|
123
|
+
throw new NoWorkspaceKeyError();
|
|
124
|
+
return this.workspaceKey(workspaceId, keyVersion);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The key to write into a workspace with: its current version, as the API
|
|
128
|
+
* requires of every new ciphertext there. Never the account key: a null
|
|
129
|
+
* workspace is the only way to get that, and it is the caller's to ask.
|
|
130
|
+
* Asks the server for the current version every time, since a removal
|
|
131
|
+
* rotates the key and the connector hears no change signal.
|
|
132
|
+
*
|
|
133
|
+
* Refuses a version older than `atLeast` (the version the row being
|
|
134
|
+
* rewritten is already under) or than any this account has been granted
|
|
135
|
+
* for the workspace: writing under an older key would hand the new text to
|
|
136
|
+
* whoever a rotation meant to shut out.
|
|
137
|
+
*/
|
|
138
|
+
async forWrite(accountKey, workspaceId, opts = {}) {
|
|
139
|
+
if (!workspaceId)
|
|
140
|
+
return { key: accountKey, keyVersion: null, ownerId: null };
|
|
141
|
+
const startEpoch = this.epoch;
|
|
142
|
+
const summary = await this.summary(workspaceId);
|
|
143
|
+
if (this.epoch !== startEpoch)
|
|
144
|
+
throw lockedError();
|
|
145
|
+
if (summary.role === 'viewer') {
|
|
146
|
+
throw new WriteRefusedError('You can view this shared workspace but not change it. Ask its owner to make you an editor.');
|
|
147
|
+
}
|
|
148
|
+
const detail = await this.detail(workspaceId);
|
|
149
|
+
if (this.epoch !== startEpoch)
|
|
150
|
+
throw lockedError();
|
|
151
|
+
const granted = Math.max(0, ...detail.grants.map((g) => g.keyVersion));
|
|
152
|
+
this.noteVersion(workspaceId, granted);
|
|
153
|
+
const floor = Math.max(this.highestVersion(workspaceId), opts.atLeast ?? 0);
|
|
154
|
+
if (summary.keyVersion < floor) {
|
|
155
|
+
throw new WriteRefusedError('The server offered an older key for this shared workspace than this account already holds, ' +
|
|
156
|
+
'so nothing was written. Open the workspace in the Virlow app.');
|
|
157
|
+
}
|
|
158
|
+
const key = await this.workspaceKey(workspaceId, summary.keyVersion);
|
|
159
|
+
return { key, keyVersion: summary.keyVersion, ownerId: summary.ownerId };
|
|
160
|
+
}
|
|
161
|
+
/** This account's role and the current key version of one workspace. Not cached. */
|
|
162
|
+
async summary(workspaceId) {
|
|
163
|
+
const summaries = await this.api.listWorkspaces();
|
|
164
|
+
const summary = summaries.find((s) => s.id === workspaceId);
|
|
165
|
+
if (!summary)
|
|
166
|
+
throw new NoWorkspaceKeyError();
|
|
167
|
+
return summary;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Who owns each workspace these folders belong to, for the exposure rule
|
|
171
|
+
* (exposure.ts, isForeignFolder). No request when none of them is shared.
|
|
172
|
+
*/
|
|
173
|
+
async workspaceOwners(folders) {
|
|
174
|
+
if (!folders.some((f) => f.workspaceId))
|
|
175
|
+
return new Map();
|
|
176
|
+
const summaries = await this.api.listWorkspaces();
|
|
177
|
+
return new Map(summaries.map((s) => [s.id, s.ownerId]));
|
|
178
|
+
}
|
|
179
|
+
/** A workspace key version, opened and checked. Kept until the vault locks. */
|
|
180
|
+
workspaceKey(workspaceId, keyVersion) {
|
|
181
|
+
const id = `${workspaceId}:${keyVersion}`;
|
|
182
|
+
let pending = this.keys.get(id);
|
|
183
|
+
if (!pending) {
|
|
184
|
+
const startEpoch = this.epoch;
|
|
185
|
+
pending = this.openKey(workspaceId, keyVersion, startEpoch);
|
|
186
|
+
// A failure is retried on the next call, not remembered: the grant may
|
|
187
|
+
// simply not have been sealed yet.
|
|
188
|
+
pending.catch(() => {
|
|
189
|
+
if (this.keys.get(id) === pending)
|
|
190
|
+
this.keys.delete(id);
|
|
191
|
+
});
|
|
192
|
+
this.keys.set(id, pending);
|
|
193
|
+
}
|
|
194
|
+
return pending;
|
|
195
|
+
}
|
|
196
|
+
versionKey(workspaceId) {
|
|
197
|
+
return `${this.vault.session.userId}:${workspaceId}`;
|
|
198
|
+
}
|
|
199
|
+
highestVersion(workspaceId) {
|
|
200
|
+
return this.highestSeen.get(this.versionKey(workspaceId)) ?? 0;
|
|
201
|
+
}
|
|
202
|
+
noteVersion(workspaceId, keyVersion) {
|
|
203
|
+
const id = this.versionKey(workspaceId);
|
|
204
|
+
if (keyVersion > (this.highestSeen.get(id) ?? 0))
|
|
205
|
+
this.highestSeen.set(id, keyVersion);
|
|
206
|
+
}
|
|
207
|
+
async openKey(workspaceId, keyVersion, startEpoch) {
|
|
208
|
+
const { userId } = this.vault.session;
|
|
209
|
+
let detail = await this.detail(workspaceId);
|
|
210
|
+
let grant = detail.grants.find((g) => g.keyVersion === keyVersion);
|
|
211
|
+
if (!grant) {
|
|
212
|
+
// A rotation since the detail was fetched: ask once more.
|
|
213
|
+
this.details.delete(workspaceId);
|
|
214
|
+
detail = await this.detail(workspaceId);
|
|
215
|
+
grant = detail.grants.find((g) => g.keyVersion === keyVersion);
|
|
216
|
+
}
|
|
217
|
+
if (!grant || !grant.granterSign)
|
|
218
|
+
throw new NoWorkspaceKeyError();
|
|
219
|
+
const granterSign = grant.granterSign;
|
|
220
|
+
const identity = await this.identity();
|
|
221
|
+
if (this.epoch !== startEpoch)
|
|
222
|
+
throw lockedError();
|
|
223
|
+
await this.requireAuthorizedGranter(detail, grant, granterSign, identity, userId);
|
|
224
|
+
// Checked against the signing key first seen for the granter, and
|
|
225
|
+
// remembered only once the grant opens and verifies under it, so a grant
|
|
226
|
+
// that fails never pins the key it came with.
|
|
227
|
+
const signers = this.signerMemory(userId);
|
|
228
|
+
const known = signers.get(grant.granterId);
|
|
229
|
+
if (known !== null && known !== granterSign)
|
|
230
|
+
throw new KeyChangedError(grant.granterId);
|
|
231
|
+
const { key } = await (0, virlow_crypto_1.openGrant)(identity, { sealed: grant.sealed, signature: grant.signature }, { workspaceId, keyVersion, recipientUserId: userId }, granterSign);
|
|
232
|
+
if (this.epoch !== startEpoch)
|
|
233
|
+
throw lockedError();
|
|
234
|
+
// Read again: another open (or another process sharing the pin file) may
|
|
235
|
+
// have pinned a different key for this granter while this one was
|
|
236
|
+
// verifying. remember() is a compare-and-set as well.
|
|
237
|
+
const now = signers.get(grant.granterId);
|
|
238
|
+
if (now !== null && now !== granterSign)
|
|
239
|
+
throw new KeyChangedError(grant.granterId);
|
|
240
|
+
if (now === null)
|
|
241
|
+
signers.remember(grant.granterId, granterSign);
|
|
242
|
+
this.noteVersion(workspaceId, keyVersion);
|
|
243
|
+
return key;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The granter must be allowed to grant (the owner, or an owner or editor in
|
|
247
|
+
* the member list), and a grant this account made must carry its own
|
|
248
|
+
* signing key: one the server substituted would otherwise be pinned for
|
|
249
|
+
* this account itself on first use.
|
|
250
|
+
*/
|
|
251
|
+
async requireAuthorizedGranter(detail, grant, granterSign, identity, userId) {
|
|
252
|
+
const member = (detail.members ?? []).find((m) => m.userId === grant.granterId);
|
|
253
|
+
const allowed = grant.granterId === detail.workspace.ownerId || member?.role === 'owner' || member?.role === 'editor';
|
|
254
|
+
if (!allowed)
|
|
255
|
+
throw new GranterNotAuthorizedError();
|
|
256
|
+
if (grant.granterId !== userId)
|
|
257
|
+
return;
|
|
258
|
+
if (granterSign !== identity.public.sign)
|
|
259
|
+
throw new KeyChangedError(userId);
|
|
260
|
+
// The public half the server stores is not bound to the sealed private
|
|
261
|
+
// one, so prove they are a pair: sign with the private key, verify with
|
|
262
|
+
// the key the grant names.
|
|
263
|
+
const signature = await crypto.subtle.sign('Ed25519', identity.signPrivate, SELF_CHECK);
|
|
264
|
+
const publicKey = await crypto.subtle.importKey('raw', (0, virlow_crypto_1.base64ToBytes)(granterSign), { name: 'Ed25519' }, false, ['verify']);
|
|
265
|
+
if (!(await crypto.subtle.verify('Ed25519', publicKey, signature, SELF_CHECK))) {
|
|
266
|
+
throw new KeyChangedError(userId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
detail(workspaceId) {
|
|
270
|
+
let pending = this.details.get(workspaceId);
|
|
271
|
+
if (!pending) {
|
|
272
|
+
pending = this.api.getWorkspace(workspaceId);
|
|
273
|
+
pending.catch(() => {
|
|
274
|
+
if (this.details.get(workspaceId) === pending)
|
|
275
|
+
this.details.delete(workspaceId);
|
|
276
|
+
});
|
|
277
|
+
this.details.set(workspaceId, pending);
|
|
278
|
+
}
|
|
279
|
+
return pending;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* This account's identity, opened with the unlocked account key. Never made
|
|
283
|
+
* here: an account without one has been sealed no workspace keys, and the
|
|
284
|
+
* app makes it on the next unlock there. Any failure to open it is reported
|
|
285
|
+
* as "no grant", without its cause: a parse error would quote the bytes it
|
|
286
|
+
* choked on, and those are decrypted.
|
|
287
|
+
*/
|
|
288
|
+
identity() {
|
|
289
|
+
if (!this.identityPending) {
|
|
290
|
+
const accountKey = this.vault.session.key;
|
|
291
|
+
const pending = (async () => {
|
|
292
|
+
const stored = await this.api.getIdentity();
|
|
293
|
+
if (!stored)
|
|
294
|
+
throw new NoWorkspaceKeyError();
|
|
295
|
+
try {
|
|
296
|
+
return await (0, virlow_crypto_1.openIdentity)(accountKey, stored.sealedPrivate, { box: stored.box, sign: stored.sign });
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new NoWorkspaceKeyError();
|
|
300
|
+
}
|
|
301
|
+
})();
|
|
302
|
+
pending.catch(() => {
|
|
303
|
+
if (this.identityPending === pending)
|
|
304
|
+
this.identityPending = null;
|
|
305
|
+
});
|
|
306
|
+
this.identityPending = pending;
|
|
307
|
+
}
|
|
308
|
+
return this.identityPending;
|
|
309
|
+
}
|
|
310
|
+
signerMemory(userId) {
|
|
311
|
+
let memory = this.signerMemories.get(userId);
|
|
312
|
+
if (!memory) {
|
|
313
|
+
memory = this.signers(userId);
|
|
314
|
+
this.signerMemories.set(userId, memory);
|
|
315
|
+
}
|
|
316
|
+
return memory;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
exports.WorkspaceKeyring = WorkspaceKeyring;
|
package/dist/cjs/memories.d.ts
CHANGED
|
@@ -158,7 +158,7 @@ export declare class MemoryStore {
|
|
|
158
158
|
* the facts behind them. Null when no memory names it. */
|
|
159
159
|
recallEntity(name: string, namespace?: string): Promise<{
|
|
160
160
|
name: string;
|
|
161
|
-
kind: "
|
|
161
|
+
kind: "place" | "project" | "person" | "organization" | "tool" | "event" | "topic" | "product" | "media" | "pet" | "group" | "goal" | "other";
|
|
162
162
|
/** Whether the kind is stored (set by a person or an AI) or guessed. */
|
|
163
163
|
kindStored: boolean;
|
|
164
164
|
aliases: string[];
|
|
@@ -193,7 +193,7 @@ export declare class MemoryStore {
|
|
|
193
193
|
note?: string | undefined;
|
|
194
194
|
name: string;
|
|
195
195
|
facts: number;
|
|
196
|
-
kind: "
|
|
196
|
+
kind: "place" | "project" | "person" | "organization" | "tool" | "event" | "topic" | "product" | "media" | "pet" | "group" | "goal" | "other";
|
|
197
197
|
}[];
|
|
198
198
|
facts: CachedMemory[];
|
|
199
199
|
}>;
|
package/dist/cjs/notes.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Exposure } from './exposure.js';
|
|
|
2
2
|
import { type HomeWidget, type NoteAttrsV1, type PropDefinition, type PropType, type PropValue } from './attrs.js';
|
|
3
3
|
import type { VirlowApi } from './api.js';
|
|
4
4
|
import { type Vault } from './vault.js';
|
|
5
|
+
import { WorkspaceKeyring } from './keyring.js';
|
|
5
6
|
/** Hard ceiling on how many notes searchNotes() will decrypt+scan in a
|
|
6
7
|
* single call. Keeps a single search bounded even for very large accounts;
|
|
7
8
|
* `truncated` tells the caller when the account holds more than this. Also
|
|
@@ -61,7 +62,10 @@ export declare class NotesService {
|
|
|
61
62
|
* the same pre-edit row and the second write would silently clobber the
|
|
62
63
|
* first's change. Mirrors MemoryStore's enqueue()/mutationChain pattern. */
|
|
63
64
|
private mutationChain;
|
|
64
|
-
|
|
65
|
+
/** Opens shared workspaces' keys for this unlock. Public so the attachment
|
|
66
|
+
* service built next to this one shares it rather than opening them twice. */
|
|
67
|
+
readonly keyring: WorkspaceKeyring;
|
|
68
|
+
constructor(api: VirlowApi, vault: Vault, keyring?: WorkspaceKeyring);
|
|
65
69
|
private handleLock;
|
|
66
70
|
/** Drops the paged row list after anything that could change which rows
|
|
67
71
|
* exist, where they live, or what they are called. Called after the write
|
|
@@ -93,6 +97,7 @@ export declare class NotesService {
|
|
|
93
97
|
withheld: {
|
|
94
98
|
inClosedFolders: number;
|
|
95
99
|
unfiled: number;
|
|
100
|
+
shared?: number;
|
|
96
101
|
};
|
|
97
102
|
exposure: Exposure;
|
|
98
103
|
}>;
|
|
@@ -135,6 +140,7 @@ export declare class NotesService {
|
|
|
135
140
|
};
|
|
136
141
|
withheld: {
|
|
137
142
|
inClosedFolders: number;
|
|
143
|
+
shared?: number;
|
|
138
144
|
};
|
|
139
145
|
sortedBy: PropDefinition | null;
|
|
140
146
|
}>;
|
|
@@ -255,6 +261,8 @@ export declare class NotesService {
|
|
|
255
261
|
* flip the switch in the app at any moment and the next tool call must
|
|
256
262
|
* respect it. */
|
|
257
263
|
private exposure;
|
|
264
|
+
/** Who is unlocked: the account whose consent the exposure rule asks for. */
|
|
265
|
+
private viewerId;
|
|
258
266
|
/** The same computation for a caller that already has the folder list and
|
|
259
267
|
* needs a folder out of it as well as the exposure (the property tools do:
|
|
260
268
|
* a folder's schema and its exposure come from the same row). */
|
|
@@ -266,6 +274,50 @@ export declare class NotesService {
|
|
|
266
274
|
createFolder(name: string, parentId?: string | null): Promise<{
|
|
267
275
|
id: string;
|
|
268
276
|
}>;
|
|
277
|
+
/** A folder's name as the person sees it: a shared folder's is under the
|
|
278
|
+
* workspace key (its `name` column only says "[ENCRYPTED]"). One that does
|
|
279
|
+
* not open is called what the app calls it. */
|
|
280
|
+
private folderName;
|
|
281
|
+
/** The shared workspace a note is in: from its own folder field when the
|
|
282
|
+
* server sent one, else from the folder list. Null for a personal note. */
|
|
283
|
+
private workspaceOfRow;
|
|
284
|
+
/** The key a note's ciphertext is under: the account key for a personal
|
|
285
|
+
* note, the workspace key at the note's version for a shared one. Throws
|
|
286
|
+
* when a shared note's key does not open; never falls back. */
|
|
287
|
+
private keyForRowOrThrow;
|
|
288
|
+
/** The same, as null instead of a throw, for scans that skip what they
|
|
289
|
+
* cannot open. A lock is not a note that cannot be opened: it propagates,
|
|
290
|
+
* so a scan cut short by one fails rather than reporting skipped notes. */
|
|
291
|
+
private keyForRowOrNull;
|
|
292
|
+
/** The key a folder's name and schema are under. */
|
|
293
|
+
private keyForFolder;
|
|
294
|
+
/**
|
|
295
|
+
* The ciphertext half of a note write: attrs, plus title and content when
|
|
296
|
+
* they changed, all under the key the note's place uses now (a shared
|
|
297
|
+
* workspace's current version, with that version sent along) and never the
|
|
298
|
+
* account key for a note in a shared workspace.
|
|
299
|
+
*
|
|
300
|
+
* The row has one key version for all of its ciphertext, and the API sets it
|
|
301
|
+
* from the write. So when the version changes (a rotation since the note was
|
|
302
|
+
* last saved, or a note the owner's sharing run has not reached yet), the
|
|
303
|
+
* unchanged title and content are re-encrypted as well; sending only the
|
|
304
|
+
* attrs would leave them under a key the row no longer names.
|
|
305
|
+
*/
|
|
306
|
+
private sealNoteWrite;
|
|
307
|
+
/**
|
|
308
|
+
* Moving ciphertext from the account key to a workspace key is what the
|
|
309
|
+
* owner's sharing run does. For anyone else, a server that says a personal
|
|
310
|
+
* row is now in a workspace could otherwise have it re-encrypted under a key
|
|
311
|
+
* that server handed out, so it is refused unless this account owns the
|
|
312
|
+
* workspace.
|
|
313
|
+
*/
|
|
314
|
+
private requireOwnedForRekey;
|
|
315
|
+
/**
|
|
316
|
+
* Writes a folder's schema under the key its place uses now. In a shared
|
|
317
|
+
* workspace the folder's name shares the row's key version, so a folder
|
|
318
|
+
* still under an older version has its name re-encrypted in the same write.
|
|
319
|
+
*/
|
|
320
|
+
private writeSchema;
|
|
269
321
|
/** Decrypt (with per-unlock caching) or pass through a legacy plaintext
|
|
270
322
|
* row. Legacy rows (no encryptedTitle) carry their real content directly
|
|
271
323
|
* in the plaintext title/content columns. */
|