@noy-db/hub 0.1.0-pre.10
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/LICENSE +21 -0
- package/README.md +197 -0
- package/dist/aggregate/index.cjs +476 -0
- package/dist/aggregate/index.cjs.map +1 -0
- package/dist/aggregate/index.d.cts +38 -0
- package/dist/aggregate/index.d.ts +38 -0
- package/dist/aggregate/index.js +53 -0
- package/dist/aggregate/index.js.map +1 -0
- package/dist/blobs/index.cjs +1480 -0
- package/dist/blobs/index.cjs.map +1 -0
- package/dist/blobs/index.d.cts +45 -0
- package/dist/blobs/index.d.ts +45 -0
- package/dist/blobs/index.js +48 -0
- package/dist/blobs/index.js.map +1 -0
- package/dist/bundle/index.cjs +496 -0
- package/dist/bundle/index.cjs.map +1 -0
- package/dist/bundle/index.d.cts +7 -0
- package/dist/bundle/index.d.ts +7 -0
- package/dist/bundle/index.js +51 -0
- package/dist/bundle/index.js.map +1 -0
- package/dist/chunk-2QR2PQTT.js +217 -0
- package/dist/chunk-2QR2PQTT.js.map +1 -0
- package/dist/chunk-72UIIX3E.js +1109 -0
- package/dist/chunk-72UIIX3E.js.map +1 -0
- package/dist/chunk-A4NFZKRW.js +722 -0
- package/dist/chunk-A4NFZKRW.js.map +1 -0
- package/dist/chunk-AOYCZP2H.js +793 -0
- package/dist/chunk-AOYCZP2H.js.map +1 -0
- package/dist/chunk-CIMZBAZB.js +72 -0
- package/dist/chunk-CIMZBAZB.js.map +1 -0
- package/dist/chunk-E3AGCGJ4.js +160 -0
- package/dist/chunk-E3AGCGJ4.js.map +1 -0
- package/dist/chunk-EKX3YVCI.js +97 -0
- package/dist/chunk-EKX3YVCI.js.map +1 -0
- package/dist/chunk-EMIGCR7X.js +39 -0
- package/dist/chunk-EMIGCR7X.js.map +1 -0
- package/dist/chunk-EMMRIE3C.js +72 -0
- package/dist/chunk-EMMRIE3C.js.map +1 -0
- package/dist/chunk-EUNIORPU.js +680 -0
- package/dist/chunk-EUNIORPU.js.map +1 -0
- package/dist/chunk-FZU343FL.js +32 -0
- package/dist/chunk-FZU343FL.js.map +1 -0
- package/dist/chunk-GHGXG53C.js +795 -0
- package/dist/chunk-GHGXG53C.js.map +1 -0
- package/dist/chunk-GKA4BGJN.js +79 -0
- package/dist/chunk-GKA4BGJN.js.map +1 -0
- package/dist/chunk-HG2OWBLX.js +430 -0
- package/dist/chunk-HG2OWBLX.js.map +1 -0
- package/dist/chunk-IGAROPKM.js +34 -0
- package/dist/chunk-IGAROPKM.js.map +1 -0
- package/dist/chunk-J66GRPNH.js +111 -0
- package/dist/chunk-J66GRPNH.js.map +1 -0
- package/dist/chunk-LVMMDXFT.js +275 -0
- package/dist/chunk-LVMMDXFT.js.map +1 -0
- package/dist/chunk-M5INGEFC.js +84 -0
- package/dist/chunk-M5INGEFC.js.map +1 -0
- package/dist/chunk-NBYQNDXA.js +557 -0
- package/dist/chunk-NBYQNDXA.js.map +1 -0
- package/dist/chunk-NPC4LFV5.js +132 -0
- package/dist/chunk-NPC4LFV5.js.map +1 -0
- package/dist/chunk-NSWHB5VQ.js +1285 -0
- package/dist/chunk-NSWHB5VQ.js.map +1 -0
- package/dist/chunk-OLM4LA6K.js +392 -0
- package/dist/chunk-OLM4LA6K.js.map +1 -0
- package/dist/chunk-UAFBZWFB.js +155 -0
- package/dist/chunk-UAFBZWFB.js.map +1 -0
- package/dist/chunk-UF3BUNQZ.js +1 -0
- package/dist/chunk-UF3BUNQZ.js.map +1 -0
- package/dist/chunk-UMMAVAYW.js +17 -0
- package/dist/chunk-UMMAVAYW.js.map +1 -0
- package/dist/chunk-UPY7WLBH.js +381 -0
- package/dist/chunk-UPY7WLBH.js.map +1 -0
- package/dist/chunk-W63BWEJH.js +311 -0
- package/dist/chunk-W63BWEJH.js.map +1 -0
- package/dist/chunk-WIGI5OJK.js +90 -0
- package/dist/chunk-WIGI5OJK.js.map +1 -0
- package/dist/chunk-XNL2TKKR.js +490 -0
- package/dist/chunk-XNL2TKKR.js.map +1 -0
- package/dist/chunk-XWNUJPIS.js +367 -0
- package/dist/chunk-XWNUJPIS.js.map +1 -0
- package/dist/chunk-YWKJZZGV.js +715 -0
- package/dist/chunk-YWKJZZGV.js.map +1 -0
- package/dist/consent/index.cjs +204 -0
- package/dist/consent/index.cjs.map +1 -0
- package/dist/consent/index.d.cts +24 -0
- package/dist/consent/index.d.ts +24 -0
- package/dist/consent/index.js +23 -0
- package/dist/consent/index.js.map +1 -0
- package/dist/crdt/index.cjs +152 -0
- package/dist/crdt/index.cjs.map +1 -0
- package/dist/crdt/index.d.cts +30 -0
- package/dist/crdt/index.d.ts +30 -0
- package/dist/crdt/index.js +24 -0
- package/dist/crdt/index.js.map +1 -0
- package/dist/crypto-6PNIHP7W.js +44 -0
- package/dist/crypto-6PNIHP7W.js.map +1 -0
- package/dist/delegation-WVIVMF73.js +17 -0
- package/dist/delegation-WVIVMF73.js.map +1 -0
- package/dist/dev-unlock-D4xB0_gs.d.cts +263 -0
- package/dist/dev-unlock-Dz8GEbd3.d.ts +263 -0
- package/dist/hash--EflSV65.d.cts +63 -0
- package/dist/hash-CRdXYnv3.d.ts +63 -0
- package/dist/history/index.cjs +1215 -0
- package/dist/history/index.cjs.map +1 -0
- package/dist/history/index.d.cts +62 -0
- package/dist/history/index.d.ts +62 -0
- package/dist/history/index.js +79 -0
- package/dist/history/index.js.map +1 -0
- package/dist/i18n/index.cjs +840 -0
- package/dist/i18n/index.cjs.map +1 -0
- package/dist/i18n/index.d.cts +38 -0
- package/dist/i18n/index.d.ts +38 -0
- package/dist/i18n/index.js +68 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index-CD1VnONm.d.cts +415 -0
- package/dist/index-CLRxPs-W.d.cts +1960 -0
- package/dist/index-CUi9wfss.d.ts +415 -0
- package/dist/index-DtV93TMP.d.ts +1960 -0
- package/dist/index.cjs +17387 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +565 -0
- package/dist/index.d.ts +565 -0
- package/dist/index.js +7525 -0
- package/dist/index.js.map +1 -0
- package/dist/indexing/index.cjs +736 -0
- package/dist/indexing/index.cjs.map +1 -0
- package/dist/indexing/index.d.cts +36 -0
- package/dist/indexing/index.d.ts +36 -0
- package/dist/indexing/index.js +77 -0
- package/dist/indexing/index.js.map +1 -0
- package/dist/lazy-builder-BwEoBQZ9.d.ts +304 -0
- package/dist/lazy-builder-CZVLKh0Z.d.cts +304 -0
- package/dist/ledger-HBBH2NPZ.js +33 -0
- package/dist/ledger-HBBH2NPZ.js.map +1 -0
- package/dist/mime-magic-CBBSOkjm.d.cts +50 -0
- package/dist/mime-magic-CBBSOkjm.d.ts +50 -0
- package/dist/periods/index.cjs +1035 -0
- package/dist/periods/index.cjs.map +1 -0
- package/dist/periods/index.d.cts +21 -0
- package/dist/periods/index.d.ts +21 -0
- package/dist/periods/index.js +25 -0
- package/dist/periods/index.js.map +1 -0
- package/dist/predicate-SBHmi6D0.d.cts +161 -0
- package/dist/predicate-SBHmi6D0.d.ts +161 -0
- package/dist/public-envelope-TLQA6REO.js +31 -0
- package/dist/public-envelope-TLQA6REO.js.map +1 -0
- package/dist/query/index.cjs +1999 -0
- package/dist/query/index.cjs.map +1 -0
- package/dist/query/index.d.cts +3 -0
- package/dist/query/index.d.ts +3 -0
- package/dist/query/index.js +73 -0
- package/dist/query/index.js.map +1 -0
- package/dist/session/index.cjs +495 -0
- package/dist/session/index.cjs.map +1 -0
- package/dist/session/index.d.cts +45 -0
- package/dist/session/index.d.ts +45 -0
- package/dist/session/index.js +51 -0
- package/dist/session/index.js.map +1 -0
- package/dist/shadow/index.cjs +133 -0
- package/dist/shadow/index.cjs.map +1 -0
- package/dist/shadow/index.d.cts +16 -0
- package/dist/shadow/index.d.ts +16 -0
- package/dist/shadow/index.js +20 -0
- package/dist/shadow/index.js.map +1 -0
- package/dist/store/index.cjs +1083 -0
- package/dist/store/index.cjs.map +1 -0
- package/dist/store/index.d.cts +491 -0
- package/dist/store/index.d.ts +491 -0
- package/dist/store/index.js +37 -0
- package/dist/store/index.js.map +1 -0
- package/dist/strategy-BSxFXGzb.d.cts +110 -0
- package/dist/strategy-BSxFXGzb.d.ts +110 -0
- package/dist/strategy-D-SrOLCl.d.cts +548 -0
- package/dist/strategy-D-SrOLCl.d.ts +548 -0
- package/dist/sync/index.cjs +1062 -0
- package/dist/sync/index.cjs.map +1 -0
- package/dist/sync/index.d.cts +42 -0
- package/dist/sync/index.d.ts +42 -0
- package/dist/sync/index.js +28 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/team/index.cjs +2606 -0
- package/dist/team/index.cjs.map +1 -0
- package/dist/team/index.d.cts +117 -0
- package/dist/team/index.d.ts +117 -0
- package/dist/team/index.js +106 -0
- package/dist/team/index.js.map +1 -0
- package/dist/tx/index.cjs +212 -0
- package/dist/tx/index.cjs.map +1 -0
- package/dist/tx/index.d.cts +20 -0
- package/dist/tx/index.d.ts +20 -0
- package/dist/tx/index.js +20 -0
- package/dist/tx/index.js.map +1 -0
- package/dist/types-DSFLtbKg.d.ts +9702 -0
- package/dist/types-zwwMOqkg.d.cts +9702 -0
- package/dist/ulid-COREQ2RQ.js +9 -0
- package/dist/ulid-COREQ2RQ.js.map +1 -0
- package/dist/util/index.cjs +230 -0
- package/dist/util/index.cjs.map +1 -0
- package/dist/util/index.d.cts +77 -0
- package/dist/util/index.d.ts +77 -0
- package/dist/util/index.js +190 -0
- package/dist/util/index.js.map +1 -0
- package/package.json +244 -0
|
@@ -0,0 +1,2606 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/team/index.ts
|
|
21
|
+
var team_exports = {};
|
|
22
|
+
__export(team_exports, {
|
|
23
|
+
PresenceHandle: () => PresenceHandle,
|
|
24
|
+
SYNC_CREDENTIALS_COLLECTION: () => SYNC_CREDENTIALS_COLLECTION,
|
|
25
|
+
SyncEngine: () => SyncEngine,
|
|
26
|
+
SyncTransaction: () => SyncTransaction,
|
|
27
|
+
buildRecipientKeyringFile: () => buildRecipientKeyringFile,
|
|
28
|
+
burnPaperRecoveryEntry: () => burnPaperRecoveryEntry,
|
|
29
|
+
changeSecret: () => changeSecret,
|
|
30
|
+
createOwnerKeyring: () => createOwnerKeyring,
|
|
31
|
+
credentialStatus: () => credentialStatus,
|
|
32
|
+
deleteCredential: () => deleteCredential,
|
|
33
|
+
deriveMagicLinkContentKey: () => deriveMagicLinkContentKey,
|
|
34
|
+
enrollAuthenticator: () => enrollAuthenticator,
|
|
35
|
+
ensureCollectionDEK: () => ensureCollectionDEK,
|
|
36
|
+
evaluateExportCapability: () => evaluateExportCapability,
|
|
37
|
+
evaluateImportCapability: () => evaluateImportCapability,
|
|
38
|
+
findAuthenticator: () => findAuthenticator,
|
|
39
|
+
getCredential: () => getCredential,
|
|
40
|
+
grant: () => grant,
|
|
41
|
+
hasExportCapability: () => hasExportCapability,
|
|
42
|
+
hasImportCapability: () => hasImportCapability,
|
|
43
|
+
isMagicLinkGrantExpired: () => isMagicLinkGrantExpired,
|
|
44
|
+
listCredentials: () => listCredentials,
|
|
45
|
+
listMagicLinkGrants: () => listMagicLinkGrants,
|
|
46
|
+
listUsers: () => listUsers,
|
|
47
|
+
listUsersWithEnvelopes: () => listUsersWithEnvelopes,
|
|
48
|
+
loadKeyring: () => loadKeyring,
|
|
49
|
+
loadPaperRecoveryEntries: () => loadPaperRecoveryEntries,
|
|
50
|
+
magicLinkGrantRecordId: () => magicLinkGrantRecordId,
|
|
51
|
+
mintPaperRecoveryEntry: () => mintPaperRecoveryEntry,
|
|
52
|
+
mintWrappedDeksBlob: () => mintWrappedDeksBlob,
|
|
53
|
+
persistKeyring: () => persistKeyring,
|
|
54
|
+
putCredential: () => putCredential,
|
|
55
|
+
readMagicLinkGrantRecord: () => readMagicLinkGrantRecord,
|
|
56
|
+
recoverPassphrase: () => recoverPassphrase,
|
|
57
|
+
recoverUser: () => recoverUser,
|
|
58
|
+
removeAuthenticator: () => removeAuthenticator,
|
|
59
|
+
revoke: () => revoke,
|
|
60
|
+
revokeMagicLinkGrant: () => revokeMagicLinkGrant,
|
|
61
|
+
rotatePassphrase: () => rotatePassphrase,
|
|
62
|
+
savePaperRecoveryEntries: () => savePaperRecoveryEntries,
|
|
63
|
+
unwrapDeksFromBlob: () => unwrapDeksFromBlob,
|
|
64
|
+
unwrapDeksFromPaperEntry: () => unwrapDeksFromPaperEntry,
|
|
65
|
+
unwrapMagicLinkGrant: () => unwrapMagicLinkGrant,
|
|
66
|
+
updateAuthenticator: () => updateAuthenticator,
|
|
67
|
+
updateKeyringIdentity: () => updateKeyringIdentity,
|
|
68
|
+
writeMagicLinkGrant: () => writeMagicLinkGrant
|
|
69
|
+
});
|
|
70
|
+
module.exports = __toCommonJS(team_exports);
|
|
71
|
+
|
|
72
|
+
// src/types.ts
|
|
73
|
+
var NOYDB_FORMAT_VERSION = 1;
|
|
74
|
+
var NOYDB_KEYRING_VERSION = 1;
|
|
75
|
+
var NOYDB_SYNC_VERSION = 1;
|
|
76
|
+
|
|
77
|
+
// src/errors.ts
|
|
78
|
+
var NoydbError = class extends Error {
|
|
79
|
+
/** Machine-readable error code. Stable across library versions. */
|
|
80
|
+
code;
|
|
81
|
+
constructor(code, message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "NoydbError";
|
|
84
|
+
this.code = code;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var DecryptionError = class extends NoydbError {
|
|
88
|
+
constructor(message = "Decryption failed") {
|
|
89
|
+
super("DECRYPTION_FAILED", message);
|
|
90
|
+
this.name = "DecryptionError";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
var TamperedError = class extends NoydbError {
|
|
94
|
+
constructor(message = "Data integrity check failed \u2014 record may have been tampered with") {
|
|
95
|
+
super("TAMPERED", message);
|
|
96
|
+
this.name = "TamperedError";
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var InvalidKeyError = class extends NoydbError {
|
|
100
|
+
constructor(message = "Invalid key \u2014 wrong passphrase or corrupted keyring") {
|
|
101
|
+
super("INVALID_KEY", message);
|
|
102
|
+
this.name = "InvalidKeyError";
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
var KeyringCorruptError = class extends NoydbError {
|
|
106
|
+
failedCollections;
|
|
107
|
+
intactCount;
|
|
108
|
+
constructor(opts) {
|
|
109
|
+
super(
|
|
110
|
+
"KEYRING_CORRUPT",
|
|
111
|
+
opts.message ?? `Keyring has ${opts.failedCollections.length} corrupted wrapped DEK(s) (${opts.failedCollections.join(", ")}); ${opts.intactCount} other DEK(s) unwrapped successfully \u2014 the passphrase is correct, the entries are damaged. Do NOT use onInvalidKey: 'reset' here \u2014 that would destroy the intact DEKs.`
|
|
112
|
+
);
|
|
113
|
+
this.name = "KeyringCorruptError";
|
|
114
|
+
this.failedCollections = opts.failedCollections;
|
|
115
|
+
this.intactCount = opts.intactCount;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
var NoAccessError = class extends NoydbError {
|
|
119
|
+
constructor(message = "No access \u2014 user does not have a key for this collection") {
|
|
120
|
+
super("NO_ACCESS", message);
|
|
121
|
+
this.name = "NoAccessError";
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
var PermissionDeniedError = class extends NoydbError {
|
|
125
|
+
constructor(message = "Permission denied \u2014 insufficient role for this operation") {
|
|
126
|
+
super("PERMISSION_DENIED", message);
|
|
127
|
+
this.name = "PermissionDeniedError";
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
var KeyringExpiredError = class extends NoydbError {
|
|
131
|
+
userId;
|
|
132
|
+
expiresAt;
|
|
133
|
+
constructor(opts) {
|
|
134
|
+
super(
|
|
135
|
+
"KEYRING_EXPIRED",
|
|
136
|
+
`Keyring "${opts.userId}" expired at ${opts.expiresAt}. The slot refuses to unlock past its expiry timestamp.`
|
|
137
|
+
);
|
|
138
|
+
this.name = "KeyringExpiredError";
|
|
139
|
+
this.userId = opts.userId;
|
|
140
|
+
this.expiresAt = opts.expiresAt;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
var PrivilegeEscalationError = class extends NoydbError {
|
|
144
|
+
offendingCollection;
|
|
145
|
+
constructor(offendingCollection, message) {
|
|
146
|
+
super(
|
|
147
|
+
"PRIVILEGE_ESCALATION",
|
|
148
|
+
message ?? `Privilege escalation: grantor has no DEK for collection "${offendingCollection}" and cannot grant access to it.`
|
|
149
|
+
);
|
|
150
|
+
this.name = "PrivilegeEscalationError";
|
|
151
|
+
this.offendingCollection = offendingCollection;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
var DelegationTargetMissingError = class extends NoydbError {
|
|
155
|
+
toUser;
|
|
156
|
+
constructor(toUser) {
|
|
157
|
+
super(
|
|
158
|
+
"DELEGATION_TARGET_MISSING",
|
|
159
|
+
`Delegation target user "${toUser}" has no keyring in this vault`
|
|
160
|
+
);
|
|
161
|
+
this.name = "DelegationTargetMissingError";
|
|
162
|
+
this.toUser = toUser;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var ConflictError = class extends NoydbError {
|
|
166
|
+
/** The actual stored version at the time of conflict. */
|
|
167
|
+
version;
|
|
168
|
+
constructor(version, message = "Version conflict") {
|
|
169
|
+
super("CONFLICT", message);
|
|
170
|
+
this.name = "ConflictError";
|
|
171
|
+
this.version = version;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var ValidationError = class extends NoydbError {
|
|
175
|
+
constructor(message = "Validation error") {
|
|
176
|
+
super("VALIDATION_ERROR", message);
|
|
177
|
+
this.name = "ValidationError";
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// src/crypto.ts
|
|
182
|
+
var PBKDF2_ITERATIONS = 6e5;
|
|
183
|
+
var SALT_BYTES = 32;
|
|
184
|
+
var IV_BYTES = 12;
|
|
185
|
+
var KEY_BITS = 256;
|
|
186
|
+
var subtle = globalThis.crypto.subtle;
|
|
187
|
+
async function deriveKey(passphrase, salt) {
|
|
188
|
+
const keyMaterial = await subtle.importKey(
|
|
189
|
+
"raw",
|
|
190
|
+
new TextEncoder().encode(passphrase),
|
|
191
|
+
"PBKDF2",
|
|
192
|
+
false,
|
|
193
|
+
["deriveKey"]
|
|
194
|
+
);
|
|
195
|
+
return subtle.deriveKey(
|
|
196
|
+
{
|
|
197
|
+
name: "PBKDF2",
|
|
198
|
+
salt,
|
|
199
|
+
iterations: PBKDF2_ITERATIONS,
|
|
200
|
+
hash: "SHA-256"
|
|
201
|
+
},
|
|
202
|
+
keyMaterial,
|
|
203
|
+
{ name: "AES-KW", length: KEY_BITS },
|
|
204
|
+
false,
|
|
205
|
+
["wrapKey", "unwrapKey"]
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
async function generateDEK() {
|
|
209
|
+
return subtle.generateKey(
|
|
210
|
+
{ name: "AES-GCM", length: KEY_BITS },
|
|
211
|
+
true,
|
|
212
|
+
// extractable — needed for AES-KW wrapping
|
|
213
|
+
["encrypt", "decrypt"]
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
async function wrapKey(dek, kek) {
|
|
217
|
+
const wrapped = await subtle.wrapKey("raw", dek, kek, "AES-KW");
|
|
218
|
+
return bufferToBase64(wrapped);
|
|
219
|
+
}
|
|
220
|
+
async function unwrapKey(wrappedBase64, kek) {
|
|
221
|
+
try {
|
|
222
|
+
return await subtle.unwrapKey(
|
|
223
|
+
"raw",
|
|
224
|
+
base64ToBuffer(wrappedBase64),
|
|
225
|
+
kek,
|
|
226
|
+
"AES-KW",
|
|
227
|
+
{ name: "AES-GCM", length: KEY_BITS },
|
|
228
|
+
true,
|
|
229
|
+
["encrypt", "decrypt"]
|
|
230
|
+
);
|
|
231
|
+
} catch {
|
|
232
|
+
throw new InvalidKeyError();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function encrypt(plaintext, dek) {
|
|
236
|
+
const iv = generateIV();
|
|
237
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
238
|
+
const ciphertext = await subtle.encrypt(
|
|
239
|
+
{ name: "AES-GCM", iv },
|
|
240
|
+
dek,
|
|
241
|
+
encoded
|
|
242
|
+
);
|
|
243
|
+
return {
|
|
244
|
+
iv: bufferToBase64(iv),
|
|
245
|
+
data: bufferToBase64(ciphertext)
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async function decrypt(ivBase64, dataBase64, dek) {
|
|
249
|
+
const iv = base64ToBuffer(ivBase64);
|
|
250
|
+
const ciphertext = base64ToBuffer(dataBase64);
|
|
251
|
+
try {
|
|
252
|
+
const plaintext = await subtle.decrypt(
|
|
253
|
+
{ name: "AES-GCM", iv },
|
|
254
|
+
dek,
|
|
255
|
+
ciphertext
|
|
256
|
+
);
|
|
257
|
+
return new TextDecoder().decode(plaintext);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err instanceof Error && err.name === "OperationError") {
|
|
260
|
+
throw new TamperedError();
|
|
261
|
+
}
|
|
262
|
+
throw new DecryptionError(
|
|
263
|
+
err instanceof Error ? err.message : "Decryption failed"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function derivePresenceKey(dek, collectionName) {
|
|
268
|
+
const rawDek = await subtle.exportKey("raw", dek);
|
|
269
|
+
const hkdfKey = await subtle.importKey(
|
|
270
|
+
"raw",
|
|
271
|
+
rawDek,
|
|
272
|
+
"HKDF",
|
|
273
|
+
false,
|
|
274
|
+
["deriveBits"]
|
|
275
|
+
);
|
|
276
|
+
const salt = new TextEncoder().encode("noydb-presence");
|
|
277
|
+
const info = new TextEncoder().encode(collectionName);
|
|
278
|
+
const bits = await subtle.deriveBits(
|
|
279
|
+
{ name: "HKDF", hash: "SHA-256", salt, info },
|
|
280
|
+
hkdfKey,
|
|
281
|
+
KEY_BITS
|
|
282
|
+
);
|
|
283
|
+
return subtle.importKey(
|
|
284
|
+
"raw",
|
|
285
|
+
bits,
|
|
286
|
+
{ name: "AES-GCM", length: KEY_BITS },
|
|
287
|
+
false,
|
|
288
|
+
["encrypt", "decrypt"]
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
function generateIV() {
|
|
292
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
293
|
+
}
|
|
294
|
+
function generateSalt() {
|
|
295
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
296
|
+
}
|
|
297
|
+
function bufferToBase64(buffer) {
|
|
298
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
299
|
+
let binary = "";
|
|
300
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
301
|
+
binary += String.fromCharCode(bytes[i]);
|
|
302
|
+
}
|
|
303
|
+
return btoa(binary);
|
|
304
|
+
}
|
|
305
|
+
function base64ToBuffer(base64) {
|
|
306
|
+
const binary = atob(base64);
|
|
307
|
+
const bytes = new Uint8Array(binary.length);
|
|
308
|
+
for (let i = 0; i < binary.length; i++) {
|
|
309
|
+
bytes[i] = binary.charCodeAt(i);
|
|
310
|
+
}
|
|
311
|
+
return bytes;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/validation.ts
|
|
315
|
+
var WeakPassphraseError = class extends NoydbError {
|
|
316
|
+
reason;
|
|
317
|
+
suggestion;
|
|
318
|
+
constructor(reason, suggestion) {
|
|
319
|
+
super("WEAK_PASSPHRASE", `Weak passphrase (${reason}). ${suggestion}`);
|
|
320
|
+
this.name = "WeakPassphraseError";
|
|
321
|
+
this.reason = reason;
|
|
322
|
+
this.suggestion = suggestion;
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
var DEFAULT_MIN_WORDS = 6;
|
|
326
|
+
var DEFAULT_MIN_WORD_LENGTH = 3;
|
|
327
|
+
var SUGGESTIONS = {
|
|
328
|
+
empty: "Provide a phrase of at least 6 lowercase words separated by single spaces.",
|
|
329
|
+
"invalid-chars": "Use only lowercase letters [a-z] and single spaces. No punctuation, symbols, digits, or uppercase.",
|
|
330
|
+
"leading-or-trailing-space": "Trim leading and trailing spaces.",
|
|
331
|
+
"double-space": "Use exactly one space between words.",
|
|
332
|
+
"too-few-words": 'Use at least 6 words by default (8 under strict policy). Example: "correct horse battery staple printer toaster".',
|
|
333
|
+
"word-too-short": 'Each word must be at least 3 characters. Drop short fillers like "a", "is", "of".',
|
|
334
|
+
"repeated-adjacent": "Avoid repeating the same word twice in a row."
|
|
335
|
+
};
|
|
336
|
+
function validatePassphrase(s, opts) {
|
|
337
|
+
if (opts?.customValidator) {
|
|
338
|
+
return opts.customValidator(s);
|
|
339
|
+
}
|
|
340
|
+
const minWords = opts?.minWords ?? DEFAULT_MIN_WORDS;
|
|
341
|
+
const minWordLength = opts?.minWordLength ?? DEFAULT_MIN_WORD_LENGTH;
|
|
342
|
+
const rejectRepeated = opts?.rejectRepeatedAdjacent ?? true;
|
|
343
|
+
if (s.length === 0) {
|
|
344
|
+
return { ok: false, reason: "empty" };
|
|
345
|
+
}
|
|
346
|
+
if (s !== s.trim()) {
|
|
347
|
+
return { ok: false, reason: "leading-or-trailing-space" };
|
|
348
|
+
}
|
|
349
|
+
if (s.includes(" ")) {
|
|
350
|
+
return { ok: false, reason: "double-space" };
|
|
351
|
+
}
|
|
352
|
+
const charPattern = opts?.pattern ?? /^[a-z]+( [a-z]+)*$/;
|
|
353
|
+
if (!charPattern.test(s)) {
|
|
354
|
+
return { ok: false, reason: "invalid-chars" };
|
|
355
|
+
}
|
|
356
|
+
const words = s.split(" ");
|
|
357
|
+
if (words.length < minWords) {
|
|
358
|
+
return { ok: false, reason: "too-few-words", minimum: minWords, got: words.length };
|
|
359
|
+
}
|
|
360
|
+
for (const w of words) {
|
|
361
|
+
if (w.length < minWordLength) {
|
|
362
|
+
return { ok: false, reason: "word-too-short", minimum: minWordLength, got: w.length };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (rejectRepeated) {
|
|
366
|
+
for (let i = 1; i < words.length; i++) {
|
|
367
|
+
if (words[i] === words[i - 1]) {
|
|
368
|
+
return { ok: false, reason: "repeated-adjacent" };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, words: words.length };
|
|
373
|
+
}
|
|
374
|
+
function assertStrongPassphrase(s, opts) {
|
|
375
|
+
if (opts?.allowWeakPassphrase) return;
|
|
376
|
+
const result = validatePassphrase(s, opts);
|
|
377
|
+
if (result.ok) return;
|
|
378
|
+
throw new WeakPassphraseError(result.reason, SUGGESTIONS[result.reason]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/meta/user-envelope/types.ts
|
|
382
|
+
var USER_ENVELOPE_MAX_BYTES = 64 * 1024;
|
|
383
|
+
var USER_ENVELOPE_COLLECTION = "_users";
|
|
384
|
+
var UserEnvelopeOversizedError = class extends NoydbError {
|
|
385
|
+
bytes;
|
|
386
|
+
limit;
|
|
387
|
+
constructor(bytes, limit = USER_ENVELOPE_MAX_BYTES) {
|
|
388
|
+
super(
|
|
389
|
+
"USER_ENVELOPE_OVERSIZED",
|
|
390
|
+
`User envelope payload is ${bytes} bytes; soft cap is ${limit} bytes. Move large data into the vault's regular collections.`
|
|
391
|
+
);
|
|
392
|
+
this.name = "UserEnvelopeOversizedError";
|
|
393
|
+
this.bytes = bytes;
|
|
394
|
+
this.limit = limit;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/meta/user-envelope/storage.ts
|
|
399
|
+
async function loadUserEnvelope(store, vault, keyringId, dek) {
|
|
400
|
+
const envelope = await store.get(vault, USER_ENVELOPE_COLLECTION, keyringId);
|
|
401
|
+
if (!envelope) return null;
|
|
402
|
+
const plaintext = await decrypt(envelope._iv, envelope._data, dek);
|
|
403
|
+
const data = JSON.parse(plaintext);
|
|
404
|
+
return {
|
|
405
|
+
keyringId,
|
|
406
|
+
data,
|
|
407
|
+
_v: envelope._v,
|
|
408
|
+
_ts: envelope._ts
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
async function saveUserEnvelope(store, vault, keyringId, payload, dek, expectedVersion) {
|
|
412
|
+
const json = JSON.stringify(payload);
|
|
413
|
+
const bytes = new TextEncoder().encode(json).byteLength;
|
|
414
|
+
if (bytes > USER_ENVELOPE_MAX_BYTES) {
|
|
415
|
+
throw new UserEnvelopeOversizedError(bytes);
|
|
416
|
+
}
|
|
417
|
+
const prior = await store.get(vault, USER_ENVELOPE_COLLECTION, keyringId);
|
|
418
|
+
if (expectedVersion !== void 0) {
|
|
419
|
+
const priorVersion = prior?._v ?? 0;
|
|
420
|
+
if (priorVersion !== expectedVersion) {
|
|
421
|
+
throw new ConflictError(
|
|
422
|
+
priorVersion,
|
|
423
|
+
`User envelope for "${keyringId}" expected version ${expectedVersion}, actual ${priorVersion}`
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const nextVersion = (prior?._v ?? 0) + 1;
|
|
428
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
429
|
+
const { iv, data } = await encrypt(json, dek);
|
|
430
|
+
const envelope = {
|
|
431
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
432
|
+
_v: nextVersion,
|
|
433
|
+
_ts: ts,
|
|
434
|
+
_iv: iv,
|
|
435
|
+
_data: data
|
|
436
|
+
};
|
|
437
|
+
await store.put(vault, USER_ENVELOPE_COLLECTION, keyringId, envelope);
|
|
438
|
+
return {
|
|
439
|
+
keyringId,
|
|
440
|
+
data: payload,
|
|
441
|
+
_v: nextVersion,
|
|
442
|
+
_ts: ts
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
async function deleteUserEnvelope(store, vault, keyringId) {
|
|
446
|
+
await store.delete(vault, USER_ENVELOPE_COLLECTION, keyringId);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/team/keyring.ts
|
|
450
|
+
var ADMIN_GRANTABLE_TARGETS = ["operator", "viewer", "client", "admin"];
|
|
451
|
+
function canGrant(callerRole, targetRole) {
|
|
452
|
+
if (callerRole === "owner") return true;
|
|
453
|
+
if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
function canRevoke(callerRole, targetRole) {
|
|
457
|
+
if (targetRole === "owner") return false;
|
|
458
|
+
if (callerRole === "owner") return true;
|
|
459
|
+
if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
function canUpdateRole(callerRole, targetRole) {
|
|
463
|
+
if (callerRole === "owner") return true;
|
|
464
|
+
if (callerRole === "admin") return ADMIN_GRANTABLE_TARGETS.includes(targetRole);
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
var CANARY_PLAINTEXT_BYTES = new Uint8Array(32);
|
|
468
|
+
var canaryKeyPromise = null;
|
|
469
|
+
function getCanaryKey() {
|
|
470
|
+
if (canaryKeyPromise === null) {
|
|
471
|
+
canaryKeyPromise = globalThis.crypto.subtle.importKey(
|
|
472
|
+
"raw",
|
|
473
|
+
CANARY_PLAINTEXT_BYTES,
|
|
474
|
+
{ name: "AES-GCM", length: 256 },
|
|
475
|
+
true,
|
|
476
|
+
// extractable so AES-KW can wrap it
|
|
477
|
+
["encrypt", "decrypt"]
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return canaryKeyPromise;
|
|
481
|
+
}
|
|
482
|
+
async function mintKeyringCanary(kek) {
|
|
483
|
+
const canaryKey = await getCanaryKey();
|
|
484
|
+
return wrapKey(canaryKey, kek);
|
|
485
|
+
}
|
|
486
|
+
async function verifyKeyringCanary(wrappedCanary, kek) {
|
|
487
|
+
try {
|
|
488
|
+
await unwrapKey(wrappedCanary, kek);
|
|
489
|
+
return true;
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function loadKeyring(adapter, vault, userId, passphrase) {
|
|
495
|
+
const envelope = await adapter.get(vault, "_keyring", userId);
|
|
496
|
+
if (!envelope) {
|
|
497
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}"`);
|
|
498
|
+
}
|
|
499
|
+
const keyringFile = JSON.parse(envelope._data);
|
|
500
|
+
if (keyringFile.expires_at !== void 0) {
|
|
501
|
+
const cutoff = Date.parse(keyringFile.expires_at);
|
|
502
|
+
if (Number.isFinite(cutoff) && Date.now() >= cutoff) {
|
|
503
|
+
throw new KeyringExpiredError({ userId: keyringFile.user_id, expiresAt: keyringFile.expires_at });
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const salt = base64ToBuffer(keyringFile.salt);
|
|
507
|
+
const kek = await deriveKey(passphrase, salt);
|
|
508
|
+
const canaryOk = keyringFile.canary !== void 0 ? await verifyKeyringCanary(keyringFile.canary, kek) : null;
|
|
509
|
+
const deks = /* @__PURE__ */ new Map();
|
|
510
|
+
const failedCollections = [];
|
|
511
|
+
let firstUnwrapError = null;
|
|
512
|
+
for (const [collName, wrappedDek] of Object.entries(keyringFile.deks)) {
|
|
513
|
+
try {
|
|
514
|
+
const dek = await unwrapKey(wrappedDek, kek);
|
|
515
|
+
deks.set(collName, dek);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
failedCollections.push(collName);
|
|
518
|
+
if (firstUnwrapError === null) firstUnwrapError = err;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (canaryOk === true) {
|
|
522
|
+
if (failedCollections.length > 0) {
|
|
523
|
+
throw new KeyringCorruptError({ failedCollections, intactCount: deks.size });
|
|
524
|
+
}
|
|
525
|
+
} else if (canaryOk === false) {
|
|
526
|
+
if (deks.size > 0) {
|
|
527
|
+
throw new KeyringCorruptError({
|
|
528
|
+
failedCollections: [...failedCollections, "_canary"],
|
|
529
|
+
intactCount: deks.size
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
throw firstUnwrapError instanceof Error ? firstUnwrapError : new InvalidKeyError();
|
|
533
|
+
} else {
|
|
534
|
+
if (failedCollections.length > 0) {
|
|
535
|
+
if (deks.size > 0) {
|
|
536
|
+
throw new KeyringCorruptError({ failedCollections, intactCount: deks.size });
|
|
537
|
+
}
|
|
538
|
+
throw firstUnwrapError instanceof Error ? firstUnwrapError : new InvalidKeyError();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
userId: keyringFile.user_id,
|
|
543
|
+
displayName: keyringFile.display_name,
|
|
544
|
+
role: keyringFile.role,
|
|
545
|
+
permissions: keyringFile.permissions,
|
|
546
|
+
deks,
|
|
547
|
+
kek,
|
|
548
|
+
salt,
|
|
549
|
+
authenticators: keyringFile.authenticators ?? [],
|
|
550
|
+
...keyringFile.export_capability !== void 0 && { exportCapability: keyringFile.export_capability },
|
|
551
|
+
...keyringFile.import_capability !== void 0 && { importCapability: keyringFile.import_capability },
|
|
552
|
+
...keyringFile.policy !== void 0 && { policy: keyringFile.policy }
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
async function createOwnerKeyring(adapter, vault, userId, passphrase, passphraseOpts) {
|
|
556
|
+
if (passphraseOpts?.validate && !passphraseOpts.allowWeakPassphrase) {
|
|
557
|
+
assertStrongPassphrase(passphrase, passphraseOpts);
|
|
558
|
+
}
|
|
559
|
+
const salt = generateSalt();
|
|
560
|
+
const kek = await deriveKey(passphrase, salt);
|
|
561
|
+
const userEnvelopeDek = await generateDEK();
|
|
562
|
+
const wrappedUserEnvelopeDek = await wrapKey(userEnvelopeDek, kek);
|
|
563
|
+
const canary = await mintKeyringCanary(kek);
|
|
564
|
+
const keyringFile = {
|
|
565
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
566
|
+
user_id: userId,
|
|
567
|
+
display_name: userId,
|
|
568
|
+
role: "owner",
|
|
569
|
+
permissions: {},
|
|
570
|
+
deks: { [USER_ENVELOPE_COLLECTION]: wrappedUserEnvelopeDek },
|
|
571
|
+
salt: bufferToBase64(salt),
|
|
572
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
573
|
+
granted_by: userId,
|
|
574
|
+
canary
|
|
575
|
+
};
|
|
576
|
+
await writeKeyringFile(adapter, vault, userId, keyringFile);
|
|
577
|
+
return {
|
|
578
|
+
userId,
|
|
579
|
+
displayName: userId,
|
|
580
|
+
role: "owner",
|
|
581
|
+
permissions: {},
|
|
582
|
+
deks: /* @__PURE__ */ new Map([[USER_ENVELOPE_COLLECTION, userEnvelopeDek]]),
|
|
583
|
+
kek,
|
|
584
|
+
salt,
|
|
585
|
+
authenticators: []
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
async function grant(adapter, vault, callerKeyring, options) {
|
|
589
|
+
if (!callerKeyring.kek) {
|
|
590
|
+
throw new ValidationError(
|
|
591
|
+
"grant: caller keyring has no KEK \u2014 tier-2 wrap-DEKs and tier-3 PIN-resume sessions cannot grant access to other users. Re-authenticate at tier 1 (passphrase) before granting."
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
if (!canGrant(callerKeyring.role, options.role)) {
|
|
595
|
+
throw new PermissionDeniedError(
|
|
596
|
+
`Role "${callerKeyring.role}" cannot grant role "${options.role}"`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (options.validatePassphrase && !options.allowWeakPassphrase) {
|
|
600
|
+
assertStrongPassphrase(options.passphrase);
|
|
601
|
+
}
|
|
602
|
+
const permissions = resolvePermissions(options.role, options.permissions);
|
|
603
|
+
const newSalt = generateSalt();
|
|
604
|
+
const newKek = await deriveKey(options.passphrase, newSalt);
|
|
605
|
+
const wrappedDeks = {};
|
|
606
|
+
for (const collName of Object.keys(permissions)) {
|
|
607
|
+
const dek = callerKeyring.deks.get(collName);
|
|
608
|
+
if (dek) {
|
|
609
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (options.role === "owner" || options.role === "admin" || options.role === "viewer") {
|
|
613
|
+
for (const [collName, dek] of callerKeyring.deks) {
|
|
614
|
+
if (!(collName in wrappedDeks)) {
|
|
615
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const [collName, dek] of callerKeyring.deks) {
|
|
620
|
+
if (collName.startsWith("_") && !(collName in wrappedDeks)) {
|
|
621
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (const collName of Object.keys(wrappedDeks)) {
|
|
625
|
+
if (!callerKeyring.deks.has(collName)) {
|
|
626
|
+
throw new PrivilegeEscalationError(collName);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const canary = await mintKeyringCanary(newKek);
|
|
630
|
+
const keyringFile = {
|
|
631
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
632
|
+
user_id: options.userId,
|
|
633
|
+
display_name: options.displayName,
|
|
634
|
+
role: options.role,
|
|
635
|
+
permissions,
|
|
636
|
+
deks: wrappedDeks,
|
|
637
|
+
salt: bufferToBase64(newSalt),
|
|
638
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
639
|
+
granted_by: callerKeyring.userId,
|
|
640
|
+
canary,
|
|
641
|
+
...options.exportCapability !== void 0 && { export_capability: options.exportCapability },
|
|
642
|
+
...options.importCapability !== void 0 && { import_capability: options.importCapability }
|
|
643
|
+
};
|
|
644
|
+
await writeKeyringFile(adapter, vault, options.userId, keyringFile);
|
|
645
|
+
const userEnvelopeDek = callerKeyring.deks.get(USER_ENVELOPE_COLLECTION);
|
|
646
|
+
if (userEnvelopeDek) {
|
|
647
|
+
const initialPayload = options.initialProfile ?? {};
|
|
648
|
+
await saveUserEnvelope(
|
|
649
|
+
adapter,
|
|
650
|
+
vault,
|
|
651
|
+
options.userId,
|
|
652
|
+
initialPayload,
|
|
653
|
+
userEnvelopeDek
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
async function findAdminDescendants(adapter, vault, rootUserId) {
|
|
658
|
+
const allUserIds = await adapter.list(vault, "_keyring");
|
|
659
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
660
|
+
for (const userId of allUserIds) {
|
|
661
|
+
const env = await adapter.get(vault, "_keyring", userId);
|
|
662
|
+
if (!env) continue;
|
|
663
|
+
const kf = JSON.parse(env._data);
|
|
664
|
+
if (kf.role !== "admin") continue;
|
|
665
|
+
if (kf.user_id === rootUserId) continue;
|
|
666
|
+
const list = childrenByParent.get(kf.granted_by) ?? [];
|
|
667
|
+
list.push(kf.user_id);
|
|
668
|
+
childrenByParent.set(kf.granted_by, list);
|
|
669
|
+
}
|
|
670
|
+
const visited = /* @__PURE__ */ new Set();
|
|
671
|
+
const order = [];
|
|
672
|
+
const stack = [...childrenByParent.get(rootUserId) ?? []];
|
|
673
|
+
while (stack.length > 0) {
|
|
674
|
+
const next = stack.pop();
|
|
675
|
+
if (visited.has(next)) continue;
|
|
676
|
+
visited.add(next);
|
|
677
|
+
order.push(next);
|
|
678
|
+
for (const grandchild of childrenByParent.get(next) ?? []) {
|
|
679
|
+
if (!visited.has(grandchild)) stack.push(grandchild);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return order;
|
|
683
|
+
}
|
|
684
|
+
async function revoke(adapter, vault, callerKeyring, options) {
|
|
685
|
+
const targetEnvelope = await adapter.get(vault, "_keyring", options.userId);
|
|
686
|
+
if (!targetEnvelope) {
|
|
687
|
+
throw new NoAccessError(`User "${options.userId}" has no keyring in vault "${vault}"`);
|
|
688
|
+
}
|
|
689
|
+
const targetKeyring = JSON.parse(targetEnvelope._data);
|
|
690
|
+
if (!canRevoke(callerKeyring.role, targetKeyring.role)) {
|
|
691
|
+
throw new PermissionDeniedError(
|
|
692
|
+
`Role "${callerKeyring.role}" cannot revoke role "${targetKeyring.role}"`
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
const cascadeMode = options.cascade ?? "strict";
|
|
696
|
+
const usersToRevoke = [options.userId];
|
|
697
|
+
const affectedCollections = new Set(Object.keys(targetKeyring.deks));
|
|
698
|
+
if (targetKeyring.role === "admin") {
|
|
699
|
+
const descendants = await findAdminDescendants(adapter, vault, options.userId);
|
|
700
|
+
if (descendants.length > 0) {
|
|
701
|
+
if (cascadeMode === "warn") {
|
|
702
|
+
console.warn(
|
|
703
|
+
`[noy-db] revoke(${options.userId}): cascade='warn' \u2014 leaving ${descendants.length} descendant admin(s) in place: ${descendants.join(", ")}. These admins were granted by the revoked user (transitively) and will become orphans in the delegation tree.`
|
|
704
|
+
);
|
|
705
|
+
} else {
|
|
706
|
+
for (const userId of descendants) {
|
|
707
|
+
const descEnv = await adapter.get(vault, "_keyring", userId);
|
|
708
|
+
if (!descEnv) continue;
|
|
709
|
+
const descKf = JSON.parse(descEnv._data);
|
|
710
|
+
usersToRevoke.push(userId);
|
|
711
|
+
for (const c of Object.keys(descKf.deks)) affectedCollections.add(c);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
for (const userId of usersToRevoke) {
|
|
717
|
+
await adapter.delete(vault, "_keyring", userId);
|
|
718
|
+
await deleteUserEnvelope(adapter, vault, userId);
|
|
719
|
+
}
|
|
720
|
+
if (options.rotateKeys !== false && affectedCollections.size > 0) {
|
|
721
|
+
await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections]);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async function updateKeyringIdentity(adapter, vault, callerKeyring, options) {
|
|
725
|
+
if (options.role === void 0 && options.displayName === void 0 && options.permissions === void 0) {
|
|
726
|
+
throw new ValidationError(
|
|
727
|
+
`updateUser: at least one of role / displayName / permissions must be provided (userId: "${options.userId}").`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
const env = await adapter.get(vault, "_keyring", options.userId);
|
|
731
|
+
if (!env) {
|
|
732
|
+
throw new NoAccessError(
|
|
733
|
+
`updateUser: user "${options.userId}" has no keyring in vault "${vault}".`
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
const target = JSON.parse(env._data);
|
|
737
|
+
if (!canUpdateRole(callerKeyring.role, target.role)) {
|
|
738
|
+
throw new PermissionDeniedError(
|
|
739
|
+
`Role "${callerKeyring.role}" cannot update a keyring with role "${target.role}"`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
if (options.role !== void 0 && options.role !== target.role && !canUpdateRole(callerKeyring.role, options.role)) {
|
|
743
|
+
throw new PermissionDeniedError(
|
|
744
|
+
`Role "${callerKeyring.role}" cannot promote target to role "${options.role}"`
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
const next = {
|
|
748
|
+
...target,
|
|
749
|
+
...options.role !== void 0 && { role: options.role },
|
|
750
|
+
...options.displayName !== void 0 && {
|
|
751
|
+
// null clears the field (stored as ""); a string sets it.
|
|
752
|
+
display_name: options.displayName ?? ""
|
|
753
|
+
},
|
|
754
|
+
...options.permissions !== void 0 && { permissions: options.permissions }
|
|
755
|
+
};
|
|
756
|
+
await writeKeyringFile(adapter, vault, options.userId, next);
|
|
757
|
+
}
|
|
758
|
+
async function rotateKeys(adapter, vault, callerKeyring, collections) {
|
|
759
|
+
const newDeks = /* @__PURE__ */ new Map();
|
|
760
|
+
for (const collName of collections) {
|
|
761
|
+
newDeks.set(collName, await generateDEK());
|
|
762
|
+
}
|
|
763
|
+
for (const collName of collections) {
|
|
764
|
+
const oldDek = callerKeyring.deks.get(collName);
|
|
765
|
+
const newDek = newDeks.get(collName);
|
|
766
|
+
if (!oldDek) continue;
|
|
767
|
+
const ids = await adapter.list(vault, collName);
|
|
768
|
+
for (const id of ids) {
|
|
769
|
+
const envelope = await adapter.get(vault, collName, id);
|
|
770
|
+
if (!envelope || !envelope._iv) continue;
|
|
771
|
+
const plaintext = await decrypt(envelope._iv, envelope._data, oldDek);
|
|
772
|
+
const { iv, data } = await encrypt(plaintext, newDek);
|
|
773
|
+
const newEnvelope = {
|
|
774
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
775
|
+
_v: envelope._v,
|
|
776
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
777
|
+
_iv: iv,
|
|
778
|
+
_data: data
|
|
779
|
+
};
|
|
780
|
+
await adapter.put(vault, collName, id, newEnvelope);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
for (const [collName, newDek] of newDeks) {
|
|
784
|
+
callerKeyring.deks.set(collName, newDek);
|
|
785
|
+
}
|
|
786
|
+
await persistKeyring(adapter, vault, callerKeyring);
|
|
787
|
+
const userIds = await adapter.list(vault, "_keyring");
|
|
788
|
+
for (const userId of userIds) {
|
|
789
|
+
if (userId === callerKeyring.userId) continue;
|
|
790
|
+
const userEnvelope = await adapter.get(vault, "_keyring", userId);
|
|
791
|
+
if (!userEnvelope) continue;
|
|
792
|
+
const userKeyringFile = JSON.parse(userEnvelope._data);
|
|
793
|
+
const updatedDeks = { ...userKeyringFile.deks };
|
|
794
|
+
for (const collName of collections) {
|
|
795
|
+
delete updatedDeks[collName];
|
|
796
|
+
}
|
|
797
|
+
const updatedPermissions = { ...userKeyringFile.permissions };
|
|
798
|
+
for (const collName of collections) {
|
|
799
|
+
delete updatedPermissions[collName];
|
|
800
|
+
}
|
|
801
|
+
const updatedKeyring = {
|
|
802
|
+
...userKeyringFile,
|
|
803
|
+
deks: updatedDeks,
|
|
804
|
+
permissions: updatedPermissions
|
|
805
|
+
};
|
|
806
|
+
await writeKeyringFile(adapter, vault, userId, updatedKeyring);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async function changeSecret(adapter, vault, keyring, newPassphrase, passphraseOpts) {
|
|
810
|
+
if (!passphraseOpts?.allowWeakPassphrase) {
|
|
811
|
+
assertStrongPassphrase(newPassphrase, passphraseOpts);
|
|
812
|
+
}
|
|
813
|
+
const newSalt = generateSalt();
|
|
814
|
+
const newKek = await deriveKey(newPassphrase, newSalt);
|
|
815
|
+
const wrappedDeks = {};
|
|
816
|
+
for (const [collName, dek] of keyring.deks) {
|
|
817
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
818
|
+
}
|
|
819
|
+
const canary = await mintKeyringCanary(newKek);
|
|
820
|
+
const keyringFile = {
|
|
821
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
822
|
+
user_id: keyring.userId,
|
|
823
|
+
display_name: keyring.displayName,
|
|
824
|
+
role: keyring.role,
|
|
825
|
+
permissions: keyring.permissions,
|
|
826
|
+
deks: wrappedDeks,
|
|
827
|
+
salt: bufferToBase64(newSalt),
|
|
828
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
829
|
+
granted_by: keyring.userId,
|
|
830
|
+
canary
|
|
831
|
+
};
|
|
832
|
+
await writeKeyringFile(adapter, vault, keyring.userId, keyringFile);
|
|
833
|
+
return {
|
|
834
|
+
userId: keyring.userId,
|
|
835
|
+
displayName: keyring.displayName,
|
|
836
|
+
role: keyring.role,
|
|
837
|
+
permissions: keyring.permissions,
|
|
838
|
+
deks: keyring.deks,
|
|
839
|
+
// Same DEKs, different wrapping
|
|
840
|
+
kek: newKek,
|
|
841
|
+
salt: newSalt,
|
|
842
|
+
// Tier-2 slots are NOT preserved through `changeSecret` —
|
|
843
|
+
// each slot wraps the OLD KEK, so the new keyring has no
|
|
844
|
+
// authenticator slots until the user re-enrolls. The higher-level
|
|
845
|
+
// `db.rotatePassphrase()` (#10) preserves slots by rewrapping the
|
|
846
|
+
// KEK reference, not the KEK itself.
|
|
847
|
+
authenticators: [],
|
|
848
|
+
...keyring.policy !== void 0 && { policy: keyring.policy }
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
async function buildRecipientKeyringFile(callerKeyring, recipient) {
|
|
852
|
+
if (!callerKeyring.kek) {
|
|
853
|
+
throw new ValidationError(
|
|
854
|
+
"buildRecipientKeyringFile: caller keyring has no KEK \u2014 tier-2 wrap-DEKs and tier-3 PIN-resume sessions cannot create bundle recipients. Re-authenticate at tier 1 (passphrase) before building a bundle."
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
const role = recipient.role ?? "viewer";
|
|
858
|
+
const permissions = resolvePermissions(role, recipient.permissions);
|
|
859
|
+
const newSalt = generateSalt();
|
|
860
|
+
const newKek = await deriveKey(recipient.passphrase, newSalt);
|
|
861
|
+
const wrappedDeks = {};
|
|
862
|
+
for (const collName of Object.keys(permissions)) {
|
|
863
|
+
const dek = callerKeyring.deks.get(collName);
|
|
864
|
+
if (dek) {
|
|
865
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (role === "owner" || role === "admin" || role === "viewer") {
|
|
869
|
+
for (const [collName, dek] of callerKeyring.deks) {
|
|
870
|
+
if (!(collName in wrappedDeks)) {
|
|
871
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
for (const [collName, dek] of callerKeyring.deks) {
|
|
876
|
+
if (collName.startsWith("_") && !(collName in wrappedDeks)) {
|
|
877
|
+
wrappedDeks[collName] = await wrapKey(dek, newKek);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
for (const collName of Object.keys(wrappedDeks)) {
|
|
881
|
+
if (!callerKeyring.deks.has(collName)) {
|
|
882
|
+
throw new PrivilegeEscalationError(collName);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const canary = await mintKeyringCanary(newKek);
|
|
886
|
+
return {
|
|
887
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
888
|
+
user_id: recipient.id,
|
|
889
|
+
display_name: recipient.displayName ?? recipient.id,
|
|
890
|
+
role,
|
|
891
|
+
permissions,
|
|
892
|
+
deks: wrappedDeks,
|
|
893
|
+
salt: bufferToBase64(newSalt),
|
|
894
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
895
|
+
granted_by: callerKeyring.userId,
|
|
896
|
+
canary,
|
|
897
|
+
...recipient.exportCapability !== void 0 ? { export_capability: recipient.exportCapability } : {},
|
|
898
|
+
...recipient.importCapability !== void 0 ? { import_capability: recipient.importCapability } : {},
|
|
899
|
+
...recipient.expiresAt !== void 0 ? { expires_at: recipient.expiresAt } : {}
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
async function listUsers(adapter, vault) {
|
|
903
|
+
const userIds = await adapter.list(vault, "_keyring");
|
|
904
|
+
const users = [];
|
|
905
|
+
for (const userId of userIds) {
|
|
906
|
+
const envelope = await adapter.get(vault, "_keyring", userId);
|
|
907
|
+
if (!envelope) continue;
|
|
908
|
+
const kf = JSON.parse(envelope._data);
|
|
909
|
+
users.push({
|
|
910
|
+
userId: kf.user_id,
|
|
911
|
+
displayName: kf.display_name,
|
|
912
|
+
role: kf.role,
|
|
913
|
+
permissions: kf.permissions,
|
|
914
|
+
createdAt: kf.created_at,
|
|
915
|
+
grantedBy: kf.granted_by
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
return users;
|
|
919
|
+
}
|
|
920
|
+
async function listUsersWithEnvelopes(adapter, vault, userEnvelopeDek) {
|
|
921
|
+
const users = await listUsers(adapter, vault);
|
|
922
|
+
const out = [];
|
|
923
|
+
for (const user of users) {
|
|
924
|
+
const envelope = await loadUserEnvelope(
|
|
925
|
+
adapter,
|
|
926
|
+
vault,
|
|
927
|
+
user.userId,
|
|
928
|
+
userEnvelopeDek
|
|
929
|
+
);
|
|
930
|
+
out.push({ user, envelope });
|
|
931
|
+
}
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
async function ensureCollectionDEK(adapter, vault, keyring) {
|
|
935
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
936
|
+
return async (collectionName) => {
|
|
937
|
+
const existing = keyring.deks.get(collectionName);
|
|
938
|
+
if (existing) return existing;
|
|
939
|
+
const pending = inFlight.get(collectionName);
|
|
940
|
+
if (pending) return pending;
|
|
941
|
+
const promise = (async () => {
|
|
942
|
+
const dek = await generateDEK();
|
|
943
|
+
keyring.deks.set(collectionName, dek);
|
|
944
|
+
await persistKeyring(adapter, vault, keyring);
|
|
945
|
+
return dek;
|
|
946
|
+
})();
|
|
947
|
+
inFlight.set(collectionName, promise);
|
|
948
|
+
try {
|
|
949
|
+
return await promise;
|
|
950
|
+
} finally {
|
|
951
|
+
inFlight.delete(collectionName);
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
async function persistKeyring(adapter, vault, keyring) {
|
|
956
|
+
if (!keyring.kek) {
|
|
957
|
+
throw new ValidationError(
|
|
958
|
+
"persistKeyring: keyring.kek is null \u2014 cannot wrap DEKs without the KEK. This typically means the keyring was opened via tier-3 PIN resume, session restore, or a wrap-DEKs tier-2 unlock. Re-authenticate at tier 1 (passphrase) before persisting."
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
const wrappedDeks = {};
|
|
962
|
+
for (const [collName, dek] of keyring.deks) {
|
|
963
|
+
wrappedDeks[collName] = await wrapKey(dek, keyring.kek);
|
|
964
|
+
}
|
|
965
|
+
const canary = await mintKeyringCanary(keyring.kek);
|
|
966
|
+
const keyringFile = {
|
|
967
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
968
|
+
user_id: keyring.userId,
|
|
969
|
+
display_name: keyring.displayName,
|
|
970
|
+
role: keyring.role,
|
|
971
|
+
permissions: keyring.permissions,
|
|
972
|
+
deks: wrappedDeks,
|
|
973
|
+
salt: bufferToBase64(keyring.salt),
|
|
974
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
975
|
+
granted_by: keyring.userId,
|
|
976
|
+
canary,
|
|
977
|
+
...keyring.exportCapability !== void 0 && { export_capability: keyring.exportCapability },
|
|
978
|
+
...keyring.importCapability !== void 0 && { import_capability: keyring.importCapability },
|
|
979
|
+
...keyring.authenticators.length > 0 && { authenticators: keyring.authenticators },
|
|
980
|
+
...keyring.policy !== void 0 && { policy: keyring.policy }
|
|
981
|
+
};
|
|
982
|
+
await writeKeyringFile(adapter, vault, keyring.userId, keyringFile);
|
|
983
|
+
}
|
|
984
|
+
function defaultBundleCapability(role) {
|
|
985
|
+
return role === "owner" || role === "admin";
|
|
986
|
+
}
|
|
987
|
+
function hasExportCapability(keyring, tier, format) {
|
|
988
|
+
const cap = keyring.exportCapability;
|
|
989
|
+
if (tier === "plaintext") {
|
|
990
|
+
const allowed = cap?.plaintext ?? [];
|
|
991
|
+
return allowed.includes("*") || format !== void 0 && allowed.includes(format);
|
|
992
|
+
}
|
|
993
|
+
return cap?.bundle ?? defaultBundleCapability(keyring.role);
|
|
994
|
+
}
|
|
995
|
+
function evaluateExportCapability(capability, role, tier, format) {
|
|
996
|
+
if (tier === "plaintext") {
|
|
997
|
+
const allowed = capability?.plaintext ?? [];
|
|
998
|
+
return allowed.includes("*") || format !== void 0 && allowed.includes(format);
|
|
999
|
+
}
|
|
1000
|
+
return capability?.bundle ?? defaultBundleCapability(role);
|
|
1001
|
+
}
|
|
1002
|
+
function hasImportCapability(keyring, tier, format) {
|
|
1003
|
+
const cap = keyring.importCapability;
|
|
1004
|
+
if (tier === "plaintext") {
|
|
1005
|
+
const allowed = cap?.plaintext ?? [];
|
|
1006
|
+
return allowed.includes("*") || format !== void 0 && allowed.includes(format);
|
|
1007
|
+
}
|
|
1008
|
+
return cap?.bundle === true;
|
|
1009
|
+
}
|
|
1010
|
+
function evaluateImportCapability(capability, _role, tier, format) {
|
|
1011
|
+
if (tier === "plaintext") {
|
|
1012
|
+
const allowed = capability?.plaintext ?? [];
|
|
1013
|
+
return allowed.includes("*") || format !== void 0 && allowed.includes(format);
|
|
1014
|
+
}
|
|
1015
|
+
return capability?.bundle === true;
|
|
1016
|
+
}
|
|
1017
|
+
function resolvePermissions(role, explicit) {
|
|
1018
|
+
if (role === "owner" || role === "admin" || role === "viewer") return {};
|
|
1019
|
+
return explicit ?? {};
|
|
1020
|
+
}
|
|
1021
|
+
async function writeKeyringFile(adapter, vault, userId, keyringFile) {
|
|
1022
|
+
const envelope = {
|
|
1023
|
+
_noydb: 1,
|
|
1024
|
+
_v: 1,
|
|
1025
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1026
|
+
_iv: "",
|
|
1027
|
+
_data: JSON.stringify(keyringFile)
|
|
1028
|
+
};
|
|
1029
|
+
await adapter.put(vault, "_keyring", userId, envelope);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/team/authenticators.ts
|
|
1033
|
+
async function enrollAuthenticator(store, vault, keyring, options) {
|
|
1034
|
+
const existing = keyring.authenticators.find((a) => a.id === options.id);
|
|
1035
|
+
if (existing) {
|
|
1036
|
+
throw new ValidationError(
|
|
1037
|
+
`enrollAuthenticator: slot id "${options.id}" already exists in vault "${vault}". Remove the slot first or pick a unique id.`
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
const base = {
|
|
1041
|
+
id: options.id,
|
|
1042
|
+
method: options.method,
|
|
1043
|
+
enrolled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1044
|
+
enrolled_via_tier: options.enrolled_via_tier ?? 1,
|
|
1045
|
+
meta: options.meta
|
|
1046
|
+
};
|
|
1047
|
+
const slot = options.wrapKind === "deks" ? {
|
|
1048
|
+
...base,
|
|
1049
|
+
wrapKind: "deks",
|
|
1050
|
+
wrapped_deks: options.wrapped_deks,
|
|
1051
|
+
iv: options.iv
|
|
1052
|
+
} : {
|
|
1053
|
+
...base,
|
|
1054
|
+
wrapped_kek: options.wrapped_kek
|
|
1055
|
+
};
|
|
1056
|
+
const next = appendSlot(keyring, slot);
|
|
1057
|
+
await persistKeyring(store, vault, next);
|
|
1058
|
+
return next;
|
|
1059
|
+
}
|
|
1060
|
+
async function updateAuthenticator(store, vault, keyring, slotId, options) {
|
|
1061
|
+
if (options.meta === void 0) {
|
|
1062
|
+
throw new ValidationError(
|
|
1063
|
+
`updateAuthenticator: at least one of meta must be provided (slotId: "${slotId}").`
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
const idx = keyring.authenticators.findIndex((a) => a.id === slotId);
|
|
1067
|
+
if (idx === -1) {
|
|
1068
|
+
throw new NoAccessError(
|
|
1069
|
+
`updateAuthenticator: slot "${slotId}" not found in vault "${vault}".`
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
const existing = keyring.authenticators[idx];
|
|
1073
|
+
const mergedMeta = { ...existing.meta };
|
|
1074
|
+
for (const [k, v] of Object.entries(options.meta)) {
|
|
1075
|
+
if (v === void 0) continue;
|
|
1076
|
+
if (v === null) {
|
|
1077
|
+
delete mergedMeta[k];
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
mergedMeta[k] = v;
|
|
1081
|
+
}
|
|
1082
|
+
const next = { ...existing, meta: mergedMeta };
|
|
1083
|
+
const nextSlots = [...keyring.authenticators];
|
|
1084
|
+
nextSlots[idx] = next;
|
|
1085
|
+
const nextKeyring = {
|
|
1086
|
+
...keyring,
|
|
1087
|
+
authenticators: nextSlots
|
|
1088
|
+
};
|
|
1089
|
+
await persistKeyring(store, vault, nextKeyring);
|
|
1090
|
+
return nextKeyring;
|
|
1091
|
+
}
|
|
1092
|
+
async function removeAuthenticator(store, vault, keyring, slotId) {
|
|
1093
|
+
const filtered = keyring.authenticators.filter((a) => a.id !== slotId);
|
|
1094
|
+
if (filtered.length === keyring.authenticators.length) {
|
|
1095
|
+
return keyring;
|
|
1096
|
+
}
|
|
1097
|
+
const next = {
|
|
1098
|
+
...keyring,
|
|
1099
|
+
authenticators: filtered
|
|
1100
|
+
};
|
|
1101
|
+
await persistKeyring(store, vault, next);
|
|
1102
|
+
return next;
|
|
1103
|
+
}
|
|
1104
|
+
function findAuthenticator(keyring, slotId) {
|
|
1105
|
+
return keyring.authenticators.find((a) => a.id === slotId);
|
|
1106
|
+
}
|
|
1107
|
+
function appendSlot(keyring, slot) {
|
|
1108
|
+
return {
|
|
1109
|
+
...keyring,
|
|
1110
|
+
authenticators: [...keyring.authenticators, slot]
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// src/policy/errors.ts
|
|
1115
|
+
var RecoveryProfileNotImplementedError = class extends NoydbError {
|
|
1116
|
+
profile;
|
|
1117
|
+
tracking;
|
|
1118
|
+
constructor(profile, tracking) {
|
|
1119
|
+
super(
|
|
1120
|
+
"RECOVERY_PROFILE_NOT_IMPLEMENTED",
|
|
1121
|
+
`Recovery profile "${profile}" is not yet implemented in this hub release. Tracking: ${tracking}. Use the "paper" profile via @noy-db/on-recovery in the meantime.`
|
|
1122
|
+
);
|
|
1123
|
+
this.name = "RecoveryProfileNotImplementedError";
|
|
1124
|
+
this.profile = profile;
|
|
1125
|
+
this.tracking = tracking;
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
// src/team/wrapped-deks.ts
|
|
1130
|
+
var PBKDF2_ITERATIONS2 = 6e5;
|
|
1131
|
+
var SALT_BYTES2 = 32;
|
|
1132
|
+
var IV_BYTES2 = 12;
|
|
1133
|
+
var subtle2 = globalThis.crypto.subtle;
|
|
1134
|
+
async function mintWrappedDeksBlob(deks, credential) {
|
|
1135
|
+
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES2));
|
|
1136
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES2));
|
|
1137
|
+
const wrappingKey = await deriveWrappingKey(credential, salt);
|
|
1138
|
+
const exported = {};
|
|
1139
|
+
for (const [coll, dek] of deks) {
|
|
1140
|
+
const raw = await subtle2.exportKey("raw", dek);
|
|
1141
|
+
exported[coll] = bytesToBase64(new Uint8Array(raw));
|
|
1142
|
+
}
|
|
1143
|
+
const plaintext = new TextEncoder().encode(JSON.stringify({ deks: exported }));
|
|
1144
|
+
const ciphertext = await subtle2.encrypt(
|
|
1145
|
+
{ name: "AES-GCM", iv },
|
|
1146
|
+
wrappingKey,
|
|
1147
|
+
plaintext
|
|
1148
|
+
);
|
|
1149
|
+
return {
|
|
1150
|
+
salt: bytesToBase64(salt),
|
|
1151
|
+
iv: bytesToBase64(iv),
|
|
1152
|
+
wrappedDeks: bytesToBase64(new Uint8Array(ciphertext))
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
async function unwrapDeksFromBlob(blob, credential) {
|
|
1156
|
+
const wrappingKey = await deriveWrappingKey(credential, base64ToBytes(blob.salt));
|
|
1157
|
+
const plaintext = await subtle2.decrypt(
|
|
1158
|
+
{ name: "AES-GCM", iv: base64ToBytes(blob.iv) },
|
|
1159
|
+
wrappingKey,
|
|
1160
|
+
base64ToBytes(blob.wrappedDeks)
|
|
1161
|
+
);
|
|
1162
|
+
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
1163
|
+
const deks = /* @__PURE__ */ new Map();
|
|
1164
|
+
for (const [coll, b64] of Object.entries(parsed.deks)) {
|
|
1165
|
+
const raw = base64ToBytes(b64);
|
|
1166
|
+
const key = await subtle2.importKey(
|
|
1167
|
+
"raw",
|
|
1168
|
+
raw,
|
|
1169
|
+
{ name: "AES-GCM", length: 256 },
|
|
1170
|
+
true,
|
|
1171
|
+
["encrypt", "decrypt"]
|
|
1172
|
+
);
|
|
1173
|
+
deks.set(coll, key);
|
|
1174
|
+
}
|
|
1175
|
+
return deks;
|
|
1176
|
+
}
|
|
1177
|
+
async function deriveWrappingKey(credential, salt) {
|
|
1178
|
+
const ikm = await subtle2.importKey(
|
|
1179
|
+
"raw",
|
|
1180
|
+
new TextEncoder().encode(credential),
|
|
1181
|
+
"PBKDF2",
|
|
1182
|
+
false,
|
|
1183
|
+
["deriveKey"]
|
|
1184
|
+
);
|
|
1185
|
+
return subtle2.deriveKey(
|
|
1186
|
+
{
|
|
1187
|
+
name: "PBKDF2",
|
|
1188
|
+
salt,
|
|
1189
|
+
iterations: PBKDF2_ITERATIONS2,
|
|
1190
|
+
hash: "SHA-256"
|
|
1191
|
+
},
|
|
1192
|
+
ikm,
|
|
1193
|
+
{ name: "AES-GCM", length: 256 },
|
|
1194
|
+
false,
|
|
1195
|
+
["encrypt", "decrypt"]
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
function bytesToBase64(b) {
|
|
1199
|
+
let s = "";
|
|
1200
|
+
for (const x of b) s += String.fromCharCode(x);
|
|
1201
|
+
return btoa(s);
|
|
1202
|
+
}
|
|
1203
|
+
function base64ToBytes(b64) {
|
|
1204
|
+
const s = atob(b64);
|
|
1205
|
+
const out = new Uint8Array(s.length);
|
|
1206
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
1207
|
+
return out;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// src/team/recovery.ts
|
|
1211
|
+
var PAPER_DOC_ID = "recovery-paper";
|
|
1212
|
+
async function loadPaperRecoveryEntries(store, vault) {
|
|
1213
|
+
const env = await store.get(vault, "_meta", PAPER_DOC_ID);
|
|
1214
|
+
if (!env) return [];
|
|
1215
|
+
try {
|
|
1216
|
+
const doc = JSON.parse(env._data);
|
|
1217
|
+
if (doc.profile !== "paper" || !Array.isArray(doc.entries)) return [];
|
|
1218
|
+
return doc.entries;
|
|
1219
|
+
} catch {
|
|
1220
|
+
return [];
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async function savePaperRecoveryEntries(store, vault, entries) {
|
|
1224
|
+
const doc = {
|
|
1225
|
+
_noydb_recovery: 1,
|
|
1226
|
+
profile: "paper",
|
|
1227
|
+
entries
|
|
1228
|
+
};
|
|
1229
|
+
const envelope = {
|
|
1230
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
1231
|
+
_v: 1,
|
|
1232
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1233
|
+
_iv: "",
|
|
1234
|
+
_data: JSON.stringify(doc)
|
|
1235
|
+
};
|
|
1236
|
+
await store.put(vault, "_meta", PAPER_DOC_ID, envelope);
|
|
1237
|
+
}
|
|
1238
|
+
async function burnPaperRecoveryEntry(store, vault, codeId) {
|
|
1239
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
1240
|
+
const remaining = entries.filter((e) => e.codeId !== codeId);
|
|
1241
|
+
await savePaperRecoveryEntries(store, vault, remaining);
|
|
1242
|
+
}
|
|
1243
|
+
async function mintPaperRecoveryEntry(deks, code, codeId) {
|
|
1244
|
+
const blob = await mintWrappedDeksBlob(deks, code);
|
|
1245
|
+
return {
|
|
1246
|
+
...blob,
|
|
1247
|
+
codeId,
|
|
1248
|
+
enrolledAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
async function unwrapDeksFromPaperEntry(entry, code) {
|
|
1252
|
+
return unwrapDeksFromBlob(entry, code);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// src/team/rotate-recover.ts
|
|
1256
|
+
async function rotatePassphrase(store, vault, userId, input) {
|
|
1257
|
+
if (!input.allowWeakPassphrase) {
|
|
1258
|
+
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
1259
|
+
}
|
|
1260
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
1261
|
+
if (!env) {
|
|
1262
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
|
|
1263
|
+
}
|
|
1264
|
+
const file = JSON.parse(env._data);
|
|
1265
|
+
const oldSalt = base64ToBuffer(file.salt);
|
|
1266
|
+
const oldKek = await deriveKey(input.oldPassphrase, oldSalt);
|
|
1267
|
+
const deks = /* @__PURE__ */ new Map();
|
|
1268
|
+
for (const [coll, wrapped] of Object.entries(file.deks)) {
|
|
1269
|
+
deks.set(coll, await unwrapKey(wrapped, oldKek));
|
|
1270
|
+
}
|
|
1271
|
+
const newSalt = generateSalt();
|
|
1272
|
+
const newKek = await deriveKey(input.newPassphrase, newSalt);
|
|
1273
|
+
const wrappedDeks = {};
|
|
1274
|
+
for (const [coll, dek] of deks) {
|
|
1275
|
+
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
1276
|
+
}
|
|
1277
|
+
const oldSlots = file.authenticators ?? [];
|
|
1278
|
+
const newSlots = [];
|
|
1279
|
+
if (input.slotCeremonies && oldSlots.length > 0) {
|
|
1280
|
+
for (const oldSlot of oldSlots) {
|
|
1281
|
+
const ceremony = input.slotCeremonies[oldSlot.id];
|
|
1282
|
+
if (!ceremony) continue;
|
|
1283
|
+
const result = await ceremony({ newKek, newDeks: deks, oldSlot });
|
|
1284
|
+
if (result.id !== oldSlot.id) {
|
|
1285
|
+
throw new ValidationError(
|
|
1286
|
+
`slotCeremonies['${oldSlot.id}'] returned id="${result.id}". The id must match the rotated slot \u2014 a ceremony cannot change a slot's identity.`
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
if (result.method !== oldSlot.method) {
|
|
1290
|
+
throw new ValidationError(
|
|
1291
|
+
`slotCeremonies['${oldSlot.id}'] returned method="${result.method}", expected "${oldSlot.method}". The method must match the rotated slot \u2014 a ceremony cannot change the auth method (e.g. webauthn \u2192 password) under cover of rotation.`
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
const oldWrapKind = oldSlot.wrapKind ?? "kek";
|
|
1295
|
+
const newWrapKind = result.wrapKind ?? "kek";
|
|
1296
|
+
if (oldWrapKind !== newWrapKind) {
|
|
1297
|
+
throw new ValidationError(
|
|
1298
|
+
`slotCeremonies['${oldSlot.id}'] returned wrapKind="${newWrapKind}", expected "${oldWrapKind}". The wrap format must match the rotated slot \u2014 a ceremony cannot change the wrap shape (e.g. wrap-KEK \u2192 wrap-DEKs) under cover of rotation, since that would silently change the session tier produced at unlock.`
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
const baseFields = {
|
|
1302
|
+
id: result.id,
|
|
1303
|
+
method: result.method,
|
|
1304
|
+
// Preserve original enrolled_at — rotation is rewrapping, not
|
|
1305
|
+
// re-enrollment. The slot's enrolment timestamp tracks when
|
|
1306
|
+
// the user originally added the slot, not when it was last
|
|
1307
|
+
// rewrapped. Forensics consumers reading enrolled_at are
|
|
1308
|
+
// tracking the slot's ORIGIN, not its CURRENT wrapping.
|
|
1309
|
+
enrolled_at: oldSlot.enrolled_at,
|
|
1310
|
+
enrolled_via_tier: result.enrolled_via_tier ?? oldSlot.enrolled_via_tier,
|
|
1311
|
+
meta: result.meta
|
|
1312
|
+
};
|
|
1313
|
+
const newSlot = result.wrapKind === "deks" ? {
|
|
1314
|
+
...baseFields,
|
|
1315
|
+
wrapKind: "deks",
|
|
1316
|
+
wrapped_deks: result.wrapped_deks,
|
|
1317
|
+
iv: result.iv
|
|
1318
|
+
} : {
|
|
1319
|
+
...baseFields,
|
|
1320
|
+
wrapped_kek: result.wrapped_kek
|
|
1321
|
+
};
|
|
1322
|
+
newSlots.push(newSlot);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
const canary = await mintKeyringCanary(newKek);
|
|
1326
|
+
const next = {
|
|
1327
|
+
...file,
|
|
1328
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
1329
|
+
deks: wrappedDeks,
|
|
1330
|
+
salt: bufferToBase64(newSalt),
|
|
1331
|
+
authenticators: newSlots,
|
|
1332
|
+
canary
|
|
1333
|
+
};
|
|
1334
|
+
await writeKeyringFile2(store, vault, userId, next);
|
|
1335
|
+
return {
|
|
1336
|
+
userId: file.user_id,
|
|
1337
|
+
displayName: file.display_name,
|
|
1338
|
+
role: file.role,
|
|
1339
|
+
permissions: file.permissions,
|
|
1340
|
+
deks,
|
|
1341
|
+
kek: newKek,
|
|
1342
|
+
salt: newSalt,
|
|
1343
|
+
authenticators: newSlots,
|
|
1344
|
+
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
1345
|
+
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
async function recoverPassphrase(store, vault, userId, input) {
|
|
1349
|
+
if (!input.allowWeakPassphrase) {
|
|
1350
|
+
assertStrongPassphrase(input.newPassphrase, input.passphrasePolicy);
|
|
1351
|
+
}
|
|
1352
|
+
const profile = input.recoveryProof.profile;
|
|
1353
|
+
if (profile !== "paper") {
|
|
1354
|
+
throw new RecoveryProfileNotImplementedError(
|
|
1355
|
+
profile,
|
|
1356
|
+
"https://github.com/vLannaAi/noy-db/issues/10"
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
return recoverViaPaperCode(store, vault, userId, input);
|
|
1360
|
+
}
|
|
1361
|
+
async function recoverViaPaperCode(store, vault, userId, input) {
|
|
1362
|
+
if (input.recoveryProof.profile !== "paper") throw new Error("unreachable");
|
|
1363
|
+
const { code } = input.recoveryProof.payload;
|
|
1364
|
+
const env = await store.get(vault, "_keyring", userId);
|
|
1365
|
+
if (!env) {
|
|
1366
|
+
throw new NoAccessError(`No keyring found for user "${userId}" in vault "${vault}".`);
|
|
1367
|
+
}
|
|
1368
|
+
const file = JSON.parse(env._data);
|
|
1369
|
+
const entries = await loadPaperRecoveryEntries(store, vault);
|
|
1370
|
+
if (entries.length === 0) {
|
|
1371
|
+
throw new NoAccessError(
|
|
1372
|
+
`No paper-recovery entries enrolled for vault "${vault}". Enroll via \`db.enrollRecovery({ profile: "paper", entries })\` before relying on recovery.`
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
const normalized = normalizePaperCode(code);
|
|
1376
|
+
let recovered;
|
|
1377
|
+
for (const entry of entries) {
|
|
1378
|
+
try {
|
|
1379
|
+
const deks2 = await unwrapDeksFromPaperEntry(entry, normalized);
|
|
1380
|
+
recovered = { deks: deks2, entry };
|
|
1381
|
+
break;
|
|
1382
|
+
} catch {
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
if (!recovered) {
|
|
1386
|
+
throw new InvalidKeyError(
|
|
1387
|
+
"Recovery code does not match any enrolled paper entry. The code may have been previously used (single-use) or typed incorrectly."
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
const deks = recovered.deks;
|
|
1391
|
+
const newSalt = generateSalt();
|
|
1392
|
+
const newKek = await deriveKey(input.newPassphrase, newSalt);
|
|
1393
|
+
const wrappedDeks = {};
|
|
1394
|
+
for (const [coll, dek] of deks) {
|
|
1395
|
+
wrappedDeks[coll] = await wrapKey(dek, newKek);
|
|
1396
|
+
}
|
|
1397
|
+
const canary = await mintKeyringCanary(newKek);
|
|
1398
|
+
const next = {
|
|
1399
|
+
...file,
|
|
1400
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
1401
|
+
deks: wrappedDeks,
|
|
1402
|
+
salt: bufferToBase64(newSalt),
|
|
1403
|
+
authenticators: [],
|
|
1404
|
+
// tier-2 slots wrap old KEK, drop them
|
|
1405
|
+
canary
|
|
1406
|
+
};
|
|
1407
|
+
await burnPaperRecoveryEntry(store, vault, recovered.entry.codeId);
|
|
1408
|
+
await writeKeyringFile2(store, vault, userId, next);
|
|
1409
|
+
return {
|
|
1410
|
+
userId: file.user_id,
|
|
1411
|
+
displayName: file.display_name,
|
|
1412
|
+
role: file.role,
|
|
1413
|
+
permissions: file.permissions,
|
|
1414
|
+
deks,
|
|
1415
|
+
kek: newKek,
|
|
1416
|
+
salt: newSalt,
|
|
1417
|
+
authenticators: [],
|
|
1418
|
+
...file.export_capability !== void 0 && { exportCapability: file.export_capability },
|
|
1419
|
+
...file.import_capability !== void 0 && { importCapability: file.import_capability }
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
function normalizePaperCode(input) {
|
|
1423
|
+
return input.toUpperCase().replace(/[\s\-_]/g, "");
|
|
1424
|
+
}
|
|
1425
|
+
async function writeKeyringFile2(store, vault, userId, file) {
|
|
1426
|
+
const envelope = {
|
|
1427
|
+
_noydb: 1,
|
|
1428
|
+
_v: 1,
|
|
1429
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1430
|
+
_iv: "",
|
|
1431
|
+
_data: JSON.stringify(file)
|
|
1432
|
+
};
|
|
1433
|
+
await store.put(vault, "_keyring", userId, envelope);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/team/peer-recover.ts
|
|
1437
|
+
var ADMIN_RECOVERABLE_TARGETS = ["operator", "viewer", "client", "admin"];
|
|
1438
|
+
function canRecover(callerRole, targetRole) {
|
|
1439
|
+
if (callerRole === "owner") return true;
|
|
1440
|
+
if (callerRole === "admin") return ADMIN_RECOVERABLE_TARGETS.includes(targetRole);
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
async function recoverUser(store, vault, callerKeyring, options) {
|
|
1444
|
+
const env = await store.get(vault, "_keyring", options.userId);
|
|
1445
|
+
if (!env) {
|
|
1446
|
+
throw new NoAccessError(
|
|
1447
|
+
`recoverUser: user "${options.userId}" has no keyring in vault "${vault}".`
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
const target = JSON.parse(env._data);
|
|
1451
|
+
const targetRole = options.role ?? target.role;
|
|
1452
|
+
if (!canRecover(callerKeyring.role, targetRole)) {
|
|
1453
|
+
throw new PermissionDeniedError(
|
|
1454
|
+
`Role "${callerKeyring.role}" cannot recover role "${targetRole}"`
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
if (!canRecover(callerKeyring.role, target.role)) {
|
|
1458
|
+
throw new PermissionDeniedError(
|
|
1459
|
+
`Role "${callerKeyring.role}" cannot recover role "${target.role}"`
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
for (const coll of Object.keys(target.deks)) {
|
|
1463
|
+
if (!callerKeyring.deks.has(coll)) {
|
|
1464
|
+
throw new PrivilegeEscalationError(coll);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (options.validatePassphrase && !options.allowWeakPassphrase) {
|
|
1468
|
+
assertStrongPassphrase(options.passphrase, options.passphrasePolicy);
|
|
1469
|
+
}
|
|
1470
|
+
const newSalt = generateSalt();
|
|
1471
|
+
const newKek = await deriveKey(options.passphrase, newSalt);
|
|
1472
|
+
const wrappedDeks = {};
|
|
1473
|
+
for (const coll of Object.keys(target.deks)) {
|
|
1474
|
+
const callerDek = callerKeyring.deks.get(coll);
|
|
1475
|
+
if (!callerDek) {
|
|
1476
|
+
throw new PrivilegeEscalationError(coll);
|
|
1477
|
+
}
|
|
1478
|
+
wrappedDeks[coll] = await wrapKey(callerDek, newKek);
|
|
1479
|
+
}
|
|
1480
|
+
const canary = await mintKeyringCanary(newKek);
|
|
1481
|
+
const next = {
|
|
1482
|
+
...target,
|
|
1483
|
+
_noydb_keyring: NOYDB_KEYRING_VERSION,
|
|
1484
|
+
role: targetRole,
|
|
1485
|
+
display_name: options.displayName ?? target.display_name,
|
|
1486
|
+
deks: wrappedDeks,
|
|
1487
|
+
salt: bufferToBase64(newSalt),
|
|
1488
|
+
granted_by: callerKeyring.userId,
|
|
1489
|
+
authenticators: [],
|
|
1490
|
+
canary
|
|
1491
|
+
};
|
|
1492
|
+
const envelope = {
|
|
1493
|
+
_noydb: 1,
|
|
1494
|
+
_v: 1,
|
|
1495
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1496
|
+
_iv: "",
|
|
1497
|
+
_data: JSON.stringify(next)
|
|
1498
|
+
};
|
|
1499
|
+
await store.put(vault, "_keyring", options.userId, envelope);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/team/tiers.ts
|
|
1503
|
+
function dekKey(collection, tier) {
|
|
1504
|
+
if (tier <= 0) return collection;
|
|
1505
|
+
return `${collection}#${tier}`;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// src/team/magic-link-grant.ts
|
|
1509
|
+
var MAGIC_LINK_GRANTS_COLLECTION = "_magic_link_grants";
|
|
1510
|
+
var MAGIC_LINK_CONTENT_INFO_PREFIX = "noydb-magic-link-content-v1:";
|
|
1511
|
+
async function deriveMagicLinkContentKey(serverSecret, token, vault) {
|
|
1512
|
+
const subtle3 = globalThis.crypto.subtle;
|
|
1513
|
+
const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
|
|
1514
|
+
const tokenBytes = new TextEncoder().encode(token);
|
|
1515
|
+
const saltBuffer = await subtle3.digest("SHA-256", tokenBytes);
|
|
1516
|
+
const info = new TextEncoder().encode(MAGIC_LINK_CONTENT_INFO_PREFIX + vault);
|
|
1517
|
+
const ikm = await subtle3.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
|
|
1518
|
+
return subtle3.deriveKey(
|
|
1519
|
+
{ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info },
|
|
1520
|
+
ikm,
|
|
1521
|
+
{ name: "AES-GCM", length: 256 },
|
|
1522
|
+
false,
|
|
1523
|
+
["encrypt", "decrypt"]
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
async function writeMagicLinkGrant(store, vault, grantor, contentKey, grantKek, recordId, opts) {
|
|
1527
|
+
const collectionName = opts.collection ?? null;
|
|
1528
|
+
const sourceKey = collectionName ? dekKey(collectionName, opts.tier) : `__any#${opts.tier}`;
|
|
1529
|
+
const sourceDek = grantor.deks.get(sourceKey);
|
|
1530
|
+
if (!sourceDek) {
|
|
1531
|
+
throw new DelegationTargetMissingError(
|
|
1532
|
+
`grantor cannot find tier ${opts.tier} DEK for ${collectionName ?? "(any)"}`
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
const wrappedDek = await wrapKey(sourceDek, grantKek);
|
|
1536
|
+
const until = typeof opts.until === "string" ? opts.until : opts.until.toISOString();
|
|
1537
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1538
|
+
const payload = {
|
|
1539
|
+
id: recordId,
|
|
1540
|
+
toUser: opts.toUser,
|
|
1541
|
+
fromUser: grantor.userId,
|
|
1542
|
+
tier: opts.tier,
|
|
1543
|
+
collection: collectionName,
|
|
1544
|
+
...opts.record && { record: opts.record },
|
|
1545
|
+
until,
|
|
1546
|
+
wrappedDek,
|
|
1547
|
+
createdAt,
|
|
1548
|
+
...opts.note && { note: opts.note }
|
|
1549
|
+
};
|
|
1550
|
+
const { iv, data } = await encrypt(JSON.stringify(payload), contentKey);
|
|
1551
|
+
const envelope = {
|
|
1552
|
+
_noydb: 1,
|
|
1553
|
+
_v: 1,
|
|
1554
|
+
_ts: createdAt,
|
|
1555
|
+
_iv: iv,
|
|
1556
|
+
_data: data,
|
|
1557
|
+
_by: grantor.userId
|
|
1558
|
+
};
|
|
1559
|
+
await store.put(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId, envelope);
|
|
1560
|
+
return { recordId, payload };
|
|
1561
|
+
}
|
|
1562
|
+
async function readMagicLinkGrantRecord(store, vault, contentKey, recordId) {
|
|
1563
|
+
const env = await store.get(vault, MAGIC_LINK_GRANTS_COLLECTION, recordId);
|
|
1564
|
+
if (!env) return null;
|
|
1565
|
+
try {
|
|
1566
|
+
const json = await decrypt(env._iv, env._data, contentKey);
|
|
1567
|
+
return JSON.parse(json);
|
|
1568
|
+
} catch {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
async function listMagicLinkGrants(store, vault, contentKey, token) {
|
|
1573
|
+
const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION);
|
|
1574
|
+
const matching = ids.filter((id) => id === token || id.startsWith(`${token}:`));
|
|
1575
|
+
const out = [];
|
|
1576
|
+
for (const id of matching) {
|
|
1577
|
+
const payload = await readMagicLinkGrantRecord(store, vault, contentKey, id);
|
|
1578
|
+
if (payload) out.push(payload);
|
|
1579
|
+
}
|
|
1580
|
+
return out;
|
|
1581
|
+
}
|
|
1582
|
+
async function unwrapMagicLinkGrant(payload, grantKek) {
|
|
1583
|
+
return unwrapKey(payload.wrappedDek, grantKek);
|
|
1584
|
+
}
|
|
1585
|
+
async function revokeMagicLinkGrant(store, vault, token) {
|
|
1586
|
+
const ids = await store.list(vault, MAGIC_LINK_GRANTS_COLLECTION);
|
|
1587
|
+
const matching = ids.filter((id) => id === token || id.startsWith(`${token}:`));
|
|
1588
|
+
for (const id of matching) {
|
|
1589
|
+
await store.delete(vault, MAGIC_LINK_GRANTS_COLLECTION, id);
|
|
1590
|
+
}
|
|
1591
|
+
return matching.length;
|
|
1592
|
+
}
|
|
1593
|
+
function magicLinkGrantRecordId(token, index) {
|
|
1594
|
+
return index === 0 ? token : `${token}:${index}`;
|
|
1595
|
+
}
|
|
1596
|
+
function isMagicLinkGrantExpired(payload, now = /* @__PURE__ */ new Date()) {
|
|
1597
|
+
return payload.until <= now.toISOString();
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/store/sync-policy.ts
|
|
1601
|
+
var SyncScheduler = class {
|
|
1602
|
+
policy;
|
|
1603
|
+
callbacks;
|
|
1604
|
+
_state = "idle";
|
|
1605
|
+
_lastPushAt = null;
|
|
1606
|
+
_lastPullAt = null;
|
|
1607
|
+
_lastError = null;
|
|
1608
|
+
_lastPushTime = 0;
|
|
1609
|
+
// monotonic ms for minIntervalMs enforcement
|
|
1610
|
+
// Timers
|
|
1611
|
+
debounceTimer = null;
|
|
1612
|
+
pushIntervalTimer = null;
|
|
1613
|
+
pullIntervalTimer = null;
|
|
1614
|
+
// Bound handlers for cleanup
|
|
1615
|
+
boundOnVisibilityChange = null;
|
|
1616
|
+
boundOnBeforeExit = null;
|
|
1617
|
+
boundOnPageHide = null;
|
|
1618
|
+
started = false;
|
|
1619
|
+
constructor(policy, callbacks) {
|
|
1620
|
+
this.policy = policy;
|
|
1621
|
+
this.callbacks = callbacks;
|
|
1622
|
+
if (this.shouldRegisterUnload()) {
|
|
1623
|
+
this.boundOnVisibilityChange = this.handleVisibilityChange.bind(this);
|
|
1624
|
+
this.boundOnPageHide = this.handlePageHide.bind(this);
|
|
1625
|
+
this.boundOnBeforeExit = this.handleBeforeExit.bind(this);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/** Current scheduler status snapshot. */
|
|
1629
|
+
get status() {
|
|
1630
|
+
return {
|
|
1631
|
+
state: this._state,
|
|
1632
|
+
lastPushAt: this._lastPushAt,
|
|
1633
|
+
lastPullAt: this._lastPullAt,
|
|
1634
|
+
lastError: this._lastError,
|
|
1635
|
+
pendingWrites: this.callbacks.getDirtyCount()
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
/** Start the scheduler — registers timers, event listeners. */
|
|
1639
|
+
start() {
|
|
1640
|
+
if (this.started) return;
|
|
1641
|
+
this.started = true;
|
|
1642
|
+
if (this.policy.push.mode === "interval" && this.policy.push.intervalMs) {
|
|
1643
|
+
this.pushIntervalTimer = setInterval(() => {
|
|
1644
|
+
void this.executePush();
|
|
1645
|
+
}, this.policy.push.intervalMs);
|
|
1646
|
+
}
|
|
1647
|
+
if (this.policy.pull.mode === "interval" && this.policy.pull.intervalMs) {
|
|
1648
|
+
this.pullIntervalTimer = setInterval(() => {
|
|
1649
|
+
void this.executePull();
|
|
1650
|
+
}, this.policy.pull.intervalMs);
|
|
1651
|
+
}
|
|
1652
|
+
if (this.policy.pull.mode === "on-focus" && typeof document !== "undefined") {
|
|
1653
|
+
document.addEventListener("visibilitychange", this.handleFocusPull);
|
|
1654
|
+
}
|
|
1655
|
+
if (this.shouldRegisterUnload()) {
|
|
1656
|
+
if (typeof document !== "undefined" && this.boundOnVisibilityChange) {
|
|
1657
|
+
document.addEventListener("visibilitychange", this.boundOnVisibilityChange);
|
|
1658
|
+
}
|
|
1659
|
+
if (typeof globalThis.addEventListener === "function" && this.boundOnPageHide) {
|
|
1660
|
+
globalThis.addEventListener("pagehide", this.boundOnPageHide);
|
|
1661
|
+
}
|
|
1662
|
+
if (typeof process !== "undefined" && this.boundOnBeforeExit) {
|
|
1663
|
+
process.on("beforeExit", this.boundOnBeforeExit);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/** Stop the scheduler — clears timers, removes event listeners. */
|
|
1668
|
+
stop() {
|
|
1669
|
+
if (!this.started) return;
|
|
1670
|
+
this.started = false;
|
|
1671
|
+
if (this.debounceTimer) {
|
|
1672
|
+
clearTimeout(this.debounceTimer);
|
|
1673
|
+
this.debounceTimer = null;
|
|
1674
|
+
}
|
|
1675
|
+
if (this.pushIntervalTimer) {
|
|
1676
|
+
clearInterval(this.pushIntervalTimer);
|
|
1677
|
+
this.pushIntervalTimer = null;
|
|
1678
|
+
}
|
|
1679
|
+
if (this.pullIntervalTimer) {
|
|
1680
|
+
clearInterval(this.pullIntervalTimer);
|
|
1681
|
+
this.pullIntervalTimer = null;
|
|
1682
|
+
}
|
|
1683
|
+
if (this.policy.pull.mode === "on-focus" && typeof document !== "undefined") {
|
|
1684
|
+
document.removeEventListener("visibilitychange", this.handleFocusPull);
|
|
1685
|
+
}
|
|
1686
|
+
if (typeof document !== "undefined" && this.boundOnVisibilityChange) {
|
|
1687
|
+
document.removeEventListener("visibilitychange", this.boundOnVisibilityChange);
|
|
1688
|
+
}
|
|
1689
|
+
if (typeof globalThis.removeEventListener === "function" && this.boundOnPageHide) {
|
|
1690
|
+
globalThis.removeEventListener("pagehide", this.boundOnPageHide);
|
|
1691
|
+
}
|
|
1692
|
+
if (typeof process !== "undefined" && this.boundOnBeforeExit) {
|
|
1693
|
+
process.removeListener("beforeExit", this.boundOnBeforeExit);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Notify the scheduler that a local write occurred.
|
|
1698
|
+
* For `on-change` mode: triggers immediate push (respecting minIntervalMs).
|
|
1699
|
+
* For `debounce` mode: resets the debounce timer.
|
|
1700
|
+
* For `manual` / `interval`: no-op.
|
|
1701
|
+
*/
|
|
1702
|
+
notifyChange() {
|
|
1703
|
+
if (!this.started) return;
|
|
1704
|
+
if (this.policy.push.mode === "on-change") {
|
|
1705
|
+
void this.executePush();
|
|
1706
|
+
} else if (this.policy.push.mode === "debounce") {
|
|
1707
|
+
this.resetDebounce();
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
/** Force an immediate push, bypassing the scheduler. */
|
|
1711
|
+
async forcePush() {
|
|
1712
|
+
await this.executePush();
|
|
1713
|
+
}
|
|
1714
|
+
/** Force an immediate pull, bypassing the scheduler. */
|
|
1715
|
+
async forcePull() {
|
|
1716
|
+
await this.executePull();
|
|
1717
|
+
}
|
|
1718
|
+
// ─── Internal ─────────────────────────────────────────────────────
|
|
1719
|
+
async executePush() {
|
|
1720
|
+
if (this._state === "pushing") return;
|
|
1721
|
+
const minInterval = this.policy.push.minIntervalMs ?? 0;
|
|
1722
|
+
if (minInterval > 0) {
|
|
1723
|
+
const elapsed = Date.now() - this._lastPushTime;
|
|
1724
|
+
if (elapsed < minInterval) {
|
|
1725
|
+
if (this.policy.push.mode === "debounce") {
|
|
1726
|
+
this.scheduleDebounce(minInterval - elapsed);
|
|
1727
|
+
}
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
if (this.callbacks.getDirtyCount() === 0) {
|
|
1732
|
+
this._state = "idle";
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
this._state = "pushing";
|
|
1736
|
+
try {
|
|
1737
|
+
await this.callbacks.push();
|
|
1738
|
+
this._lastPushAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1739
|
+
this._lastPushTime = Date.now();
|
|
1740
|
+
this._lastError = null;
|
|
1741
|
+
this._state = this.callbacks.getDirtyCount() > 0 ? "pending" : "idle";
|
|
1742
|
+
} catch (err) {
|
|
1743
|
+
this._lastError = err instanceof Error ? err : new Error(String(err));
|
|
1744
|
+
this._state = "error";
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
async executePull() {
|
|
1748
|
+
if (this._state === "pulling") return;
|
|
1749
|
+
const previousState = this._state;
|
|
1750
|
+
this._state = "pulling";
|
|
1751
|
+
try {
|
|
1752
|
+
await this.callbacks.pull();
|
|
1753
|
+
this._lastPullAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1754
|
+
this._lastError = null;
|
|
1755
|
+
this._state = previousState === "pending" ? "pending" : "idle";
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
this._lastError = err instanceof Error ? err : new Error(String(err));
|
|
1758
|
+
this._state = "error";
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
resetDebounce() {
|
|
1762
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
1763
|
+
const ms = this.policy.push.debounceMs ?? 3e4;
|
|
1764
|
+
this._state = "pending";
|
|
1765
|
+
this.scheduleDebounce(ms);
|
|
1766
|
+
}
|
|
1767
|
+
scheduleDebounce(ms) {
|
|
1768
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
1769
|
+
this.debounceTimer = setTimeout(() => {
|
|
1770
|
+
this.debounceTimer = null;
|
|
1771
|
+
void this.executePush();
|
|
1772
|
+
}, ms);
|
|
1773
|
+
}
|
|
1774
|
+
shouldRegisterUnload() {
|
|
1775
|
+
const onUnload = this.policy.push.onUnload;
|
|
1776
|
+
if (onUnload !== void 0) return onUnload;
|
|
1777
|
+
return this.policy.push.mode !== "manual";
|
|
1778
|
+
}
|
|
1779
|
+
// ─── Event handlers ───────────────────────────────────────────────
|
|
1780
|
+
handleVisibilityChange() {
|
|
1781
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
1782
|
+
this.fireUnloadPush();
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
handlePageHide() {
|
|
1786
|
+
this.fireUnloadPush();
|
|
1787
|
+
}
|
|
1788
|
+
handleBeforeExit() {
|
|
1789
|
+
this.fireUnloadPush();
|
|
1790
|
+
}
|
|
1791
|
+
handleFocusPull = () => {
|
|
1792
|
+
if (typeof document !== "undefined" && document.visibilityState === "visible") {
|
|
1793
|
+
void this.executePull();
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
fireUnloadPush() {
|
|
1797
|
+
if (this.callbacks.getDirtyCount() === 0) return;
|
|
1798
|
+
void this.callbacks.push().catch(() => {
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
// src/team/sync.ts
|
|
1804
|
+
var SyncEngine = class {
|
|
1805
|
+
local;
|
|
1806
|
+
remote;
|
|
1807
|
+
strategy;
|
|
1808
|
+
emitter;
|
|
1809
|
+
vault;
|
|
1810
|
+
role;
|
|
1811
|
+
label;
|
|
1812
|
+
dirty = [];
|
|
1813
|
+
lastPush = null;
|
|
1814
|
+
lastPull = null;
|
|
1815
|
+
loaded = false;
|
|
1816
|
+
autoSyncInterval = null;
|
|
1817
|
+
isOnline = true;
|
|
1818
|
+
/** Sync scheduler. Manages push/pull timing. */
|
|
1819
|
+
scheduler;
|
|
1820
|
+
/** Per-collection conflict resolvers registered by Collection instances. */
|
|
1821
|
+
conflictResolvers = /* @__PURE__ */ new Map();
|
|
1822
|
+
constructor(opts) {
|
|
1823
|
+
this.local = opts.local;
|
|
1824
|
+
this.remote = opts.remote;
|
|
1825
|
+
this.vault = opts.vault;
|
|
1826
|
+
this.strategy = opts.strategy;
|
|
1827
|
+
this.emitter = opts.emitter;
|
|
1828
|
+
this.role = opts.role ?? "sync-peer";
|
|
1829
|
+
this.label = opts.label;
|
|
1830
|
+
const policy = opts.syncPolicy;
|
|
1831
|
+
if (policy && policy.push.mode !== "manual") {
|
|
1832
|
+
this.scheduler = new SyncScheduler(policy, {
|
|
1833
|
+
push: () => this.push().then(() => {
|
|
1834
|
+
}),
|
|
1835
|
+
pull: () => this.pull().then(() => {
|
|
1836
|
+
}),
|
|
1837
|
+
getDirtyCount: () => this.dirty.length
|
|
1838
|
+
});
|
|
1839
|
+
} else {
|
|
1840
|
+
this.scheduler = null;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
/** Start the sync scheduler. Called after vault is fully opened. */
|
|
1844
|
+
startScheduler() {
|
|
1845
|
+
this.scheduler?.start();
|
|
1846
|
+
}
|
|
1847
|
+
/** Stop the sync scheduler. Called on close. */
|
|
1848
|
+
stopScheduler() {
|
|
1849
|
+
this.scheduler?.stop();
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Register a per-collection conflict resolver.
|
|
1853
|
+
* Called by Collection when `conflictPolicy` is set.
|
|
1854
|
+
*/
|
|
1855
|
+
registerConflictResolver(collection, resolver) {
|
|
1856
|
+
this.conflictResolvers.set(collection, resolver);
|
|
1857
|
+
}
|
|
1858
|
+
/** Record a local change for later push. */
|
|
1859
|
+
async trackChange(collection, id, action, version) {
|
|
1860
|
+
await this.ensureLoaded();
|
|
1861
|
+
const idx = this.dirty.findIndex((d) => d.collection === collection && d.id === id);
|
|
1862
|
+
const entry = {
|
|
1863
|
+
vault: this.vault,
|
|
1864
|
+
collection,
|
|
1865
|
+
id,
|
|
1866
|
+
action,
|
|
1867
|
+
version,
|
|
1868
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1869
|
+
};
|
|
1870
|
+
if (idx >= 0) {
|
|
1871
|
+
this.dirty[idx] = entry;
|
|
1872
|
+
} else {
|
|
1873
|
+
this.dirty.push(entry);
|
|
1874
|
+
}
|
|
1875
|
+
await this.persistMeta();
|
|
1876
|
+
this.scheduler?.notifyChange();
|
|
1877
|
+
}
|
|
1878
|
+
/** Push dirty records to remote adapter. Accepts optional `PushOptions` for partial sync. */
|
|
1879
|
+
async push(options) {
|
|
1880
|
+
await this.ensureLoaded();
|
|
1881
|
+
let pushed = 0;
|
|
1882
|
+
const conflicts = [];
|
|
1883
|
+
const errors = [];
|
|
1884
|
+
const completed = [];
|
|
1885
|
+
for (let i = 0; i < this.dirty.length; i++) {
|
|
1886
|
+
const entry = this.dirty[i];
|
|
1887
|
+
if (options?.collections && !options.collections.includes(entry.collection)) {
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
try {
|
|
1891
|
+
if (entry.action === "delete") {
|
|
1892
|
+
await this.remote.delete(this.vault, entry.collection, entry.id);
|
|
1893
|
+
completed.push(i);
|
|
1894
|
+
pushed++;
|
|
1895
|
+
} else {
|
|
1896
|
+
const envelope = await this.local.get(this.vault, entry.collection, entry.id);
|
|
1897
|
+
if (!envelope) {
|
|
1898
|
+
completed.push(i);
|
|
1899
|
+
continue;
|
|
1900
|
+
}
|
|
1901
|
+
try {
|
|
1902
|
+
await this.remote.put(
|
|
1903
|
+
this.vault,
|
|
1904
|
+
entry.collection,
|
|
1905
|
+
entry.id,
|
|
1906
|
+
envelope,
|
|
1907
|
+
entry.version - 1
|
|
1908
|
+
);
|
|
1909
|
+
completed.push(i);
|
|
1910
|
+
pushed++;
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
if (err instanceof ConflictError) {
|
|
1913
|
+
const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id);
|
|
1914
|
+
if (remoteEnvelope) {
|
|
1915
|
+
const { handled, conflict } = await this.handleConflict(
|
|
1916
|
+
entry.collection,
|
|
1917
|
+
entry.id,
|
|
1918
|
+
envelope,
|
|
1919
|
+
remoteEnvelope,
|
|
1920
|
+
"push"
|
|
1921
|
+
);
|
|
1922
|
+
conflicts.push(conflict);
|
|
1923
|
+
if (handled === "local") {
|
|
1924
|
+
await this.remote.put(this.vault, entry.collection, entry.id, conflict.local);
|
|
1925
|
+
completed.push(i);
|
|
1926
|
+
pushed++;
|
|
1927
|
+
} else if (handled === "remote") {
|
|
1928
|
+
await this.local.put(this.vault, entry.collection, entry.id, conflict.remote);
|
|
1929
|
+
completed.push(i);
|
|
1930
|
+
} else if (handled === "merged" && conflict.local !== envelope) {
|
|
1931
|
+
const merged = conflict.local;
|
|
1932
|
+
await this.remote.put(this.vault, entry.collection, entry.id, merged);
|
|
1933
|
+
await this.local.put(this.vault, entry.collection, entry.id, merged);
|
|
1934
|
+
completed.push(i);
|
|
1935
|
+
pushed++;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
} else {
|
|
1939
|
+
throw err;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
for (const i of completed.sort((a, b) => b - a)) {
|
|
1948
|
+
this.dirty.splice(i, 1);
|
|
1949
|
+
}
|
|
1950
|
+
this.lastPush = (/* @__PURE__ */ new Date()).toISOString();
|
|
1951
|
+
await this.persistMeta();
|
|
1952
|
+
const result = { pushed, conflicts, errors };
|
|
1953
|
+
this.emitter.emit("sync:push", result);
|
|
1954
|
+
return result;
|
|
1955
|
+
}
|
|
1956
|
+
/** Pull remote records to local adapter. Accepts optional `PullOptions` for partial sync. */
|
|
1957
|
+
async pull(options) {
|
|
1958
|
+
await this.ensureLoaded();
|
|
1959
|
+
let pulled = 0;
|
|
1960
|
+
const conflicts = [];
|
|
1961
|
+
const errors = [];
|
|
1962
|
+
try {
|
|
1963
|
+
const remoteSnapshot = await this.remote.loadAll(this.vault);
|
|
1964
|
+
for (const [collName, records] of Object.entries(remoteSnapshot)) {
|
|
1965
|
+
if (options?.collections && !options.collections.includes(collName)) {
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1968
|
+
for (const [id, remoteEnvelope] of Object.entries(records)) {
|
|
1969
|
+
if (options?.modifiedSince && remoteEnvelope._ts <= options.modifiedSince) {
|
|
1970
|
+
continue;
|
|
1971
|
+
}
|
|
1972
|
+
try {
|
|
1973
|
+
const localEnvelope = await this.local.get(this.vault, collName, id);
|
|
1974
|
+
if (!localEnvelope) {
|
|
1975
|
+
await this.local.put(this.vault, collName, id, remoteEnvelope);
|
|
1976
|
+
pulled++;
|
|
1977
|
+
} else if (remoteEnvelope._v > localEnvelope._v) {
|
|
1978
|
+
const isDirty = this.dirty.some((d) => d.collection === collName && d.id === id);
|
|
1979
|
+
if (isDirty) {
|
|
1980
|
+
const { handled, conflict } = await this.handleConflict(
|
|
1981
|
+
collName,
|
|
1982
|
+
id,
|
|
1983
|
+
localEnvelope,
|
|
1984
|
+
remoteEnvelope,
|
|
1985
|
+
"pull"
|
|
1986
|
+
);
|
|
1987
|
+
conflicts.push(conflict);
|
|
1988
|
+
if (handled === "remote") {
|
|
1989
|
+
await this.local.put(this.vault, collName, id, conflict.remote);
|
|
1990
|
+
this.dirty = this.dirty.filter((d) => !(d.collection === collName && d.id === id));
|
|
1991
|
+
pulled++;
|
|
1992
|
+
} else if (handled === "merged" && conflict.local !== localEnvelope) {
|
|
1993
|
+
const merged = conflict.local;
|
|
1994
|
+
await this.local.put(this.vault, collName, id, merged);
|
|
1995
|
+
this.dirty = this.dirty.filter((d) => !(d.collection === collName && d.id === id));
|
|
1996
|
+
pulled++;
|
|
1997
|
+
}
|
|
1998
|
+
} else {
|
|
1999
|
+
await this.local.put(this.vault, collName, id, remoteEnvelope);
|
|
2000
|
+
pulled++;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
} catch (err) {
|
|
2004
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
} catch (err) {
|
|
2009
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
2010
|
+
}
|
|
2011
|
+
this.lastPull = (/* @__PURE__ */ new Date()).toISOString();
|
|
2012
|
+
await this.persistMeta();
|
|
2013
|
+
const result = { pulled, conflicts, errors };
|
|
2014
|
+
this.emitter.emit("sync:pull", result);
|
|
2015
|
+
return result;
|
|
2016
|
+
}
|
|
2017
|
+
/** Bidirectional sync: pull then push. */
|
|
2018
|
+
async sync(options) {
|
|
2019
|
+
const pullResult = await this.pull(options?.pull);
|
|
2020
|
+
const pushResult = await this.push(options?.push);
|
|
2021
|
+
return { pull: pullResult, push: pushResult };
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Push a specific subset of dirty entries (for sync transactions, ).
|
|
2025
|
+
* Entries are matched by collection+id from the dirty log; matched entries
|
|
2026
|
+
* are removed from the dirty log on success.
|
|
2027
|
+
*/
|
|
2028
|
+
async pushFiltered(predicate) {
|
|
2029
|
+
await this.ensureLoaded();
|
|
2030
|
+
let pushed = 0;
|
|
2031
|
+
const conflicts = [];
|
|
2032
|
+
const errors = [];
|
|
2033
|
+
const completed = [];
|
|
2034
|
+
for (let i = 0; i < this.dirty.length; i++) {
|
|
2035
|
+
const entry = this.dirty[i];
|
|
2036
|
+
if (!predicate(entry)) continue;
|
|
2037
|
+
try {
|
|
2038
|
+
if (entry.action === "delete") {
|
|
2039
|
+
await this.remote.delete(this.vault, entry.collection, entry.id);
|
|
2040
|
+
completed.push(i);
|
|
2041
|
+
pushed++;
|
|
2042
|
+
} else {
|
|
2043
|
+
const envelope = await this.local.get(this.vault, entry.collection, entry.id);
|
|
2044
|
+
if (!envelope) {
|
|
2045
|
+
completed.push(i);
|
|
2046
|
+
continue;
|
|
2047
|
+
}
|
|
2048
|
+
try {
|
|
2049
|
+
await this.remote.put(
|
|
2050
|
+
this.vault,
|
|
2051
|
+
entry.collection,
|
|
2052
|
+
entry.id,
|
|
2053
|
+
envelope,
|
|
2054
|
+
entry.version - 1
|
|
2055
|
+
);
|
|
2056
|
+
completed.push(i);
|
|
2057
|
+
pushed++;
|
|
2058
|
+
} catch (err) {
|
|
2059
|
+
if (err instanceof ConflictError) {
|
|
2060
|
+
const remoteEnvelope = await this.remote.get(this.vault, entry.collection, entry.id);
|
|
2061
|
+
if (remoteEnvelope) {
|
|
2062
|
+
const { handled, conflict } = await this.handleConflict(
|
|
2063
|
+
entry.collection,
|
|
2064
|
+
entry.id,
|
|
2065
|
+
envelope,
|
|
2066
|
+
remoteEnvelope,
|
|
2067
|
+
"push"
|
|
2068
|
+
);
|
|
2069
|
+
conflicts.push(conflict);
|
|
2070
|
+
if (handled === "local") {
|
|
2071
|
+
await this.remote.put(this.vault, entry.collection, entry.id, conflict.local);
|
|
2072
|
+
completed.push(i);
|
|
2073
|
+
pushed++;
|
|
2074
|
+
} else if (handled === "remote") {
|
|
2075
|
+
await this.local.put(this.vault, entry.collection, entry.id, conflict.remote);
|
|
2076
|
+
completed.push(i);
|
|
2077
|
+
} else if (handled === "merged" && conflict.local !== envelope) {
|
|
2078
|
+
const merged = conflict.local;
|
|
2079
|
+
await this.remote.put(this.vault, entry.collection, entry.id, merged);
|
|
2080
|
+
await this.local.put(this.vault, entry.collection, entry.id, merged);
|
|
2081
|
+
completed.push(i);
|
|
2082
|
+
pushed++;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
} else {
|
|
2086
|
+
throw err;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
} catch (err) {
|
|
2091
|
+
errors.push(err instanceof Error ? err : new Error(String(err)));
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
for (const i of completed.sort((a, b) => b - a)) {
|
|
2095
|
+
this.dirty.splice(i, 1);
|
|
2096
|
+
}
|
|
2097
|
+
this.lastPush = (/* @__PURE__ */ new Date()).toISOString();
|
|
2098
|
+
await this.persistMeta();
|
|
2099
|
+
const result = { pushed, conflicts, errors };
|
|
2100
|
+
this.emitter.emit("sync:push", result);
|
|
2101
|
+
return result;
|
|
2102
|
+
}
|
|
2103
|
+
/** Get current sync status. */
|
|
2104
|
+
status() {
|
|
2105
|
+
return {
|
|
2106
|
+
dirty: this.dirty.length,
|
|
2107
|
+
lastPush: this.lastPush,
|
|
2108
|
+
lastPull: this.lastPull,
|
|
2109
|
+
online: this.isOnline
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
// ─── Auto-Sync ───────────────────────────────────────────────────
|
|
2113
|
+
/** Start auto-sync: listen for online/offline events, optional periodic sync. */
|
|
2114
|
+
startAutoSync(intervalMs) {
|
|
2115
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
2116
|
+
globalThis.addEventListener("online", this.handleOnline);
|
|
2117
|
+
globalThis.addEventListener("offline", this.handleOffline);
|
|
2118
|
+
}
|
|
2119
|
+
if (intervalMs && intervalMs > 0) {
|
|
2120
|
+
this.autoSyncInterval = setInterval(() => {
|
|
2121
|
+
if (this.isOnline) {
|
|
2122
|
+
void this.sync();
|
|
2123
|
+
}
|
|
2124
|
+
}, intervalMs);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
/** Stop auto-sync and scheduler. */
|
|
2128
|
+
stopAutoSync() {
|
|
2129
|
+
this.stopScheduler();
|
|
2130
|
+
if (typeof globalThis.removeEventListener === "function") {
|
|
2131
|
+
globalThis.removeEventListener("online", this.handleOnline);
|
|
2132
|
+
globalThis.removeEventListener("offline", this.handleOffline);
|
|
2133
|
+
}
|
|
2134
|
+
if (this.autoSyncInterval) {
|
|
2135
|
+
clearInterval(this.autoSyncInterval);
|
|
2136
|
+
this.autoSyncInterval = null;
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
handleOnline = () => {
|
|
2140
|
+
this.isOnline = true;
|
|
2141
|
+
this.emitter.emit("sync:online", void 0);
|
|
2142
|
+
void this.sync();
|
|
2143
|
+
};
|
|
2144
|
+
handleOffline = () => {
|
|
2145
|
+
this.isOnline = false;
|
|
2146
|
+
this.emitter.emit("sync:offline", void 0);
|
|
2147
|
+
};
|
|
2148
|
+
/**
|
|
2149
|
+
* Resolve a conflict, checking per-collection resolvers first,
|
|
2150
|
+
* then falling back to the db-level `ConflictStrategy`.
|
|
2151
|
+
*
|
|
2152
|
+
* Returns the resolved `Conflict` object (possibly with `resolve` set for
|
|
2153
|
+
* manual mode) and a `handled` discriminant:
|
|
2154
|
+
* - `'local'` — keep the local envelope; push it to remote.
|
|
2155
|
+
* - `'remote'` — keep the remote envelope; update local.
|
|
2156
|
+
* - `'merged'` — a custom merge fn produced a new envelope stored as `conflict.local`.
|
|
2157
|
+
* - `'deferred'` — manual mode, resolve was not called synchronously.
|
|
2158
|
+
*/
|
|
2159
|
+
async handleConflict(collection, id, local, remote, _phase) {
|
|
2160
|
+
const resolver = this.conflictResolvers.get(collection);
|
|
2161
|
+
if (resolver) {
|
|
2162
|
+
const winner = await resolver(id, local, remote);
|
|
2163
|
+
const base = {
|
|
2164
|
+
vault: this.vault,
|
|
2165
|
+
collection,
|
|
2166
|
+
id,
|
|
2167
|
+
local,
|
|
2168
|
+
remote,
|
|
2169
|
+
localVersion: local._v,
|
|
2170
|
+
remoteVersion: remote._v
|
|
2171
|
+
};
|
|
2172
|
+
if (winner === null) return { handled: "deferred", conflict: base };
|
|
2173
|
+
if (winner === local) return { handled: "local", conflict: base };
|
|
2174
|
+
if (winner === remote) return { handled: "remote", conflict: base };
|
|
2175
|
+
return {
|
|
2176
|
+
handled: "merged",
|
|
2177
|
+
conflict: { ...base, local: winner, localVersion: winner._v }
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
const baseConflict = {
|
|
2181
|
+
vault: this.vault,
|
|
2182
|
+
collection,
|
|
2183
|
+
id,
|
|
2184
|
+
local,
|
|
2185
|
+
remote,
|
|
2186
|
+
localVersion: local._v,
|
|
2187
|
+
remoteVersion: remote._v
|
|
2188
|
+
};
|
|
2189
|
+
this.emitter.emit("sync:conflict", baseConflict);
|
|
2190
|
+
const side = this.legacyResolve(baseConflict);
|
|
2191
|
+
return { handled: side, conflict: baseConflict };
|
|
2192
|
+
}
|
|
2193
|
+
/** DB-level ConflictStrategy resolution (legacy, kept for backward compat). */
|
|
2194
|
+
legacyResolve(conflict) {
|
|
2195
|
+
if (typeof this.strategy === "function") {
|
|
2196
|
+
return this.strategy(conflict);
|
|
2197
|
+
}
|
|
2198
|
+
switch (this.strategy) {
|
|
2199
|
+
case "local-wins":
|
|
2200
|
+
return "local";
|
|
2201
|
+
case "remote-wins":
|
|
2202
|
+
return "remote";
|
|
2203
|
+
case "version":
|
|
2204
|
+
default:
|
|
2205
|
+
return conflict.localVersion >= conflict.remoteVersion ? "local" : "remote";
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
// ─── Persistence ─────────────────────────────────────────────────
|
|
2209
|
+
async ensureLoaded() {
|
|
2210
|
+
if (this.loaded) return;
|
|
2211
|
+
const envelope = await this.local.get(this.vault, "_sync", "meta");
|
|
2212
|
+
if (envelope) {
|
|
2213
|
+
const meta = JSON.parse(envelope._data);
|
|
2214
|
+
this.dirty = [...meta.dirty];
|
|
2215
|
+
this.lastPush = meta.last_push;
|
|
2216
|
+
this.lastPull = meta.last_pull;
|
|
2217
|
+
}
|
|
2218
|
+
this.loaded = true;
|
|
2219
|
+
}
|
|
2220
|
+
async persistMeta() {
|
|
2221
|
+
const meta = {
|
|
2222
|
+
_noydb_sync: NOYDB_SYNC_VERSION,
|
|
2223
|
+
last_push: this.lastPush,
|
|
2224
|
+
last_pull: this.lastPull,
|
|
2225
|
+
dirty: this.dirty
|
|
2226
|
+
};
|
|
2227
|
+
const envelope = {
|
|
2228
|
+
_noydb: 1,
|
|
2229
|
+
_v: 1,
|
|
2230
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2231
|
+
_iv: "",
|
|
2232
|
+
_data: JSON.stringify(meta)
|
|
2233
|
+
};
|
|
2234
|
+
await this.local.put(this.vault, "_sync", "meta", envelope);
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
|
|
2238
|
+
// src/team/sync-transaction.ts
|
|
2239
|
+
var SyncTransaction = class {
|
|
2240
|
+
comp;
|
|
2241
|
+
engine;
|
|
2242
|
+
ops = [];
|
|
2243
|
+
/** @internal — constructed by `Noydb.transaction()` */
|
|
2244
|
+
constructor(comp, engine) {
|
|
2245
|
+
this.comp = comp;
|
|
2246
|
+
this.engine = engine;
|
|
2247
|
+
}
|
|
2248
|
+
/** Stage a record write. Does not write to any adapter until `commit()`. */
|
|
2249
|
+
put(collection, id, record) {
|
|
2250
|
+
this.ops.push({ type: "put", collection, id, record });
|
|
2251
|
+
return this;
|
|
2252
|
+
}
|
|
2253
|
+
/** Stage a record delete. Does not write to any adapter until `commit()`. */
|
|
2254
|
+
delete(collection, id) {
|
|
2255
|
+
this.ops.push({ type: "delete", collection, id });
|
|
2256
|
+
return this;
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Commit the transaction.
|
|
2260
|
+
*
|
|
2261
|
+
* Phase 1 — writes all staged operations to the local adapter via the
|
|
2262
|
+
* collection layer (encryption + dirty-log tracking).
|
|
2263
|
+
*
|
|
2264
|
+
* Phase 2 — pushes only the records that were written in this
|
|
2265
|
+
* transaction to the remote adapter. Existing dirty entries from
|
|
2266
|
+
* outside this transaction are not affected.
|
|
2267
|
+
*
|
|
2268
|
+
* If any record conflicts during the push, `status` is `'conflict'`
|
|
2269
|
+
* and `conflicts` lists the affected records. No automatic rollback is
|
|
2270
|
+
* performed.
|
|
2271
|
+
*/
|
|
2272
|
+
async commit() {
|
|
2273
|
+
for (const op of this.ops) {
|
|
2274
|
+
if (op.type === "put") {
|
|
2275
|
+
await this.comp.collection(op.collection).put(op.id, op.record);
|
|
2276
|
+
} else {
|
|
2277
|
+
await this.comp.collection(op.collection).delete(op.id);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
const opSet = /* @__PURE__ */ new Set();
|
|
2281
|
+
for (const op of this.ops) {
|
|
2282
|
+
opSet.add(`${op.collection}::${op.id}`);
|
|
2283
|
+
}
|
|
2284
|
+
const pushResult = await this.engine.pushFiltered(
|
|
2285
|
+
(entry) => opSet.has(`${entry.collection}::${entry.id}`)
|
|
2286
|
+
);
|
|
2287
|
+
return {
|
|
2288
|
+
status: pushResult.conflicts.length > 0 ? "conflict" : "committed",
|
|
2289
|
+
pushed: pushResult.pushed,
|
|
2290
|
+
conflicts: pushResult.conflicts
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
// src/team/presence.ts
|
|
2296
|
+
var PresenceHandle = class {
|
|
2297
|
+
adapter;
|
|
2298
|
+
syncAdapter;
|
|
2299
|
+
vault;
|
|
2300
|
+
collectionName;
|
|
2301
|
+
userId;
|
|
2302
|
+
encrypted;
|
|
2303
|
+
getDEK;
|
|
2304
|
+
staleMs;
|
|
2305
|
+
pollIntervalMs;
|
|
2306
|
+
channel;
|
|
2307
|
+
storageCollection;
|
|
2308
|
+
presenceKey = null;
|
|
2309
|
+
subscribers = [];
|
|
2310
|
+
unsubscribePubSub = null;
|
|
2311
|
+
pollTimer = null;
|
|
2312
|
+
stopped = false;
|
|
2313
|
+
constructor(opts) {
|
|
2314
|
+
this.adapter = opts.adapter;
|
|
2315
|
+
this.syncAdapter = opts.syncAdapter;
|
|
2316
|
+
this.vault = opts.vault;
|
|
2317
|
+
this.collectionName = opts.collectionName;
|
|
2318
|
+
this.userId = opts.userId;
|
|
2319
|
+
this.encrypted = opts.encrypted;
|
|
2320
|
+
this.getDEK = opts.getDEK;
|
|
2321
|
+
this.staleMs = opts.staleMs ?? 3e4;
|
|
2322
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 5e3;
|
|
2323
|
+
this.channel = `${opts.vault}:${opts.collectionName}:presence`;
|
|
2324
|
+
this.storageCollection = `_presence_${opts.collectionName}`;
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Announce yourself (or update your cursor/status).
|
|
2328
|
+
* Encrypts `payload` with the presence key and publishes it.
|
|
2329
|
+
*/
|
|
2330
|
+
async update(payload) {
|
|
2331
|
+
if (this.stopped) return;
|
|
2332
|
+
const key = await this.getPresenceKey();
|
|
2333
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2334
|
+
const plaintext = JSON.stringify({ userId: this.userId, lastSeen: now, payload });
|
|
2335
|
+
let encryptedPayload;
|
|
2336
|
+
if (this.encrypted && key) {
|
|
2337
|
+
const iv = generateIV();
|
|
2338
|
+
const ivB64 = bufferToBase64(iv);
|
|
2339
|
+
const { data } = await encrypt(plaintext, key);
|
|
2340
|
+
encryptedPayload = JSON.stringify({ iv: ivB64, data });
|
|
2341
|
+
} else {
|
|
2342
|
+
encryptedPayload = plaintext;
|
|
2343
|
+
}
|
|
2344
|
+
const pubAdapter = this.getPubSubAdapter();
|
|
2345
|
+
if (pubAdapter?.presencePublish) {
|
|
2346
|
+
await pubAdapter.presencePublish(this.channel, encryptedPayload);
|
|
2347
|
+
}
|
|
2348
|
+
await this.writeStorageRecord(payload, now);
|
|
2349
|
+
}
|
|
2350
|
+
/**
|
|
2351
|
+
* Subscribe to presence updates. The callback receives a filtered, decrypted
|
|
2352
|
+
* list of all currently-active peers (excluding yourself, excluding stale).
|
|
2353
|
+
*
|
|
2354
|
+
* Returns an unsubscribe function. Also call `stop()` to release all resources.
|
|
2355
|
+
*/
|
|
2356
|
+
subscribe(cb) {
|
|
2357
|
+
if (this.stopped) return () => {
|
|
2358
|
+
};
|
|
2359
|
+
this.subscribers.push(cb);
|
|
2360
|
+
if (this.subscribers.length === 1) {
|
|
2361
|
+
this.startListening();
|
|
2362
|
+
}
|
|
2363
|
+
return () => {
|
|
2364
|
+
this.subscribers = this.subscribers.filter((s) => s !== cb);
|
|
2365
|
+
if (this.subscribers.length === 0) this.stopListening();
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
/** Stop all listening and clear resources. */
|
|
2369
|
+
stop() {
|
|
2370
|
+
this.stopped = true;
|
|
2371
|
+
this.stopListening();
|
|
2372
|
+
this.subscribers = [];
|
|
2373
|
+
}
|
|
2374
|
+
// ─── Private ────────────────────────────────────────────────────────
|
|
2375
|
+
async getPresenceKey() {
|
|
2376
|
+
if (!this.encrypted) return null;
|
|
2377
|
+
if (!this.presenceKey) {
|
|
2378
|
+
try {
|
|
2379
|
+
const dek = await this.getDEK(this.collectionName);
|
|
2380
|
+
this.presenceKey = await derivePresenceKey(dek, this.collectionName);
|
|
2381
|
+
} catch {
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
return this.presenceKey;
|
|
2385
|
+
}
|
|
2386
|
+
getPubSubAdapter() {
|
|
2387
|
+
if (this.syncAdapter?.presencePublish) return this.syncAdapter;
|
|
2388
|
+
if (this.adapter.presencePublish) return this.adapter;
|
|
2389
|
+
return void 0;
|
|
2390
|
+
}
|
|
2391
|
+
startListening() {
|
|
2392
|
+
const pubAdapter = this.getPubSubAdapter();
|
|
2393
|
+
if (pubAdapter?.presenceSubscribe) {
|
|
2394
|
+
this.unsubscribePubSub = pubAdapter.presenceSubscribe(
|
|
2395
|
+
this.channel,
|
|
2396
|
+
(encryptedPayload) => {
|
|
2397
|
+
void this.handlePubSubMessage(encryptedPayload);
|
|
2398
|
+
}
|
|
2399
|
+
);
|
|
2400
|
+
} else {
|
|
2401
|
+
this.pollTimer = setInterval(
|
|
2402
|
+
() => {
|
|
2403
|
+
void this.pollStoragePresence();
|
|
2404
|
+
},
|
|
2405
|
+
this.pollIntervalMs
|
|
2406
|
+
);
|
|
2407
|
+
void this.pollStoragePresence();
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
stopListening() {
|
|
2411
|
+
if (this.unsubscribePubSub) {
|
|
2412
|
+
this.unsubscribePubSub();
|
|
2413
|
+
this.unsubscribePubSub = null;
|
|
2414
|
+
}
|
|
2415
|
+
if (this.pollTimer) {
|
|
2416
|
+
clearInterval(this.pollTimer);
|
|
2417
|
+
this.pollTimer = null;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
async handlePubSubMessage(encryptedPayload) {
|
|
2421
|
+
try {
|
|
2422
|
+
const peer = await this.decryptPresencePayload(encryptedPayload);
|
|
2423
|
+
if (!peer || peer.userId === this.userId) return;
|
|
2424
|
+
const cutoff = new Date(Date.now() - this.staleMs).toISOString();
|
|
2425
|
+
if (peer.lastSeen < cutoff) return;
|
|
2426
|
+
await this.pollStoragePresence();
|
|
2427
|
+
} catch {
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
async decryptPresencePayload(encryptedPayload) {
|
|
2431
|
+
const key = await this.getPresenceKey();
|
|
2432
|
+
if (!this.encrypted || !key) {
|
|
2433
|
+
return JSON.parse(encryptedPayload);
|
|
2434
|
+
}
|
|
2435
|
+
const { iv: ivB64, data } = JSON.parse(encryptedPayload);
|
|
2436
|
+
const plaintext = await decrypt(ivB64, data, key);
|
|
2437
|
+
return JSON.parse(plaintext);
|
|
2438
|
+
}
|
|
2439
|
+
async writeStorageRecord(payload, now) {
|
|
2440
|
+
const key = await this.getPresenceKey();
|
|
2441
|
+
const plaintext = JSON.stringify(payload);
|
|
2442
|
+
let iv = "";
|
|
2443
|
+
let data;
|
|
2444
|
+
if (this.encrypted && key) {
|
|
2445
|
+
const ivBytes = generateIV();
|
|
2446
|
+
iv = bufferToBase64(ivBytes);
|
|
2447
|
+
const result = await encrypt(plaintext, key);
|
|
2448
|
+
data = result.data;
|
|
2449
|
+
} else {
|
|
2450
|
+
data = plaintext;
|
|
2451
|
+
}
|
|
2452
|
+
const record = { userId: this.userId, lastSeen: now, iv, data };
|
|
2453
|
+
const json = JSON.stringify(record);
|
|
2454
|
+
const storeAdapter = this.syncAdapter ?? this.adapter;
|
|
2455
|
+
const envelope = {
|
|
2456
|
+
_noydb: 1,
|
|
2457
|
+
_v: 1,
|
|
2458
|
+
_ts: now,
|
|
2459
|
+
_iv: "",
|
|
2460
|
+
_data: json
|
|
2461
|
+
};
|
|
2462
|
+
try {
|
|
2463
|
+
await storeAdapter.put(
|
|
2464
|
+
this.vault,
|
|
2465
|
+
this.storageCollection,
|
|
2466
|
+
this.userId,
|
|
2467
|
+
envelope
|
|
2468
|
+
);
|
|
2469
|
+
} catch {
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
async pollStoragePresence() {
|
|
2473
|
+
if (this.stopped || this.subscribers.length === 0) return;
|
|
2474
|
+
try {
|
|
2475
|
+
const storeAdapter = this.syncAdapter ?? this.adapter;
|
|
2476
|
+
const ids = await storeAdapter.list(this.vault, this.storageCollection);
|
|
2477
|
+
const cutoff = new Date(Date.now() - this.staleMs).toISOString();
|
|
2478
|
+
const peers = [];
|
|
2479
|
+
for (const id of ids) {
|
|
2480
|
+
if (id === this.userId) continue;
|
|
2481
|
+
const envelope = await storeAdapter.get(this.vault, this.storageCollection, id);
|
|
2482
|
+
if (!envelope) continue;
|
|
2483
|
+
const record = JSON.parse(envelope._data);
|
|
2484
|
+
if (record.lastSeen < cutoff) continue;
|
|
2485
|
+
let peerPayload;
|
|
2486
|
+
if (this.encrypted && this.presenceKey && record.iv) {
|
|
2487
|
+
const plaintext = await decrypt(record.iv, record.data, this.presenceKey);
|
|
2488
|
+
peerPayload = JSON.parse(plaintext);
|
|
2489
|
+
} else {
|
|
2490
|
+
peerPayload = JSON.parse(record.data);
|
|
2491
|
+
}
|
|
2492
|
+
peers.push({ userId: record.userId, payload: peerPayload, lastSeen: record.lastSeen });
|
|
2493
|
+
}
|
|
2494
|
+
for (const cb of this.subscribers) {
|
|
2495
|
+
cb(peers);
|
|
2496
|
+
}
|
|
2497
|
+
} catch {
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
|
|
2502
|
+
// src/team/sync-credentials.ts
|
|
2503
|
+
var SYNC_CREDENTIALS_COLLECTION = "_sync_credentials";
|
|
2504
|
+
function requireAdminAccess(keyring) {
|
|
2505
|
+
if (keyring.role !== "owner" && keyring.role !== "admin") {
|
|
2506
|
+
throw new PermissionDeniedError(
|
|
2507
|
+
`Sync credentials require owner or admin role. Current role: "${keyring.role}"`
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
async function putCredential(adapter, vault, keyring, credential) {
|
|
2512
|
+
requireAdminAccess(keyring);
|
|
2513
|
+
const getDek = await ensureCollectionDEK(adapter, vault, keyring);
|
|
2514
|
+
const dek = await getDek(SYNC_CREDENTIALS_COLLECTION);
|
|
2515
|
+
const { iv, data } = await encrypt(JSON.stringify(credential), dek);
|
|
2516
|
+
const existing = await adapter.get(vault, SYNC_CREDENTIALS_COLLECTION, credential.adapterId);
|
|
2517
|
+
const version = existing ? existing._v + 1 : 1;
|
|
2518
|
+
const envelope = {
|
|
2519
|
+
_noydb: NOYDB_FORMAT_VERSION,
|
|
2520
|
+
_v: version,
|
|
2521
|
+
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2522
|
+
_iv: iv,
|
|
2523
|
+
_data: data,
|
|
2524
|
+
_by: keyring.userId
|
|
2525
|
+
};
|
|
2526
|
+
await adapter.put(
|
|
2527
|
+
vault,
|
|
2528
|
+
SYNC_CREDENTIALS_COLLECTION,
|
|
2529
|
+
credential.adapterId,
|
|
2530
|
+
envelope,
|
|
2531
|
+
existing ? existing._v : void 0
|
|
2532
|
+
);
|
|
2533
|
+
}
|
|
2534
|
+
async function getCredential(adapter, vault, keyring, adapterId) {
|
|
2535
|
+
requireAdminAccess(keyring);
|
|
2536
|
+
const getDek = await ensureCollectionDEK(adapter, vault, keyring);
|
|
2537
|
+
const dek = await getDek(SYNC_CREDENTIALS_COLLECTION);
|
|
2538
|
+
const envelope = await adapter.get(vault, SYNC_CREDENTIALS_COLLECTION, adapterId);
|
|
2539
|
+
if (!envelope) return null;
|
|
2540
|
+
const plaintext = await decrypt(envelope._iv, envelope._data, dek);
|
|
2541
|
+
return JSON.parse(plaintext);
|
|
2542
|
+
}
|
|
2543
|
+
async function deleteCredential(adapter, vault, keyring, adapterId) {
|
|
2544
|
+
requireAdminAccess(keyring);
|
|
2545
|
+
await adapter.delete(vault, SYNC_CREDENTIALS_COLLECTION, adapterId);
|
|
2546
|
+
}
|
|
2547
|
+
async function listCredentials(adapter, vault, keyring) {
|
|
2548
|
+
requireAdminAccess(keyring);
|
|
2549
|
+
return adapter.list(vault, SYNC_CREDENTIALS_COLLECTION);
|
|
2550
|
+
}
|
|
2551
|
+
async function credentialStatus(adapter, vault, keyring, adapterId) {
|
|
2552
|
+
const credential = await getCredential(adapter, vault, keyring, adapterId);
|
|
2553
|
+
if (!credential) return { exists: false };
|
|
2554
|
+
const expired = credential.expiresAt ? Date.now() > new Date(credential.expiresAt).getTime() : false;
|
|
2555
|
+
return { exists: true, expired };
|
|
2556
|
+
}
|
|
2557
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2558
|
+
0 && (module.exports = {
|
|
2559
|
+
PresenceHandle,
|
|
2560
|
+
SYNC_CREDENTIALS_COLLECTION,
|
|
2561
|
+
SyncEngine,
|
|
2562
|
+
SyncTransaction,
|
|
2563
|
+
buildRecipientKeyringFile,
|
|
2564
|
+
burnPaperRecoveryEntry,
|
|
2565
|
+
changeSecret,
|
|
2566
|
+
createOwnerKeyring,
|
|
2567
|
+
credentialStatus,
|
|
2568
|
+
deleteCredential,
|
|
2569
|
+
deriveMagicLinkContentKey,
|
|
2570
|
+
enrollAuthenticator,
|
|
2571
|
+
ensureCollectionDEK,
|
|
2572
|
+
evaluateExportCapability,
|
|
2573
|
+
evaluateImportCapability,
|
|
2574
|
+
findAuthenticator,
|
|
2575
|
+
getCredential,
|
|
2576
|
+
grant,
|
|
2577
|
+
hasExportCapability,
|
|
2578
|
+
hasImportCapability,
|
|
2579
|
+
isMagicLinkGrantExpired,
|
|
2580
|
+
listCredentials,
|
|
2581
|
+
listMagicLinkGrants,
|
|
2582
|
+
listUsers,
|
|
2583
|
+
listUsersWithEnvelopes,
|
|
2584
|
+
loadKeyring,
|
|
2585
|
+
loadPaperRecoveryEntries,
|
|
2586
|
+
magicLinkGrantRecordId,
|
|
2587
|
+
mintPaperRecoveryEntry,
|
|
2588
|
+
mintWrappedDeksBlob,
|
|
2589
|
+
persistKeyring,
|
|
2590
|
+
putCredential,
|
|
2591
|
+
readMagicLinkGrantRecord,
|
|
2592
|
+
recoverPassphrase,
|
|
2593
|
+
recoverUser,
|
|
2594
|
+
removeAuthenticator,
|
|
2595
|
+
revoke,
|
|
2596
|
+
revokeMagicLinkGrant,
|
|
2597
|
+
rotatePassphrase,
|
|
2598
|
+
savePaperRecoveryEntries,
|
|
2599
|
+
unwrapDeksFromBlob,
|
|
2600
|
+
unwrapDeksFromPaperEntry,
|
|
2601
|
+
unwrapMagicLinkGrant,
|
|
2602
|
+
updateAuthenticator,
|
|
2603
|
+
updateKeyringIdentity,
|
|
2604
|
+
writeMagicLinkGrant
|
|
2605
|
+
});
|
|
2606
|
+
//# sourceMappingURL=index.cjs.map
|