@muhkoo/connect 0.10.14-alpha.0 → 0.12.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/index.js +2141 -78
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +822 -8
- package/dist/server/index.js +2141 -78
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -263,7 +263,7 @@ function fromHex(hex) {
|
|
|
263
263
|
return out;
|
|
264
264
|
}
|
|
265
265
|
/** Encode a byte array as a standard base64 string. */
|
|
266
|
-
function toBase64$
|
|
266
|
+
function toBase64$2(bytes) {
|
|
267
267
|
let binary = "";
|
|
268
268
|
for (let i = 0; i < bytes.length; i++) {
|
|
269
269
|
binary += String.fromCharCode(bytes[i]);
|
|
@@ -271,7 +271,7 @@ function toBase64$1(bytes) {
|
|
|
271
271
|
return btoa(binary);
|
|
272
272
|
}
|
|
273
273
|
/** Decode a standard base64 string to bytes. */
|
|
274
|
-
function fromBase64$
|
|
274
|
+
function fromBase64$2(b64) {
|
|
275
275
|
const binary = atob(b64);
|
|
276
276
|
const ab = new ArrayBuffer(binary.length);
|
|
277
277
|
const out = new Uint8Array(ab);
|
|
@@ -281,13 +281,13 @@ function fromBase64$1(b64) {
|
|
|
281
281
|
}
|
|
282
282
|
/** Encode a byte array as a base64url string (no padding). */
|
|
283
283
|
function toBase64Url(bytes) {
|
|
284
|
-
return toBase64$
|
|
284
|
+
return toBase64$2(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
285
285
|
}
|
|
286
286
|
/** Decode a base64url string (with or without padding) to bytes. */
|
|
287
287
|
function fromBase64Url(b64url) {
|
|
288
288
|
const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
|
|
289
289
|
"=".repeat((4 - (b64url.length % 4)) % 4);
|
|
290
|
-
return fromBase64$
|
|
290
|
+
return fromBase64$2(padded);
|
|
291
291
|
}
|
|
292
292
|
/** Concatenate any number of byte arrays into a single Uint8Array. */
|
|
293
293
|
function concatBytes(...arrays) {
|
|
@@ -898,7 +898,7 @@ function randomBytes(n) {
|
|
|
898
898
|
/** Length of the AES key in bytes. */
|
|
899
899
|
const AES_GCM_KEY_BYTES = 32;
|
|
900
900
|
/** Length of the AES-GCM IV in bytes (WebCrypto-recommended). */
|
|
901
|
-
const AES_GCM_IV_BYTES = 12;
|
|
901
|
+
const AES_GCM_IV_BYTES$1 = 12;
|
|
902
902
|
/** Length of the AES-GCM authentication tag in bytes (appended to ciphertext). */
|
|
903
903
|
const AES_GCM_TAG_BYTES = 16;
|
|
904
904
|
/**
|
|
@@ -939,8 +939,8 @@ function assertKey(key) {
|
|
|
939
939
|
}
|
|
940
940
|
}
|
|
941
941
|
function assertIv(iv) {
|
|
942
|
-
if (iv.byteLength !== AES_GCM_IV_BYTES) {
|
|
943
|
-
throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES} bytes (got ${iv.byteLength})`);
|
|
942
|
+
if (iv.byteLength !== AES_GCM_IV_BYTES$1) {
|
|
943
|
+
throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES$1} bytes (got ${iv.byteLength})`);
|
|
944
944
|
}
|
|
945
945
|
}
|
|
946
946
|
|
|
@@ -1118,10 +1118,10 @@ class KeyStore {
|
|
|
1118
1118
|
async packDehydratedKeys(id) {
|
|
1119
1119
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
1120
1120
|
const combined = JSON.stringify(dehydrated);
|
|
1121
|
-
return toBase64$
|
|
1121
|
+
return toBase64$2(new TextEncoder().encode(combined));
|
|
1122
1122
|
}
|
|
1123
1123
|
async hydrateFromPacked(id, masterKey) {
|
|
1124
|
-
const decoded = utf8Decode(fromBase64$
|
|
1124
|
+
const decoded = utf8Decode(fromBase64$2(masterKey));
|
|
1125
1125
|
const dehydrated = JSON.parse(decoded);
|
|
1126
1126
|
await this.hydrateKeyPair(id, dehydrated);
|
|
1127
1127
|
}
|
|
@@ -1231,7 +1231,7 @@ class DoubleRatchet {
|
|
|
1231
1231
|
}
|
|
1232
1232
|
const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
|
|
1233
1233
|
this.state.sendChainKey = newChainKey;
|
|
1234
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
1234
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
1235
1235
|
const encrypted = await encryptAesGcm(messageKey, iv, utf8Encode(plaintext));
|
|
1236
1236
|
const cipherTextWithTag = toHex(encrypted);
|
|
1237
1237
|
appLogger.debug(`Encrypt: plaintextLength=${plaintext.length}, ciphertextLength=${encrypted.byteLength}`);
|
|
@@ -1951,8 +1951,8 @@ class ChunkCipher {
|
|
|
1951
1951
|
*/
|
|
1952
1952
|
generateKeyMaterial() {
|
|
1953
1953
|
return {
|
|
1954
|
-
key: toBase64$
|
|
1955
|
-
iv: toBase64$
|
|
1954
|
+
key: toBase64$2(randomBytes(AES_GCM_KEY_BYTES)),
|
|
1955
|
+
iv: toBase64$2(randomBytes(AES_GCM_IV_BYTES$1)),
|
|
1956
1956
|
};
|
|
1957
1957
|
}
|
|
1958
1958
|
/**
|
|
@@ -1961,7 +1961,7 @@ class ChunkCipher {
|
|
|
1961
1961
|
* GCM behavior).
|
|
1962
1962
|
*/
|
|
1963
1963
|
async encryptChunk(plaintext, material) {
|
|
1964
|
-
return encryptAesGcm(fromBase64$
|
|
1964
|
+
return encryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), plaintext);
|
|
1965
1965
|
}
|
|
1966
1966
|
/**
|
|
1967
1967
|
* Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
|
|
@@ -1970,7 +1970,7 @@ class ChunkCipher {
|
|
|
1970
1970
|
*/
|
|
1971
1971
|
async decryptChunk(ciphertext, material) {
|
|
1972
1972
|
try {
|
|
1973
|
-
return await decryptAesGcm(fromBase64$
|
|
1973
|
+
return await decryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), ciphertext);
|
|
1974
1974
|
}
|
|
1975
1975
|
catch {
|
|
1976
1976
|
// WebCrypto throws a vague OperationError on GCM tag mismatch.
|
|
@@ -2034,7 +2034,7 @@ async function wrapWithPassphrase(passphrase, plaintext) {
|
|
|
2034
2034
|
throw new Error("wrapWithPassphrase: passphrase must be a non-empty string");
|
|
2035
2035
|
}
|
|
2036
2036
|
const salt = randomBytes(SALT_BYTES);
|
|
2037
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
2037
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
2038
2038
|
const key = await deriveAesKeyFromPassphrase(passphrase, salt, PBKDF2_DEFAULT_ITERATIONS);
|
|
2039
2039
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
2040
2040
|
return {
|
|
@@ -4541,8 +4541,10 @@ class AuthClient {
|
|
|
4541
4541
|
/** Passkeys are per-origin: naming the caller's host returns THAT origin's
|
|
4542
4542
|
* passkey instead of whichever was enrolled first (which WebAuthn would
|
|
4543
4543
|
* refuse). Ignored for other factor types. */
|
|
4544
|
-
rpId
|
|
4545
|
-
|
|
4544
|
+
rpId,
|
|
4545
|
+
/** Device factors are selected by id — a user may have many machines. */
|
|
4546
|
+
factorId) {
|
|
4547
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId, factorId }));
|
|
4546
4548
|
return await this.parse("vaultRead", res);
|
|
4547
4549
|
}
|
|
4548
4550
|
/**
|
|
@@ -4983,20 +4985,20 @@ async function wrapKeyFromBytes(keyMaterial, info) {
|
|
|
4983
4985
|
async function wrapSeed(seed, key) {
|
|
4984
4986
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
4985
4987
|
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
|
|
4986
|
-
return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
|
|
4988
|
+
return { iv: toBase64$1(iv), ct: toBase64$1(new Uint8Array(ct)) };
|
|
4987
4989
|
}
|
|
4988
4990
|
/** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
|
|
4989
4991
|
async function unwrapSeed(wrapped, key) {
|
|
4990
|
-
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
|
|
4992
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(wrapped.iv) }, key, fromBase64$1(wrapped.ct));
|
|
4991
4993
|
return new Uint8Array(pt);
|
|
4992
4994
|
}
|
|
4993
|
-
function toBase64(bytes) {
|
|
4995
|
+
function toBase64$1(bytes) {
|
|
4994
4996
|
let s = "";
|
|
4995
4997
|
for (let i = 0; i < bytes.length; i++)
|
|
4996
4998
|
s += String.fromCharCode(bytes[i]);
|
|
4997
4999
|
return btoa(s);
|
|
4998
5000
|
}
|
|
4999
|
-
function fromBase64(b64) {
|
|
5001
|
+
function fromBase64$1(b64) {
|
|
5000
5002
|
const s = atob(b64);
|
|
5001
5003
|
const out = new Uint8Array(s.length);
|
|
5002
5004
|
for (let i = 0; i < s.length; i++)
|
|
@@ -5054,20 +5056,20 @@ async function sealSeed(seed) {
|
|
|
5054
5056
|
const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
5055
5057
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
5056
5058
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
5057
|
-
const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
5059
|
+
const envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
|
|
5058
5060
|
return {
|
|
5059
|
-
sealedKeys: toBase64(TE$1.encode(JSON.stringify(envelope))),
|
|
5061
|
+
sealedKeys: toBase64$1(TE$1.encode(JSON.stringify(envelope))),
|
|
5060
5062
|
fragmentKey: base64UrlEncode(ktRaw),
|
|
5061
5063
|
};
|
|
5062
5064
|
}
|
|
5063
5065
|
/** Recover the master seed from the sealed envelope + the fragment key. */
|
|
5064
5066
|
async function unsealSeed(sealedKeys, fragmentKey) {
|
|
5065
|
-
const envelope = JSON.parse(TD$1.decode(fromBase64(sealedKeys)));
|
|
5067
|
+
const envelope = JSON.parse(TD$1.decode(fromBase64$1(sealedKeys)));
|
|
5066
5068
|
if (envelope.v !== 1)
|
|
5067
5069
|
throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
|
|
5068
5070
|
const ktRaw = base64UrlDecode(fragmentKey);
|
|
5069
5071
|
const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
5070
|
-
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
|
|
5072
|
+
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct));
|
|
5071
5073
|
return new Uint8Array(seed);
|
|
5072
5074
|
}
|
|
5073
5075
|
// ---- v2: ECDH device pairing ----------------------------------------------
|
|
@@ -5118,7 +5120,7 @@ const PAIRING_CODE_ITERATIONS = 200_000;
|
|
|
5118
5120
|
async function generateDevicePairingKeypair() {
|
|
5119
5121
|
const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
|
|
5120
5122
|
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
5121
|
-
return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
|
|
5123
|
+
return { publicKeyB64: toBase64$1(raw), privateKey: pair.privateKey };
|
|
5122
5124
|
}
|
|
5123
5125
|
/**
|
|
5124
5126
|
* (Hosted-page side) Seal the master seed *to a device's* pairing public key.
|
|
@@ -5139,8 +5141,8 @@ async function sealSeedToDevice(seed, devicePublicKeyB64) {
|
|
|
5139
5141
|
const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
|
|
5140
5142
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
5141
5143
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
5142
|
-
const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
|
|
5143
|
-
return toBase64(TE$1.encode(JSON.stringify(envelope)));
|
|
5144
|
+
const envelope = { v: 2, epk: toBase64$1(epkRaw), iv: toBase64$1(iv), ct: toBase64$1(ct) };
|
|
5145
|
+
return toBase64$1(TE$1.encode(JSON.stringify(envelope)));
|
|
5144
5146
|
}
|
|
5145
5147
|
/**
|
|
5146
5148
|
* (Device side) Recover the master seed from a v2 envelope using the ephemeral
|
|
@@ -5298,7 +5300,7 @@ function importPairingPublicKey(raw) {
|
|
|
5298
5300
|
*/
|
|
5299
5301
|
function fromBase64Loose(s) {
|
|
5300
5302
|
const norm = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
5301
|
-
return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
|
|
5303
|
+
return fromBase64$1(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
|
|
5302
5304
|
}
|
|
5303
5305
|
/** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
|
|
5304
5306
|
async function generatePkce() {
|
|
@@ -5311,10 +5313,10 @@ function randomState() {
|
|
|
5311
5313
|
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
|
|
5312
5314
|
}
|
|
5313
5315
|
function base64UrlEncode(bytes) {
|
|
5314
|
-
return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5316
|
+
return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5315
5317
|
}
|
|
5316
5318
|
function base64UrlDecode(s) {
|
|
5317
|
-
return fromBase64(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
|
|
5319
|
+
return fromBase64$1(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
|
|
5318
5320
|
}
|
|
5319
5321
|
|
|
5320
5322
|
/**
|
|
@@ -5325,7 +5327,7 @@ function base64UrlDecode(s) {
|
|
|
5325
5327
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
5326
5328
|
* `package.json` `version`.
|
|
5327
5329
|
*/
|
|
5328
|
-
const VERSION = "0.
|
|
5330
|
+
const VERSION = "0.12.0-alpha.0";
|
|
5329
5331
|
|
|
5330
5332
|
/**
|
|
5331
5333
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -5778,8 +5780,8 @@ function gatedBlind(input) {
|
|
|
5778
5780
|
* unblind each, concatenate, HKDF with the gated-factor domain separation.
|
|
5779
5781
|
*/
|
|
5780
5782
|
async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
|
|
5781
|
-
const out1 = oprfFinalize(input, blind, fromBase64(evaluatedB64));
|
|
5782
|
-
const out2 = oprfFinalize(input, blind, fromBase64(evaluated2B64));
|
|
5783
|
+
const out1 = oprfFinalize(input, blind, fromBase64$1(evaluatedB64));
|
|
5784
|
+
const out2 = oprfFinalize(input, blind, fromBase64$1(evaluated2B64));
|
|
5783
5785
|
const ikm = new Uint8Array(out1.length + out2.length);
|
|
5784
5786
|
ikm.set(out1);
|
|
5785
5787
|
ikm.set(out2, out1.length);
|
|
@@ -6113,13 +6115,13 @@ async function persistDeviceIdentity(v) {
|
|
|
6113
6115
|
const plain = TE.encode(JSON.stringify({
|
|
6114
6116
|
username: v.username,
|
|
6115
6117
|
commitment: v.commitment,
|
|
6116
|
-
seed: toBase64(v.seed),
|
|
6118
|
+
seed: toBase64$1(v.seed),
|
|
6117
6119
|
pairedAt: v.pairedAt,
|
|
6118
6120
|
}));
|
|
6119
6121
|
const bridge = keystoreBridge();
|
|
6120
6122
|
let envelope;
|
|
6121
6123
|
if (bridge) {
|
|
6122
|
-
envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64(plain)) };
|
|
6124
|
+
envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64$1(plain)) };
|
|
6123
6125
|
}
|
|
6124
6126
|
else {
|
|
6125
6127
|
const key = await wrapKey(true);
|
|
@@ -6127,7 +6129,7 @@ async function persistDeviceIdentity(v) {
|
|
|
6127
6129
|
throw new DeviceStoreUnavailableError("no IndexedDB for the wrapping key");
|
|
6128
6130
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
6129
6131
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plain));
|
|
6130
|
-
envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
6132
|
+
envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
|
|
6131
6133
|
}
|
|
6132
6134
|
blobs.set(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(envelope));
|
|
6133
6135
|
}
|
|
@@ -6151,18 +6153,18 @@ async function loadDeviceIdentity() {
|
|
|
6151
6153
|
if (envelope.ks === 1) {
|
|
6152
6154
|
if (!bridge)
|
|
6153
6155
|
return null; // sealed by the native shell; nothing else can open it
|
|
6154
|
-
plain = fromBase64(await bridge.unwrap(envelope.ct));
|
|
6156
|
+
plain = fromBase64$1(await bridge.unwrap(envelope.ct));
|
|
6155
6157
|
}
|
|
6156
6158
|
else {
|
|
6157
6159
|
const key = await wrapKey(false);
|
|
6158
6160
|
if (!key || !envelope.iv)
|
|
6159
6161
|
return null;
|
|
6160
|
-
plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct)));
|
|
6162
|
+
plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct)));
|
|
6161
6163
|
}
|
|
6162
6164
|
const parsed = JSON.parse(TD.decode(plain));
|
|
6163
6165
|
if (!parsed?.username || !parsed?.commitment || !parsed?.seed)
|
|
6164
6166
|
return null;
|
|
6165
|
-
const seed = fromBase64(parsed.seed);
|
|
6167
|
+
const seed = fromBase64$1(parsed.seed);
|
|
6166
6168
|
if (seed.length !== 32)
|
|
6167
6169
|
return null;
|
|
6168
6170
|
return {
|
|
@@ -6196,7 +6198,7 @@ async function clearDeviceIdentity() {
|
|
|
6196
6198
|
async function fingerprintOf(publicKey) {
|
|
6197
6199
|
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
|
|
6198
6200
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
|
|
6199
|
-
return toBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6201
|
+
return toBase64$1(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6200
6202
|
}
|
|
6201
6203
|
async function loadOrCreateIdentityKey() {
|
|
6202
6204
|
const vault = keyVault();
|
|
@@ -7262,6 +7264,8 @@ class VaultUnavailableError extends Error {
|
|
|
7262
7264
|
* ZK identity auth. One instance per {@link Client}; reads/writes the shared
|
|
7263
7265
|
* {@link SessionState}.
|
|
7264
7266
|
*/
|
|
7267
|
+
/** HKDF label separating a device wrap key from every other use of the seed. */
|
|
7268
|
+
const DEVICE_WRAP_INFO = "muhkoo-device-key-v1";
|
|
7265
7269
|
class ZkAuth {
|
|
7266
7270
|
deps;
|
|
7267
7271
|
constructor(deps) {
|
|
@@ -7553,7 +7557,7 @@ class ZkAuth {
|
|
|
7553
7557
|
const { verifyToken, email: verified } = await this.deps.auth.emailOtpVerify({ token, code });
|
|
7554
7558
|
const input = await emailFactorInput(username, verified);
|
|
7555
7559
|
const { blind, blinded } = gatedBlind(input);
|
|
7556
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "enroll", verifyToken);
|
|
7560
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "enroll", verifyToken);
|
|
7557
7561
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
7558
7562
|
const wrapped = await wrapSeed(seed, key);
|
|
7559
7563
|
await this.deps.auth.vaultPutFactor(token, { id: "email", type: "email", wrap: wrapped.ct, iv: wrapped.iv, params: { email: verified }, createdAt: Date.now() }, verifyToken);
|
|
@@ -7581,7 +7585,7 @@ class ZkAuth {
|
|
|
7581
7585
|
}
|
|
7582
7586
|
const input = await emailFactorInput(username, email);
|
|
7583
7587
|
const { blind, blinded } = gatedBlind(input);
|
|
7584
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "recover", verifyToken);
|
|
7588
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "recover", verifyToken);
|
|
7585
7589
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
7586
7590
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
7587
7591
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -7609,7 +7613,7 @@ class ZkAuth {
|
|
|
7609
7613
|
const { verifyToken, sub, emailHint } = await this.deps.auth.googleVerify({ idToken, token });
|
|
7610
7614
|
const input = await googleFactorInput(username, sub);
|
|
7611
7615
|
const { blind, blinded } = gatedBlind(input);
|
|
7612
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "enroll", verifyToken);
|
|
7616
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "enroll", verifyToken);
|
|
7613
7617
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
7614
7618
|
const wrapped = await wrapSeed(seed, key);
|
|
7615
7619
|
await this.deps.auth.vaultPutFactor(token, { id: "google", type: "google", wrap: wrapped.ct, iv: wrapped.iv, params: { sub, emailHint: emailHint ?? undefined }, createdAt: Date.now() }, verifyToken);
|
|
@@ -7627,7 +7631,7 @@ class ZkAuth {
|
|
|
7627
7631
|
throw new Error("No Google account is linked to this username.");
|
|
7628
7632
|
const input = await googleFactorInput(username, sub);
|
|
7629
7633
|
const { blind, blinded } = gatedBlind(input);
|
|
7630
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "recover", verifyToken);
|
|
7634
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "recover", verifyToken);
|
|
7631
7635
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
7632
7636
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
7633
7637
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -7708,11 +7712,11 @@ class ZkAuth {
|
|
|
7708
7712
|
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
7709
7713
|
const wrapped = await wrapSeed(seed, key);
|
|
7710
7714
|
await this.deps.auth.vaultPutFactor(token, {
|
|
7711
|
-
id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
|
|
7715
|
+
id: `passkey:${toBase64$1(credentialId).slice(0, 16)}`,
|
|
7712
7716
|
type: "passkey",
|
|
7713
7717
|
wrap: wrapped.ct,
|
|
7714
7718
|
iv: wrapped.iv,
|
|
7715
|
-
params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
|
|
7719
|
+
params: { credentialId: toBase64$1(credentialId), prfSalt: toBase64$1(prfSalt), rpId },
|
|
7716
7720
|
label: opts?.label ?? "Passkey",
|
|
7717
7721
|
createdAt: Date.now(),
|
|
7718
7722
|
});
|
|
@@ -7737,7 +7741,7 @@ class ZkAuth {
|
|
|
7737
7741
|
// to enroll a passkey for this origin.
|
|
7738
7742
|
if (!rpIdUsableForOrigin(rpId, host))
|
|
7739
7743
|
throw new PasskeyOriginError();
|
|
7740
|
-
const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64(params.credentialId), fromBase64(params.prfSalt));
|
|
7744
|
+
const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64$1(params.credentialId), fromBase64$1(params.prfSalt));
|
|
7741
7745
|
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
7742
7746
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
7743
7747
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -7816,7 +7820,107 @@ class ZkAuth {
|
|
|
7816
7820
|
*/
|
|
7817
7821
|
get seedBase64() {
|
|
7818
7822
|
const seed = this.deps.session.seed;
|
|
7819
|
-
return seed ? toBase64(seed) : null;
|
|
7823
|
+
return seed ? toBase64$1(seed) : null;
|
|
7824
|
+
}
|
|
7825
|
+
/**
|
|
7826
|
+
* Pair this machine, so it can unlock the account later without a password.
|
|
7827
|
+
*
|
|
7828
|
+
* Generates a random 32-byte key, wraps the master seed under it, and stores
|
|
7829
|
+
* only the CIPHERTEXT in the vault as a `device` factor. The key is returned
|
|
7830
|
+
* for the caller to persist locally and is never sent anywhere.
|
|
7831
|
+
*
|
|
7832
|
+
* The split is the point: the seed itself never lands on disk, and the
|
|
7833
|
+
* server holds a blob it cannot open. Revoking the factor
|
|
7834
|
+
* ({@link revokeDevice}) makes the local key inert immediately — which a
|
|
7835
|
+
* stored master seed can never be, because nothing can reach out and
|
|
7836
|
+
* un-store it.
|
|
7837
|
+
*
|
|
7838
|
+
* Returns the factor id and the key, both base64. Losing the key just means
|
|
7839
|
+
* re-pairing; leaking it is equivalent to leaking the seed for as long as
|
|
7840
|
+
* the factor exists, so store it with the tightest permissions available.
|
|
7841
|
+
*/
|
|
7842
|
+
async enrollDevice(label) {
|
|
7843
|
+
const seed = this.deps.session.seed;
|
|
7844
|
+
const token = this.deps.session.token;
|
|
7845
|
+
if (!seed || !token)
|
|
7846
|
+
throw new Error("Sign in first to pair this device.");
|
|
7847
|
+
const keyBytes = randomSeed(); // 32 bytes of CSPRNG
|
|
7848
|
+
const key = await wrapKeyFromBytes(keyBytes, DEVICE_WRAP_INFO);
|
|
7849
|
+
const wrapped = await wrapSeed(seed, key);
|
|
7850
|
+
// 128 bits of id, so reading a factor by id is not an enumeration surface.
|
|
7851
|
+
const factorId = `device:${toBase64$1(randomSeed()).replace(/[+/=]/g, "").slice(0, 22)}`;
|
|
7852
|
+
await this.deps.auth.vaultPutFactor(token, {
|
|
7853
|
+
id: factorId,
|
|
7854
|
+
type: "device",
|
|
7855
|
+
wrap: wrapped.ct,
|
|
7856
|
+
iv: wrapped.iv,
|
|
7857
|
+
label,
|
|
7858
|
+
createdAt: Date.now(),
|
|
7859
|
+
});
|
|
7860
|
+
return { factorId, deviceKey: toBase64$1(keyBytes) };
|
|
7861
|
+
}
|
|
7862
|
+
/**
|
|
7863
|
+
* Unlock using a key stored by {@link enrollDevice}.
|
|
7864
|
+
*
|
|
7865
|
+
* Fetches the wrapped seed and opens it locally. Establishes the encryption
|
|
7866
|
+
* identity only — pair it with a session for calls that need one.
|
|
7867
|
+
*
|
|
7868
|
+
* Throws distinguishably: a REVOKED factor is gone from the vault and says
|
|
7869
|
+
* so, where a wrong key fails to decrypt. A machine that has had its access
|
|
7870
|
+
* withdrawn should be able to tell you that, not just "decryption failed".
|
|
7871
|
+
*/
|
|
7872
|
+
async unlockWithDevice(username, factorId, deviceKey) {
|
|
7873
|
+
const { factor } = await this.deps.auth.vaultRead(username, "device", undefined, undefined, factorId);
|
|
7874
|
+
if (!factor) {
|
|
7875
|
+
throw new Error("This device is no longer paired — its access was revoked. Sign in again to re-pair.");
|
|
7876
|
+
}
|
|
7877
|
+
if (!factor.wrap || !factor.iv)
|
|
7878
|
+
throw new Error("The stored device factor is incomplete; re-pair this device.");
|
|
7879
|
+
const key = await wrapKeyFromBytes(fromBase64$1(deviceKey), DEVICE_WRAP_INFO);
|
|
7880
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
7881
|
+
this.deps.session.setSeed(seed);
|
|
7882
|
+
}
|
|
7883
|
+
/** Paired machines, newest first. Metadata only — never the ciphertext. */
|
|
7884
|
+
async listDevices() {
|
|
7885
|
+
const token = this.deps.session.token;
|
|
7886
|
+
if (!token)
|
|
7887
|
+
throw new Error("Sign in first to list paired devices.");
|
|
7888
|
+
const { factors } = await this.deps.auth.vaultFactors(token);
|
|
7889
|
+
return factors
|
|
7890
|
+
.filter((f) => f.type === "device")
|
|
7891
|
+
.map((f) => ({ id: f.id, label: f.label, createdAt: f.createdAt }))
|
|
7892
|
+
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
|
7893
|
+
}
|
|
7894
|
+
/** Withdraw a machine's access. Its stored key stops working immediately. */
|
|
7895
|
+
async revokeDevice(factorId) {
|
|
7896
|
+
const token = this.deps.session.token;
|
|
7897
|
+
if (!token)
|
|
7898
|
+
throw new Error("Sign in first to revoke a device.");
|
|
7899
|
+
const { deleted } = await this.deps.auth.vaultDeleteFactor(token, factorId);
|
|
7900
|
+
return deleted;
|
|
7901
|
+
}
|
|
7902
|
+
/**
|
|
7903
|
+
* Restore an unlocked identity from a seed you already hold.
|
|
7904
|
+
*
|
|
7905
|
+
* The inverse of {@link seedBase64}, for hosts that persist the seed
|
|
7906
|
+
* themselves rather than re-deriving it from a password: a CLI holding it in
|
|
7907
|
+
* a 0600 file, a server-side tool, a test. Without this the seed is a
|
|
7908
|
+
* one-way export and anything outside a browser has to run a full ZK login —
|
|
7909
|
+
* circuits download and proof — on every single invocation.
|
|
7910
|
+
*
|
|
7911
|
+
* Establishes the ENCRYPTION identity only. It does not authenticate: pair
|
|
7912
|
+
* it with a session token (`sessionStore`) for calls that need one.
|
|
7913
|
+
*
|
|
7914
|
+
* Treat a persisted seed as the master credential it is. It is not scoped
|
|
7915
|
+
* and cannot be revoked server-side the way a session token can — rotating
|
|
7916
|
+
* it means moving the identity.
|
|
7917
|
+
*/
|
|
7918
|
+
unlockWithSeed(seedBase64) {
|
|
7919
|
+
const seed = fromBase64$1(seedBase64);
|
|
7920
|
+
if (seed.length !== 32) {
|
|
7921
|
+
throw new Error(`auth.zk.unlockWithSeed: expected a 32-byte seed, got ${seed.length}`);
|
|
7922
|
+
}
|
|
7923
|
+
this.deps.session.setSeed(seed);
|
|
7820
7924
|
}
|
|
7821
7925
|
// -------------------------------------------------------------------------
|
|
7822
7926
|
/** Poseidon commitment for an identity — the value the server stores. */
|
|
@@ -7835,8 +7939,8 @@ class ZkAuth {
|
|
|
7835
7939
|
async passwordWrapKey(username, password) {
|
|
7836
7940
|
const pre = passwordPreHash(username, password);
|
|
7837
7941
|
const { blind, blinded } = oprfBlind(pre);
|
|
7838
|
-
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
|
|
7839
|
-
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
|
|
7942
|
+
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64$1(blinded));
|
|
7943
|
+
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64$1(evaluated)));
|
|
7840
7944
|
}
|
|
7841
7945
|
/**
|
|
7842
7946
|
* Try to recover the master seed from the password factor.
|
|
@@ -7942,16 +8046,16 @@ class StorageCipher {
|
|
|
7942
8046
|
/** Seal a JSON-serializable value into an {@link EncryptedEnvelope}. */
|
|
7943
8047
|
async encrypt(value) {
|
|
7944
8048
|
const key = await this.keyPromise;
|
|
7945
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
8049
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
7946
8050
|
const plaintext = utf8Encode(JSON.stringify(value));
|
|
7947
8051
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
7948
|
-
return { _enc: "a256gcm", iv: toBase64$
|
|
8052
|
+
return { _enc: "a256gcm", iv: toBase64$2(iv), ct: toBase64$2(ciphertext) };
|
|
7949
8053
|
}
|
|
7950
8054
|
/** Open an {@link EncryptedEnvelope} back into the original value. */
|
|
7951
8055
|
async decrypt(envelope) {
|
|
7952
8056
|
const key = await this.keyPromise;
|
|
7953
|
-
const iv = fromBase64$
|
|
7954
|
-
const ciphertext = fromBase64$
|
|
8057
|
+
const iv = fromBase64$2(envelope.iv);
|
|
8058
|
+
const ciphertext = fromBase64$2(envelope.ct);
|
|
7955
8059
|
const plaintext = await decryptAesGcm(key, iv, ciphertext);
|
|
7956
8060
|
return JSON.parse(utf8Decode(plaintext));
|
|
7957
8061
|
}
|
|
@@ -7990,6 +8094,8 @@ class StorageCipher {
|
|
|
7990
8094
|
*/
|
|
7991
8095
|
class KvNamespace {
|
|
7992
8096
|
deps;
|
|
8097
|
+
/** One personal-space socket, shared by every subscriber. */
|
|
8098
|
+
feed = null;
|
|
7993
8099
|
cipher = null;
|
|
7994
8100
|
cipherIdentity = null;
|
|
7995
8101
|
constructor(deps) {
|
|
@@ -8114,22 +8220,64 @@ class KvNamespace {
|
|
|
8114
8220
|
if (!token)
|
|
8115
8221
|
throw new Error("client.kv.on: not signed in.");
|
|
8116
8222
|
const commitment = this.commitment();
|
|
8117
|
-
|
|
8223
|
+
`${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
8118
8224
|
// Ride a WSTransport rather than a bare socket: it keeps the (otherwise
|
|
8119
8225
|
// idle) personal-space feed warm with heartbeat pings, auto-reconnects
|
|
8120
8226
|
// if it drops, and namespaces its events by `id` so this feed never
|
|
8121
8227
|
// crosses wires with chat/space transports on the shared EventCore bus.
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
8126
|
-
|
|
8127
|
-
|
|
8128
|
-
|
|
8129
|
-
|
|
8228
|
+
return this.onRaw((raw) => {
|
|
8229
|
+
void this.dispatchChange(raw, handler);
|
|
8230
|
+
});
|
|
8231
|
+
}
|
|
8232
|
+
/**
|
|
8233
|
+
* Subscribe to the personal space's raw change feed.
|
|
8234
|
+
*
|
|
8235
|
+
* Every key this user owns crosses this socket, not only `client.kv`
|
|
8236
|
+
* collections — the VFS keeps its directory records here too. Consumers get
|
|
8237
|
+
* the frame untouched: no decryption, no offline-cache merge, because the
|
|
8238
|
+
* value's meaning depends on which subsystem wrote it.
|
|
8239
|
+
*
|
|
8240
|
+
* The transport is SHARED and reference-counted. `WSTransport` namespaces
|
|
8241
|
+
* its events by `id`, so two transports with the same id would cross wires
|
|
8242
|
+
* on the shared event bus — and a second socket to the same Durable Object
|
|
8243
|
+
* would be pure waste besides.
|
|
8244
|
+
*/
|
|
8245
|
+
onRaw(handler) {
|
|
8246
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
8247
|
+
throw new Error("client.kv.onRaw: no `WebSocket` global available in this runtime.");
|
|
8248
|
+
}
|
|
8249
|
+
const token = this.deps.session.token;
|
|
8250
|
+
if (!token)
|
|
8251
|
+
throw new Error("client.kv.onRaw: not signed in.");
|
|
8252
|
+
const commitment = this.commitment();
|
|
8253
|
+
if (!this.feed) {
|
|
8254
|
+
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
8255
|
+
// Ride a WSTransport rather than a bare socket: it keeps the
|
|
8256
|
+
// (otherwise idle) feed warm with heartbeat pings, auto-reconnects
|
|
8257
|
+
// if it drops, and namespaces its events by `id`.
|
|
8258
|
+
const transport = new WSTransport({ url, id: `personal:${commitment}` });
|
|
8259
|
+
const handlers = new Set();
|
|
8260
|
+
const onMessage = (e) => {
|
|
8261
|
+
for (const fn of handlers)
|
|
8262
|
+
fn(e.detail);
|
|
8263
|
+
};
|
|
8264
|
+
transport.on(EventCoreEvents.MESSAGE, onMessage);
|
|
8265
|
+
// Fire-and-forget: the feed is best-effort, and the transport queues
|
|
8266
|
+
// nothing it needs before CONNECTED (server→client only).
|
|
8267
|
+
void transport.connect();
|
|
8268
|
+
this.feed = { transport, handlers, onMessage };
|
|
8269
|
+
}
|
|
8270
|
+
const feed = this.feed;
|
|
8271
|
+
feed.handlers.add(handler);
|
|
8130
8272
|
return () => {
|
|
8131
|
-
|
|
8132
|
-
|
|
8273
|
+
feed.handlers.delete(handler);
|
|
8274
|
+
// Last subscriber out closes the socket, so an app that unsubscribes
|
|
8275
|
+
// everything is not left holding an open connection.
|
|
8276
|
+
if (feed.handlers.size === 0 && this.feed === feed) {
|
|
8277
|
+
feed.transport.off(EventCoreEvents.MESSAGE, feed.onMessage);
|
|
8278
|
+
feed.transport.disconnect();
|
|
8279
|
+
this.feed = null;
|
|
8280
|
+
}
|
|
8133
8281
|
};
|
|
8134
8282
|
}
|
|
8135
8283
|
// -------------------------------------------------------------------------
|
|
@@ -8571,12 +8719,12 @@ function canonicalMessage(fields) {
|
|
|
8571
8719
|
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
8572
8720
|
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
8573
8721
|
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
8574
|
-
return toBase64$
|
|
8722
|
+
return toBase64$2(new Uint8Array(sig));
|
|
8575
8723
|
}
|
|
8576
8724
|
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
8577
8725
|
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
8578
8726
|
try {
|
|
8579
|
-
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$
|
|
8727
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$2(sigB64), utf8Encode(canonical));
|
|
8580
8728
|
}
|
|
8581
8729
|
catch {
|
|
8582
8730
|
return false;
|
|
@@ -8607,13 +8755,13 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
|
|
|
8607
8755
|
const eph = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
|
|
8608
8756
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: recipientEcdhPub }, eph.privateKey, P384_BITS));
|
|
8609
8757
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${epoch}`));
|
|
8610
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
8758
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
8611
8759
|
const ciphertext = await encryptAesGcm(wrapKey, iv, kSpace);
|
|
8612
8760
|
return {
|
|
8613
8761
|
epoch,
|
|
8614
8762
|
ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
|
|
8615
|
-
iv: toBase64$
|
|
8616
|
-
ciphertext: toBase64$
|
|
8763
|
+
iv: toBase64$2(iv),
|
|
8764
|
+
ciphertext: toBase64$2(ciphertext),
|
|
8617
8765
|
alg: WRAP_ALG,
|
|
8618
8766
|
};
|
|
8619
8767
|
}
|
|
@@ -8625,23 +8773,23 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
8625
8773
|
const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
|
|
8626
8774
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
|
|
8627
8775
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
|
|
8628
|
-
return decryptAesGcm(wrapKey, fromBase64$
|
|
8776
|
+
return decryptAesGcm(wrapKey, fromBase64$2(wrapped.iv), fromBase64$2(wrapped.ciphertext));
|
|
8629
8777
|
}
|
|
8630
8778
|
// ---------------------------------------------------------------------------
|
|
8631
8779
|
// Message sealing
|
|
8632
8780
|
// ---------------------------------------------------------------------------
|
|
8633
8781
|
/** Seal an already-serialized message string under the group key. */
|
|
8634
8782
|
async function sealSerialized(kSpace, serialized) {
|
|
8635
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
8783
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
8636
8784
|
const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
|
|
8637
|
-
return { iv: toBase64$
|
|
8785
|
+
return { iv: toBase64$2(iv), ciphertext: toBase64$2(ciphertext) };
|
|
8638
8786
|
}
|
|
8639
8787
|
/**
|
|
8640
8788
|
* Open a sealed payload back into its serialized message string. Throws (GCM
|
|
8641
8789
|
* tag failure) on the wrong group key or a tampered ciphertext.
|
|
8642
8790
|
*/
|
|
8643
8791
|
async function openSerialized(kSpace, iv, ciphertext) {
|
|
8644
|
-
const plaintext = await decryptAesGcm(kSpace, fromBase64$
|
|
8792
|
+
const plaintext = await decryptAesGcm(kSpace, fromBase64$2(iv), fromBase64$2(ciphertext));
|
|
8645
8793
|
return utf8Decode(plaintext);
|
|
8646
8794
|
}
|
|
8647
8795
|
|
|
@@ -11145,6 +11293,16 @@ class SpaceNamespace {
|
|
|
11145
11293
|
* Create a new space: generate its keypair (public key = id), register
|
|
11146
11294
|
* metadata, mint the initial group key, and connect. Returns the open Space.
|
|
11147
11295
|
*/
|
|
11296
|
+
/**
|
|
11297
|
+
* Create a space.
|
|
11298
|
+
*
|
|
11299
|
+
* `messaging: false` registers the space and stops there — no group-key
|
|
11300
|
+
* bootstrap and no WebSocket. Use it for a space that only ever holds
|
|
11301
|
+
* FILES: each chunk carries its own key inside its manifest, so the group
|
|
11302
|
+
* keyring buys nothing, and opening a socket makes the write path depend on
|
|
11303
|
+
* a live connection. That dependency is wrong for a CLI, and it is what made
|
|
11304
|
+
* the first VFS write hang forever instead of failing.
|
|
11305
|
+
*/
|
|
11148
11306
|
async createSpace(opts = {}) {
|
|
11149
11307
|
const historyPolicy = opts.historyPolicy ?? "static";
|
|
11150
11308
|
const identity = await generateSpaceIdentity();
|
|
@@ -11153,7 +11311,8 @@ class SpaceNamespace {
|
|
|
11153
11311
|
// its first member — so the metadata POST must carry the user session.
|
|
11154
11312
|
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
11155
11313
|
const space = await this.build(identity.id, historyPolicy);
|
|
11156
|
-
|
|
11314
|
+
if (opts.messaging !== false)
|
|
11315
|
+
await space.create();
|
|
11157
11316
|
return space;
|
|
11158
11317
|
}
|
|
11159
11318
|
/** Join an existing space by id (its public key). Resolves once a key arrives. */
|
|
@@ -11350,7 +11509,7 @@ class SpaceNamespace {
|
|
|
11350
11509
|
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
11351
11510
|
if (this.deps.chatKeys && seed) {
|
|
11352
11511
|
try {
|
|
11353
|
-
await this.deps.chatKeys.ensure(memberId, toBase64$
|
|
11512
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
|
|
11354
11513
|
}
|
|
11355
11514
|
catch (e) {
|
|
11356
11515
|
if (staging)
|
|
@@ -11655,6 +11814,1807 @@ class AccessTokensNamespace {
|
|
|
11655
11814
|
}
|
|
11656
11815
|
}
|
|
11657
11816
|
|
|
11817
|
+
/**
|
|
11818
|
+
* Wire types for the VFS metadata layer.
|
|
11819
|
+
*
|
|
11820
|
+
* ## What is stored where
|
|
11821
|
+
*
|
|
11822
|
+
* The filesystem is a graph of **directory records**, one per directory, each
|
|
11823
|
+
* held under a personal-space KV key and encrypted client-side. A record lists
|
|
11824
|
+
* only its immediate children — so listing a directory is one fetch, and you
|
|
11825
|
+
* only ever pay for the subtrees you actually open.
|
|
11826
|
+
*
|
|
11827
|
+
* File CONTENT is not here. Bytes are written with `client.storage.writeFile`
|
|
11828
|
+
* exactly as any other file — into a real Space, with a real gated manifest —
|
|
11829
|
+
* and the entry keeps the returned `FileManifest` as its handle.
|
|
11830
|
+
* `client.storage.readByManifest(manifest)` then reconstructs the bytes from
|
|
11831
|
+
* the content-addressed shard store with no space membership or directory
|
|
11832
|
+
* lookup needed, which is what makes a file shareable by handing over the
|
|
11833
|
+
* handle alone.
|
|
11834
|
+
*
|
|
11835
|
+
* This supersedes the flat personal mirror `writeFile` already maintains
|
|
11836
|
+
* (`{spaceId, manifest}` keyed by manifest id): the same metadata, in the same
|
|
11837
|
+
* personal space, arranged as a tree you can actually navigate.
|
|
11838
|
+
*
|
|
11839
|
+
* ## Why directories have stable random ids, not content hashes
|
|
11840
|
+
*
|
|
11841
|
+
* A git-style tree keys each directory by the hash of its contents, so writing
|
|
11842
|
+
* one file rewrites every ancestor up to the root. Here a directory's id is
|
|
11843
|
+
* random and permanent, so a file write touches exactly ONE record — its
|
|
11844
|
+
* parent — and renaming or moving a directory edits only the parent entry
|
|
11845
|
+
* while the whole subtree underneath stays byte-identical and untouched.
|
|
11846
|
+
*
|
|
11847
|
+
* ## Why a directory's key lives in its parent
|
|
11848
|
+
*
|
|
11849
|
+
* Only the root key is derived from the master seed. Every other directory has
|
|
11850
|
+
* a random key stored in its parent's entry, which chains capabilities
|
|
11851
|
+
* downward: handing someone `{id, key}` for `/apps/my-app` lets them walk that
|
|
11852
|
+
* subtree and nothing else, because no key can be derived upward. That is the
|
|
11853
|
+
* seam group-sharing hangs off later without re-encrypting the filesystem.
|
|
11854
|
+
*/
|
|
11855
|
+
/** Personal-space KV key holding a directory record. */
|
|
11856
|
+
const dirKey = (id) => `vfs/d/${id}`;
|
|
11857
|
+
/** Personal-space KV key holding one file's prior versions. */
|
|
11858
|
+
const historyKey = (id) => `vfs/h/${id}`;
|
|
11859
|
+
/** The root directory's id is fixed — it is the one node reached without a parent. */
|
|
11860
|
+
const ROOT_ID = "root";
|
|
11861
|
+
/** HKDF label for the seed-derived root key. Sits alongside `muhkoo-storage-v1`. */
|
|
11862
|
+
const VFS_ROOT_INFO = "muhkoo-vfs-v1";
|
|
11863
|
+
/** Raised when the master seed is not held, so the root key cannot be derived. */
|
|
11864
|
+
class VfsLockedError extends Error {
|
|
11865
|
+
constructor() {
|
|
11866
|
+
// Named rather than generic because the caller's remedy is specific and
|
|
11867
|
+
// not guessable from a failed decrypt: re-authenticate to recover the
|
|
11868
|
+
// seed. A page reload alone loses it — it is memory-only, never stored.
|
|
11869
|
+
super("VFS is locked: the master seed is not in memory. Sign in again to unlock.");
|
|
11870
|
+
this.name = "VfsLockedError";
|
|
11871
|
+
}
|
|
11872
|
+
}
|
|
11873
|
+
/** Raised for a path that does not exist. */
|
|
11874
|
+
class VfsNotFoundError extends Error {
|
|
11875
|
+
constructor(path) {
|
|
11876
|
+
super(`VFS: ${path} does not exist`);
|
|
11877
|
+
this.name = "VfsNotFoundError";
|
|
11878
|
+
}
|
|
11879
|
+
}
|
|
11880
|
+
/** Raised when an operation would clobber or misuse an existing entry. */
|
|
11881
|
+
class VfsConflictError extends Error {
|
|
11882
|
+
constructor(message) {
|
|
11883
|
+
super(`VFS: ${message}`);
|
|
11884
|
+
this.name = "VfsConflictError";
|
|
11885
|
+
}
|
|
11886
|
+
}
|
|
11887
|
+
|
|
11888
|
+
/**
|
|
11889
|
+
* Sealing for VFS metadata records.
|
|
11890
|
+
*
|
|
11891
|
+
* Every directory and history record is AES-256-GCM encrypted before it leaves
|
|
11892
|
+
* the process, so the server holds ciphertext and never learns a filename, a
|
|
11893
|
+
* directory shape, or a file size. The manifests inside carry their own chunk
|
|
11894
|
+
* keys, which is why the metadata must be sealed too: a readable directory
|
|
11895
|
+
* record would hand over the keys to its files.
|
|
11896
|
+
*/
|
|
11897
|
+
const AES_GCM_IV_BYTES = 12;
|
|
11898
|
+
const AES_KEY_BYTES = 32;
|
|
11899
|
+
function toBase64(bytes) {
|
|
11900
|
+
let s = "";
|
|
11901
|
+
for (const b of bytes)
|
|
11902
|
+
s += String.fromCharCode(b);
|
|
11903
|
+
return btoa(s);
|
|
11904
|
+
}
|
|
11905
|
+
function fromBase64(b64) {
|
|
11906
|
+
const s = atob(b64);
|
|
11907
|
+
const out = new Uint8Array(s.length);
|
|
11908
|
+
for (let i = 0; i < s.length; i++)
|
|
11909
|
+
out[i] = s.charCodeAt(i);
|
|
11910
|
+
return out;
|
|
11911
|
+
}
|
|
11912
|
+
/** A fresh random key for a newly created directory. */
|
|
11913
|
+
function newDirKey() {
|
|
11914
|
+
return randomBytes(AES_KEY_BYTES);
|
|
11915
|
+
}
|
|
11916
|
+
/**
|
|
11917
|
+
* A fresh random id.
|
|
11918
|
+
*
|
|
11919
|
+
* Ids are opaque and never derived from names or contents — a predictable id
|
|
11920
|
+
* would leak the directory structure through the KV key, which is the one part
|
|
11921
|
+
* of a record the server does see.
|
|
11922
|
+
*/
|
|
11923
|
+
function newId() {
|
|
11924
|
+
return toBase64(randomBytes(16)).replace(/[+/=]/g, "").slice(0, 22);
|
|
11925
|
+
}
|
|
11926
|
+
/** The root key, derived from the master seed. The only key not stored in a parent. */
|
|
11927
|
+
/**
|
|
11928
|
+
* The key a project's repository objects are sealed with.
|
|
11929
|
+
*
|
|
11930
|
+
* Per project, not one key for everything: commits, trees and refs describe a
|
|
11931
|
+
* single project's history, and deriving per slug keeps that blast radius small
|
|
11932
|
+
* if a key is ever shared to collaborate on one project.
|
|
11933
|
+
*/
|
|
11934
|
+
async function deriveRepoKey(seed, slug) {
|
|
11935
|
+
return deriveBitsHkdf(seed, `${VFS_ROOT_INFO}:vcs:${slug}`, AES_KEY_BYTES);
|
|
11936
|
+
}
|
|
11937
|
+
async function deriveRootKey(seed) {
|
|
11938
|
+
return deriveBitsHkdf(seed, VFS_ROOT_INFO, AES_KEY_BYTES);
|
|
11939
|
+
}
|
|
11940
|
+
async function seal(key, value) {
|
|
11941
|
+
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
11942
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
|
11943
|
+
const ct = await encryptAesGcm(key, iv, plaintext);
|
|
11944
|
+
return { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
11945
|
+
}
|
|
11946
|
+
/**
|
|
11947
|
+
* Open a sealed record. Returns null for anything that is not a well-formed
|
|
11948
|
+
* envelope, so a missing or corrupt record reads as "absent" rather than
|
|
11949
|
+
* throwing — the caller can then rebuild it. A WRONG KEY still throws, because
|
|
11950
|
+
* silently treating an undecryptable directory as empty would present the user
|
|
11951
|
+
* with an empty filesystem and then let a write overwrite the real one.
|
|
11952
|
+
*/
|
|
11953
|
+
async function unseal(key, record) {
|
|
11954
|
+
if (!record || typeof record !== "object")
|
|
11955
|
+
return null;
|
|
11956
|
+
const r = record;
|
|
11957
|
+
if (typeof r.iv !== "string" || typeof r.ct !== "string")
|
|
11958
|
+
return null;
|
|
11959
|
+
const plaintext = await decryptAesGcm(key, fromBase64(r.iv), fromBase64(r.ct));
|
|
11960
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
11961
|
+
}
|
|
11962
|
+
|
|
11963
|
+
/**
|
|
11964
|
+
* Absolute POSIX paths for the VFS.
|
|
11965
|
+
*
|
|
11966
|
+
* A path is a lookup route, not a stored value: nothing in the metadata records
|
|
11967
|
+
* a full path, only a name inside its parent directory. That is what makes
|
|
11968
|
+
* renaming or moving a directory a single-record edit instead of a rewrite of
|
|
11969
|
+
* every descendant.
|
|
11970
|
+
*/
|
|
11971
|
+
/** Absolute, single leading slash, no `.` / `..`, no trailing slash. Root is `/`. */
|
|
11972
|
+
function normalizePath(input) {
|
|
11973
|
+
const out = [];
|
|
11974
|
+
for (const part of input.split("/")) {
|
|
11975
|
+
if (part === "" || part === ".")
|
|
11976
|
+
continue;
|
|
11977
|
+
if (part === "..")
|
|
11978
|
+
out.pop();
|
|
11979
|
+
else
|
|
11980
|
+
out.push(part);
|
|
11981
|
+
}
|
|
11982
|
+
return "/" + out.join("/");
|
|
11983
|
+
}
|
|
11984
|
+
/** Path split into its segments. `/` yields `[]`. */
|
|
11985
|
+
function segments(path) {
|
|
11986
|
+
return normalizePath(path).split("/").filter(Boolean);
|
|
11987
|
+
}
|
|
11988
|
+
/**
|
|
11989
|
+
* Resolve `path` against a working directory, POSIX-style.
|
|
11990
|
+
*
|
|
11991
|
+
* An absolute path ignores the cwd; a relative one hangs off it; `..` climbs
|
|
11992
|
+
* and stops at the root rather than escaping it. This is the single place
|
|
11993
|
+
* relativity is interpreted, so every VFS method behaves the same way — a
|
|
11994
|
+
* filesystem where `cat a.txt` and `list("a")` disagreed about what "here"
|
|
11995
|
+
* means would be worse than having no cwd at all.
|
|
11996
|
+
*/
|
|
11997
|
+
function resolveFrom(cwd, path) {
|
|
11998
|
+
if (path.startsWith("/"))
|
|
11999
|
+
return normalizePath(path);
|
|
12000
|
+
return normalizePath(`${cwd}/${path}`);
|
|
12001
|
+
}
|
|
12002
|
+
function dirname(path) {
|
|
12003
|
+
const p = normalizePath(path);
|
|
12004
|
+
const i = p.lastIndexOf("/");
|
|
12005
|
+
return i <= 0 ? "/" : p.slice(0, i);
|
|
12006
|
+
}
|
|
12007
|
+
function basename(path) {
|
|
12008
|
+
const p = normalizePath(path);
|
|
12009
|
+
return p.slice(p.lastIndexOf("/") + 1);
|
|
12010
|
+
}
|
|
12011
|
+
function join(...parts) {
|
|
12012
|
+
return normalizePath(parts.join("/"));
|
|
12013
|
+
}
|
|
12014
|
+
/**
|
|
12015
|
+
* Is `path` inside `dir` (at any depth)? A directory is never "under" itself,
|
|
12016
|
+
* which is what makes it usable as the guard against moving a directory into
|
|
12017
|
+
* its own subtree.
|
|
12018
|
+
*/
|
|
12019
|
+
function isUnder(path, dir) {
|
|
12020
|
+
const p = normalizePath(path);
|
|
12021
|
+
const d = normalizePath(dir);
|
|
12022
|
+
if (p === d)
|
|
12023
|
+
return false;
|
|
12024
|
+
return d === "/" ? true : p.startsWith(d + "/");
|
|
12025
|
+
}
|
|
12026
|
+
/**
|
|
12027
|
+
* Reject names that would make a path ambiguous or unrepresentable.
|
|
12028
|
+
*
|
|
12029
|
+
* `/` would forge a nesting level, and `.` / `..` would resolve to a different
|
|
12030
|
+
* directory on the next lookup — a file literally named `..` could never be
|
|
12031
|
+
* addressed again.
|
|
12032
|
+
*/
|
|
12033
|
+
function assertValidName(name) {
|
|
12034
|
+
if (!name)
|
|
12035
|
+
throw new Error("VFS: name cannot be empty");
|
|
12036
|
+
if (name === "." || name === "..")
|
|
12037
|
+
throw new Error(`VFS: "${name}" is not a usable name`);
|
|
12038
|
+
if (name.includes("/"))
|
|
12039
|
+
throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
|
|
12040
|
+
}
|
|
12041
|
+
/**
|
|
12042
|
+
* MIME type for a filename.
|
|
12043
|
+
*
|
|
12044
|
+
* Stored with the content rather than guessed at read time, because whatever
|
|
12045
|
+
* eventually serves these bytes — a preview, a static host — sends this as the
|
|
12046
|
+
* `Content-Type`, and a browser will refuse to run a module or apply a
|
|
12047
|
+
* stylesheet labelled `application/octet-stream`.
|
|
12048
|
+
*/
|
|
12049
|
+
function contentTypeFor(name) {
|
|
12050
|
+
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
12051
|
+
switch (ext) {
|
|
12052
|
+
case "html":
|
|
12053
|
+
case "htm": return "text/html; charset=utf-8";
|
|
12054
|
+
case "css": return "text/css; charset=utf-8";
|
|
12055
|
+
case "js":
|
|
12056
|
+
case "mjs":
|
|
12057
|
+
case "jsx": return "text/javascript; charset=utf-8";
|
|
12058
|
+
case "ts":
|
|
12059
|
+
case "tsx": return "text/typescript; charset=utf-8";
|
|
12060
|
+
case "json": return "application/json; charset=utf-8";
|
|
12061
|
+
case "md": return "text/markdown; charset=utf-8";
|
|
12062
|
+
case "txt": return "text/plain; charset=utf-8";
|
|
12063
|
+
case "svg": return "image/svg+xml";
|
|
12064
|
+
case "png": return "image/png";
|
|
12065
|
+
case "jpg":
|
|
12066
|
+
case "jpeg": return "image/jpeg";
|
|
12067
|
+
case "gif": return "image/gif";
|
|
12068
|
+
case "webp": return "image/webp";
|
|
12069
|
+
case "avif": return "image/avif";
|
|
12070
|
+
case "ico": return "image/x-icon";
|
|
12071
|
+
case "woff2": return "font/woff2";
|
|
12072
|
+
case "woff": return "font/woff";
|
|
12073
|
+
case "wasm": return "application/wasm";
|
|
12074
|
+
case "map": return "application/json";
|
|
12075
|
+
default: return "application/octet-stream";
|
|
12076
|
+
}
|
|
12077
|
+
}
|
|
12078
|
+
|
|
12079
|
+
/**
|
|
12080
|
+
* Glob matching for VFS paths.
|
|
12081
|
+
*
|
|
12082
|
+
* A deliberately small dialect — `*`, `**`, `?`, and `{a,b}` — because these
|
|
12083
|
+
* cover what a project actually asks for (`**\/*.tsx`, `src/*.css`) and every
|
|
12084
|
+
* addition beyond them is a new way for a pattern to mean something the caller
|
|
12085
|
+
* did not expect.
|
|
12086
|
+
*/
|
|
12087
|
+
/** `*` stops at a separator; `**` crosses them. That distinction is the whole point. */
|
|
12088
|
+
function globToRegExp(pattern) {
|
|
12089
|
+
let out = "";
|
|
12090
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
12091
|
+
const c = pattern[i];
|
|
12092
|
+
if (c === "*") {
|
|
12093
|
+
if (pattern[i + 1] === "*") {
|
|
12094
|
+
// `**/` should also match zero directories, so `/**/x` finds `/x`.
|
|
12095
|
+
if (pattern[i + 2] === "/") {
|
|
12096
|
+
out += "(?:.*/)?";
|
|
12097
|
+
i += 2;
|
|
12098
|
+
}
|
|
12099
|
+
else {
|
|
12100
|
+
out += ".*";
|
|
12101
|
+
i += 1;
|
|
12102
|
+
}
|
|
12103
|
+
}
|
|
12104
|
+
else {
|
|
12105
|
+
out += "[^/]*";
|
|
12106
|
+
}
|
|
12107
|
+
}
|
|
12108
|
+
else if (c === "?") {
|
|
12109
|
+
out += "[^/]";
|
|
12110
|
+
}
|
|
12111
|
+
else if (c === "{") {
|
|
12112
|
+
const close = pattern.indexOf("}", i);
|
|
12113
|
+
if (close === -1) {
|
|
12114
|
+
out += "\\{";
|
|
12115
|
+
}
|
|
12116
|
+
else {
|
|
12117
|
+
const alts = pattern.slice(i + 1, close).split(",");
|
|
12118
|
+
out += `(?:${alts.map(escapeLiteral).join("|")})`;
|
|
12119
|
+
i = close;
|
|
12120
|
+
}
|
|
12121
|
+
}
|
|
12122
|
+
else {
|
|
12123
|
+
out += escapeLiteral(c);
|
|
12124
|
+
}
|
|
12125
|
+
}
|
|
12126
|
+
return new RegExp(`^${out}$`);
|
|
12127
|
+
}
|
|
12128
|
+
function escapeLiteral(s) {
|
|
12129
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12130
|
+
}
|
|
12131
|
+
|
|
12132
|
+
/**
|
|
12133
|
+
* `client.vfs` — a real filesystem over Muhkoo primitives.
|
|
12134
|
+
*
|
|
12135
|
+
* Metadata is a graph of encrypted directory records in the user's personal
|
|
12136
|
+
* space; file content is chunked, encrypted and stored in the global
|
|
12137
|
+
* content-addressed shard store, referenced by manifest. See `types.ts` for
|
|
12138
|
+
* why directories carry stable random ids and why each key lives in its parent.
|
|
12139
|
+
*
|
|
12140
|
+
* ## Consistency
|
|
12141
|
+
*
|
|
12142
|
+
* Every mutation is a read-modify-write of exactly ONE directory record, so a
|
|
12143
|
+
* write cannot leave the tree half-updated: either the parent names the new
|
|
12144
|
+
* child or it does not. Two tabs writing different directories never conflict;
|
|
12145
|
+
* two tabs writing the SAME directory resolve last-writer-wins at the personal
|
|
12146
|
+
* space, which carries an HLC per key. Orphaned records (a directory whose
|
|
12147
|
+
* parent entry lost the race) are unreachable rather than corrupting — they
|
|
12148
|
+
* cost storage until a future sweep, which is the trade this shape makes in
|
|
12149
|
+
* exchange for single-record writes.
|
|
12150
|
+
*/
|
|
12151
|
+
const DEFAULT_HISTORY_LIMIT = 20;
|
|
12152
|
+
class VfsNamespace {
|
|
12153
|
+
deps;
|
|
12154
|
+
rootKey = null;
|
|
12155
|
+
/**
|
|
12156
|
+
* Decrypted directory records, by id.
|
|
12157
|
+
*
|
|
12158
|
+
* The file tree re-lists constantly while typing, and every miss is a
|
|
12159
|
+
* network round trip plus a decrypt. Invalidated precisely on write rather
|
|
12160
|
+
* than by TTL — a stale directory listing shows files that are not there.
|
|
12161
|
+
*/
|
|
12162
|
+
cache = new Map();
|
|
12163
|
+
historyLimit;
|
|
12164
|
+
constructor(deps) {
|
|
12165
|
+
this.deps = deps;
|
|
12166
|
+
this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
12167
|
+
}
|
|
12168
|
+
/**
|
|
12169
|
+
* The working directory relative paths resolve against.
|
|
12170
|
+
*
|
|
12171
|
+
* Kept here rather than left to each caller so that a CLI, a script and the
|
|
12172
|
+
* SDK all agree on what "here" means — the alternative is every consumer
|
|
12173
|
+
* re-implementing `cd`, and disagreeing about `..`.
|
|
12174
|
+
*/
|
|
12175
|
+
_cwd = "/";
|
|
12176
|
+
/** The current working directory. Always absolute. */
|
|
12177
|
+
get cwd() {
|
|
12178
|
+
return this._cwd;
|
|
12179
|
+
}
|
|
12180
|
+
/**
|
|
12181
|
+
* Change the working directory.
|
|
12182
|
+
*
|
|
12183
|
+
* Verifies the target is a real directory, so a typo fails here — where the
|
|
12184
|
+
* message can name the path — rather than turning every later relative path
|
|
12185
|
+
* into a confusing "does not exist".
|
|
12186
|
+
*/
|
|
12187
|
+
async cd(path) {
|
|
12188
|
+
const target = this.resolve(path);
|
|
12189
|
+
if (target !== "/") {
|
|
12190
|
+
const found = await this.find(target);
|
|
12191
|
+
if (!found)
|
|
12192
|
+
throw new VfsNotFoundError(target);
|
|
12193
|
+
if (found.entry.kind !== "dir")
|
|
12194
|
+
throw new VfsConflictError(`${target} is not a directory`);
|
|
12195
|
+
}
|
|
12196
|
+
this._cwd = target;
|
|
12197
|
+
return target;
|
|
12198
|
+
}
|
|
12199
|
+
/** Resolve a path against the working directory. Absolute paths pass through. */
|
|
12200
|
+
resolve(path) {
|
|
12201
|
+
return resolveFrom(this._cwd, path);
|
|
12202
|
+
}
|
|
12203
|
+
/** True when the master seed is held, so the root key can be derived. */
|
|
12204
|
+
get unlocked() {
|
|
12205
|
+
return this.deps.seed() !== null;
|
|
12206
|
+
}
|
|
12207
|
+
/**
|
|
12208
|
+
* Drop every cached record.
|
|
12209
|
+
*
|
|
12210
|
+
* Call after signing in as someone else — the cache is keyed by directory
|
|
12211
|
+
* id with no notion of whose filesystem it came from, and serving one
|
|
12212
|
+
* identity's directories to another would be the worst possible bug here.
|
|
12213
|
+
*/
|
|
12214
|
+
clearCache() {
|
|
12215
|
+
this.cache.clear();
|
|
12216
|
+
this.rootKey = null;
|
|
12217
|
+
this._cwd = "/"; // a different identity's tree has different directories
|
|
12218
|
+
}
|
|
12219
|
+
/**
|
|
12220
|
+
* Call `handler` when this filesystem changes somewhere else — another tab,
|
|
12221
|
+
* another machine, the CLI.
|
|
12222
|
+
*
|
|
12223
|
+
* Deliberately a NOTIFICATION, not a diff: the frame names a record id, and
|
|
12224
|
+
* a record id is not a path (paths are resolved by walking, and the walk is
|
|
12225
|
+
* what the change may have invalidated). So the cache is dropped and the
|
|
12226
|
+
* caller re-reads whatever it is actually showing. Trying to translate a
|
|
12227
|
+
* record id back into a path would mean maintaining a reverse index that
|
|
12228
|
+
* could itself go stale.
|
|
12229
|
+
*
|
|
12230
|
+
* Returns an unsubscribe function. A runtime with no socket (a CLI) gets a
|
|
12231
|
+
* no-op rather than an error — watching is an optimisation, never required
|
|
12232
|
+
* for correctness.
|
|
12233
|
+
*/
|
|
12234
|
+
watch(handler) {
|
|
12235
|
+
if (!this.deps.subscribe)
|
|
12236
|
+
return () => { };
|
|
12237
|
+
return this.deps.subscribe((raw) => {
|
|
12238
|
+
let frame;
|
|
12239
|
+
try {
|
|
12240
|
+
frame = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
12241
|
+
}
|
|
12242
|
+
catch {
|
|
12243
|
+
return;
|
|
12244
|
+
}
|
|
12245
|
+
if (frame?._t !== "change" || typeof frame.key !== "string")
|
|
12246
|
+
return;
|
|
12247
|
+
if (!frame.key.startsWith("vfs/"))
|
|
12248
|
+
return; // someone else's data on the shared feed
|
|
12249
|
+
// Drop only the record that changed.
|
|
12250
|
+
//
|
|
12251
|
+
// Clearing everything looked cheap and is not: our own writes echo
|
|
12252
|
+
// back, so a sync that writes a few hundred files emptied the cache
|
|
12253
|
+
// a few hundred times, and every path resolution in flight went back
|
|
12254
|
+
// to the network for the root and every directory below it. The key
|
|
12255
|
+
// names the record, so there is no need to guess.
|
|
12256
|
+
const id = frame.key.startsWith("vfs/d/") ? frame.key.slice("vfs/d/".length) : null;
|
|
12257
|
+
if (id)
|
|
12258
|
+
this.cache.delete(id);
|
|
12259
|
+
else
|
|
12260
|
+
this.cache.clear(); // a shape we do not recognise: be safe
|
|
12261
|
+
handler();
|
|
12262
|
+
});
|
|
12263
|
+
}
|
|
12264
|
+
// ---- reads --------------------------------------------------------------
|
|
12265
|
+
async exists(path) {
|
|
12266
|
+
return (await this.find(path)) !== null;
|
|
12267
|
+
}
|
|
12268
|
+
async stat(path) {
|
|
12269
|
+
const found = await this.find(path);
|
|
12270
|
+
if (!found)
|
|
12271
|
+
throw new VfsNotFoundError(this.resolve(path));
|
|
12272
|
+
return toStat(this.resolve(path), found.name, found.entry);
|
|
12273
|
+
}
|
|
12274
|
+
/** Immediate children of a directory. One record fetch. */
|
|
12275
|
+
async list(path = ".") {
|
|
12276
|
+
const dir = await this.openDir(path);
|
|
12277
|
+
const base = this.resolve(path);
|
|
12278
|
+
return Object.entries(dir.node.entries)
|
|
12279
|
+
.map(([name, entry]) => toStat(join(base, name), name, entry))
|
|
12280
|
+
.sort(byDirsThenName);
|
|
12281
|
+
}
|
|
12282
|
+
/**
|
|
12283
|
+
* Every file path under a prefix, depth-first.
|
|
12284
|
+
*
|
|
12285
|
+
* Deliberately separate from `list`: this walks the whole subtree, which is
|
|
12286
|
+
* one fetch per directory. Fine for a project, wrong as a default.
|
|
12287
|
+
*/
|
|
12288
|
+
async walk(path = ".") {
|
|
12289
|
+
const out = [];
|
|
12290
|
+
const visit = async (dirPath) => {
|
|
12291
|
+
for (const entry of await this.list(dirPath)) {
|
|
12292
|
+
if (entry.kind === "dir")
|
|
12293
|
+
await visit(entry.path);
|
|
12294
|
+
else
|
|
12295
|
+
out.push(entry.path);
|
|
12296
|
+
}
|
|
12297
|
+
};
|
|
12298
|
+
await visit(this.resolve(path));
|
|
12299
|
+
return out;
|
|
12300
|
+
}
|
|
12301
|
+
async readFile(path) {
|
|
12302
|
+
const entry = await this.expectFile(path);
|
|
12303
|
+
return this.deps.content.read(entry.manifest);
|
|
12304
|
+
}
|
|
12305
|
+
async readText(path) {
|
|
12306
|
+
return new TextDecoder().decode(await this.readFile(path));
|
|
12307
|
+
}
|
|
12308
|
+
/**
|
|
12309
|
+
* A file's manifest — the handle itself, without reading the bytes.
|
|
12310
|
+
*
|
|
12311
|
+
* Version control records manifests rather than content, so a commit costs
|
|
12312
|
+
* a hash and a reference, not a copy.
|
|
12313
|
+
*/
|
|
12314
|
+
async statManifest(path) {
|
|
12315
|
+
const entry = await this.expectFile(path);
|
|
12316
|
+
return { manifest: entry.manifest, size: entry.size };
|
|
12317
|
+
}
|
|
12318
|
+
/**
|
|
12319
|
+
* Read content by its manifest, wherever it came from.
|
|
12320
|
+
*
|
|
12321
|
+
* A merge needs the COMMON ANCESTOR's version of a file, which by definition
|
|
12322
|
+
* is not in the working tree any more. The manifest is the whole capability,
|
|
12323
|
+
* so no path lookup is involved.
|
|
12324
|
+
*/
|
|
12325
|
+
async readByManifest(manifest) {
|
|
12326
|
+
return this.deps.content.read(manifest);
|
|
12327
|
+
}
|
|
12328
|
+
/**
|
|
12329
|
+
* Pin content that something other than the working tree depends on.
|
|
12330
|
+
*
|
|
12331
|
+
* A commit references a manifest, so it is an OWNER of that content — without
|
|
12332
|
+
* a reference of its own, deleting the file from the working tree would free
|
|
12333
|
+
* shards the history still needs, and checking out an older commit would find
|
|
12334
|
+
* the content gone. (It did, in test.)
|
|
12335
|
+
*/
|
|
12336
|
+
async retainManifest(manifest) {
|
|
12337
|
+
await this.deps.content.retain(manifest);
|
|
12338
|
+
}
|
|
12339
|
+
/**
|
|
12340
|
+
* Point a path at content that already exists.
|
|
12341
|
+
*
|
|
12342
|
+
* This is how a checkout restores a file: the bytes are in the shard store
|
|
12343
|
+
* already, so nothing is uploaded or downloaded — only the directory record
|
|
12344
|
+
* changes. Takes a reference, because a second path now depends on those
|
|
12345
|
+
* shards.
|
|
12346
|
+
*/
|
|
12347
|
+
async writeManifest(path, manifest, size) {
|
|
12348
|
+
const target = this.resolve(path);
|
|
12349
|
+
const name = basename(target);
|
|
12350
|
+
assertValidName(name);
|
|
12351
|
+
await this.mkdir(dirname(target), { recursive: true });
|
|
12352
|
+
const parent = await this.openDir(dirname(target));
|
|
12353
|
+
const prior = parent.node.entries[name];
|
|
12354
|
+
if (prior?.kind === "dir")
|
|
12355
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
12356
|
+
await this.deps.content.retain(manifest);
|
|
12357
|
+
const entry = {
|
|
12358
|
+
kind: "file",
|
|
12359
|
+
id: prior?.id ?? newId(),
|
|
12360
|
+
manifest,
|
|
12361
|
+
size,
|
|
12362
|
+
mtime: Date.now(),
|
|
12363
|
+
versions: prior?.versions ?? 0,
|
|
12364
|
+
};
|
|
12365
|
+
await this.mutate(parent, (node) => {
|
|
12366
|
+
node.entries[name] = entry;
|
|
12367
|
+
});
|
|
12368
|
+
return toStat(target, name, entry);
|
|
12369
|
+
}
|
|
12370
|
+
// ---- writes -------------------------------------------------------------
|
|
12371
|
+
/**
|
|
12372
|
+
* Create a directory. `recursive` creates missing parents and tolerates an
|
|
12373
|
+
* existing target, matching `mkdir -p`.
|
|
12374
|
+
*/
|
|
12375
|
+
async mkdir(path, opts = {}) {
|
|
12376
|
+
const target = this.resolve(path);
|
|
12377
|
+
if (target === "/")
|
|
12378
|
+
return;
|
|
12379
|
+
const parts = segments(target);
|
|
12380
|
+
let current = "/";
|
|
12381
|
+
for (let i = 0; i < parts.length; i++) {
|
|
12382
|
+
const name = parts[i];
|
|
12383
|
+
const isLast = i === parts.length - 1;
|
|
12384
|
+
const next = join(current, name);
|
|
12385
|
+
const existing = (await this.openDir(current)).node.entries[name];
|
|
12386
|
+
if (existing) {
|
|
12387
|
+
if (existing.kind === "file")
|
|
12388
|
+
throw new VfsConflictError(`${next} is a file`);
|
|
12389
|
+
if (isLast && !opts.recursive)
|
|
12390
|
+
throw new VfsConflictError(`${next} already exists`);
|
|
12391
|
+
}
|
|
12392
|
+
else {
|
|
12393
|
+
if (!isLast && !opts.recursive)
|
|
12394
|
+
throw new VfsNotFoundError(current);
|
|
12395
|
+
await this.createDir(current, name);
|
|
12396
|
+
}
|
|
12397
|
+
current = next;
|
|
12398
|
+
}
|
|
12399
|
+
}
|
|
12400
|
+
/**
|
|
12401
|
+
* Write a file, creating parent directories as needed.
|
|
12402
|
+
*
|
|
12403
|
+
* The previous version is pushed onto the file's history record before the
|
|
12404
|
+
* directory entry is replaced, so an interrupted write can lose the new
|
|
12405
|
+
* version but never the old one.
|
|
12406
|
+
*/
|
|
12407
|
+
async writeFile(path, data, opts = {}) {
|
|
12408
|
+
const target = this.resolve(path);
|
|
12409
|
+
const name = basename(target);
|
|
12410
|
+
assertValidName(name);
|
|
12411
|
+
const parentPath = dirname(target);
|
|
12412
|
+
await this.mkdir(parentPath, { recursive: true });
|
|
12413
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
12414
|
+
const { manifest, size } = await this.deps.content.write(bytes, {
|
|
12415
|
+
name,
|
|
12416
|
+
type: opts.type ?? contentTypeFor(name),
|
|
12417
|
+
});
|
|
12418
|
+
const parent = await this.openDir(parentPath);
|
|
12419
|
+
const prior = parent.node.entries[name];
|
|
12420
|
+
if (prior?.kind === "dir")
|
|
12421
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
12422
|
+
const id = prior?.id ?? newId();
|
|
12423
|
+
let versions = prior?.versions ?? 0;
|
|
12424
|
+
if (prior)
|
|
12425
|
+
versions = await this.pushHistory(parent, id, prior);
|
|
12426
|
+
const entry = { kind: "file", id, manifest, size, mtime: Date.now(), versions };
|
|
12427
|
+
await this.mutate(parent, (node) => {
|
|
12428
|
+
node.entries[name] = entry;
|
|
12429
|
+
});
|
|
12430
|
+
return toStat(target, name, entry);
|
|
12431
|
+
}
|
|
12432
|
+
/**
|
|
12433
|
+
* Delete a file, or a directory when `recursive`.
|
|
12434
|
+
*
|
|
12435
|
+
* `keepContent` removes the entry WITHOUT releasing its shards. A checkout
|
|
12436
|
+
* needs that: the file is leaving the working tree, but commits still
|
|
12437
|
+
* reference the same manifest, and releasing would free content history
|
|
12438
|
+
* depends on.
|
|
12439
|
+
*/
|
|
12440
|
+
async delete(path, opts = {}) {
|
|
12441
|
+
const target = this.resolve(path);
|
|
12442
|
+
if (target === "/")
|
|
12443
|
+
throw new VfsConflictError("the root directory cannot be deleted");
|
|
12444
|
+
const name = basename(target);
|
|
12445
|
+
const parent = await this.openDir(dirname(target));
|
|
12446
|
+
const entry = parent.node.entries[name];
|
|
12447
|
+
if (!entry)
|
|
12448
|
+
throw new VfsNotFoundError(target);
|
|
12449
|
+
if (entry.kind === "dir") {
|
|
12450
|
+
const child = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12451
|
+
const empty = Object.keys(child.node.entries).length === 0;
|
|
12452
|
+
if (!empty && !opts.recursive)
|
|
12453
|
+
throw new VfsConflictError(`${target} is not empty`);
|
|
12454
|
+
await this.purge(child);
|
|
12455
|
+
}
|
|
12456
|
+
else if (opts.keepContent) {
|
|
12457
|
+
await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
|
|
12458
|
+
}
|
|
12459
|
+
else {
|
|
12460
|
+
await this.releaseFile(parent, entry);
|
|
12461
|
+
}
|
|
12462
|
+
await this.mutate(parent, (node) => {
|
|
12463
|
+
delete node.entries[name];
|
|
12464
|
+
});
|
|
12465
|
+
}
|
|
12466
|
+
/**
|
|
12467
|
+
* Move or rename. Works across directories, which is the same operation:
|
|
12468
|
+
* detach the entry from one parent and attach it to another.
|
|
12469
|
+
*
|
|
12470
|
+
* A directory move is O(1) regardless of how much is inside it — the
|
|
12471
|
+
* subtree records are not touched at all, because nothing in them records
|
|
12472
|
+
* its own path.
|
|
12473
|
+
*/
|
|
12474
|
+
async rename(from, to) {
|
|
12475
|
+
const src = this.resolve(from);
|
|
12476
|
+
const dst = this.resolve(to);
|
|
12477
|
+
if (src === dst)
|
|
12478
|
+
return;
|
|
12479
|
+
if (src === "/")
|
|
12480
|
+
throw new VfsConflictError("the root directory cannot be moved");
|
|
12481
|
+
if (isUnder(dst, src))
|
|
12482
|
+
throw new VfsConflictError(`cannot move ${src} into itself`);
|
|
12483
|
+
const dstName = basename(dst);
|
|
12484
|
+
assertValidName(dstName);
|
|
12485
|
+
const srcParent = await this.openDir(dirname(src));
|
|
12486
|
+
const entry = srcParent.node.entries[basename(src)];
|
|
12487
|
+
if (!entry)
|
|
12488
|
+
throw new VfsNotFoundError(src);
|
|
12489
|
+
await this.mkdir(dirname(dst), { recursive: true });
|
|
12490
|
+
const dstParent = await this.openDir(dirname(dst));
|
|
12491
|
+
if (dstParent.node.entries[dstName])
|
|
12492
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
12493
|
+
// Attach before detaching. If the second write fails the entry is
|
|
12494
|
+
// reachable from both parents — visible and fixable. The other order
|
|
12495
|
+
// would lose the subtree entirely.
|
|
12496
|
+
const moved = { ...entry, mtime: Date.now() };
|
|
12497
|
+
await this.mutate(dstParent, (node) => {
|
|
12498
|
+
node.entries[dstName] = moved;
|
|
12499
|
+
});
|
|
12500
|
+
// Re-open: same-parent moves mutated the record we are about to edit.
|
|
12501
|
+
const detachFrom = await this.openDir(dirname(src));
|
|
12502
|
+
await this.mutate(detachFrom, (node) => {
|
|
12503
|
+
delete node.entries[basename(src)];
|
|
12504
|
+
});
|
|
12505
|
+
}
|
|
12506
|
+
/**
|
|
12507
|
+
* Copy a file or a directory tree.
|
|
12508
|
+
*
|
|
12509
|
+
* Copying a FILE moves no bytes: content is immutable and content-addressed,
|
|
12510
|
+
* so the new entry simply points at the same manifest. A 200 MB asset
|
|
12511
|
+
* duplicates in one small metadata write, and the shard store already holds
|
|
12512
|
+
* exactly one copy of it.
|
|
12513
|
+
*
|
|
12514
|
+
* The copy gets a NEW file id, so it does not inherit or share history —
|
|
12515
|
+
* two paths sharing an id would mean saving one silently pushed a version
|
|
12516
|
+
* onto the other.
|
|
12517
|
+
*/
|
|
12518
|
+
async copy(from, to) {
|
|
12519
|
+
const src = this.resolve(from);
|
|
12520
|
+
const dst = this.resolve(to);
|
|
12521
|
+
if (src === "/")
|
|
12522
|
+
throw new VfsConflictError("the root directory cannot be copied");
|
|
12523
|
+
if (src === dst)
|
|
12524
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
12525
|
+
if (isUnder(dst, src))
|
|
12526
|
+
throw new VfsConflictError(`cannot copy ${src} into itself`);
|
|
12527
|
+
const found = await this.find(src);
|
|
12528
|
+
if (!found)
|
|
12529
|
+
throw new VfsNotFoundError(src);
|
|
12530
|
+
const dstName = basename(dst);
|
|
12531
|
+
assertValidName(dstName);
|
|
12532
|
+
await this.mkdir(dirname(dst), { recursive: true });
|
|
12533
|
+
const dstParent = await this.openDir(dirname(dst));
|
|
12534
|
+
if (dstParent.node.entries[dstName])
|
|
12535
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
12536
|
+
const copied = await this.cloneEntry(found.entry);
|
|
12537
|
+
await this.mutate(dstParent, (node) => {
|
|
12538
|
+
node.entries[dstName] = copied;
|
|
12539
|
+
});
|
|
12540
|
+
return toStat(dst, dstName, copied);
|
|
12541
|
+
}
|
|
12542
|
+
/**
|
|
12543
|
+
* Paths matching a glob, relative to the whole filesystem.
|
|
12544
|
+
*
|
|
12545
|
+
* Matching happens on the client over a walk of the tree, so the cost is one
|
|
12546
|
+
* record fetch per directory — cheap for a project, and worth knowing before
|
|
12547
|
+
* pointing it at the root of a large filesystem. Use `walk` when the prefix
|
|
12548
|
+
* already narrows it.
|
|
12549
|
+
*/
|
|
12550
|
+
async glob(pattern, opts = {}) {
|
|
12551
|
+
const re = globToRegExp(this.resolve(pattern));
|
|
12552
|
+
return (await this.walk(opts.from ?? ".")).filter((path) => re.test(path));
|
|
12553
|
+
}
|
|
12554
|
+
/**
|
|
12555
|
+
* Delete metadata records no longer reachable from the root.
|
|
12556
|
+
*
|
|
12557
|
+
* These accumulate because every write touches exactly one record: if two
|
|
12558
|
+
* tabs edit the same directory, last-writer-wins at the personal space can
|
|
12559
|
+
* leave a child whose parent entry lost the race. Unreachable is harmless —
|
|
12560
|
+
* it is invisible and undecryptable-by-nobody — but it costs storage, so
|
|
12561
|
+
* this reclaims it.
|
|
12562
|
+
*
|
|
12563
|
+
* Deliberately explicit, never automatic: a sweep racing another tab's
|
|
12564
|
+
* in-flight write could delete a record that is about to be referenced.
|
|
12565
|
+
* Run it when the filesystem is idle.
|
|
12566
|
+
*/
|
|
12567
|
+
async sweep(opts = {}) {
|
|
12568
|
+
const root = await this.root();
|
|
12569
|
+
const reachable = new Set();
|
|
12570
|
+
const visit = async (dir) => {
|
|
12571
|
+
reachable.add(dirKey(dir.id));
|
|
12572
|
+
for (const entry of Object.values(dir.node.entries)) {
|
|
12573
|
+
if (entry.kind === "dir") {
|
|
12574
|
+
await visit(await this.openDirById(entry.id, fromBase64(entry.key)));
|
|
12575
|
+
}
|
|
12576
|
+
else {
|
|
12577
|
+
reachable.add(historyKey(entry.id));
|
|
12578
|
+
}
|
|
12579
|
+
}
|
|
12580
|
+
};
|
|
12581
|
+
await visit(root);
|
|
12582
|
+
// Only ever consider OUR records: the personal space is shared with
|
|
12583
|
+
// chat keys, space keys and the legacy file mirror, and a sweep that
|
|
12584
|
+
// reached beyond its own namespace would be a data-loss bug.
|
|
12585
|
+
const mine = (await this.deps.store.list()).filter((k) => /^vfs\/[dh]\//.test(k));
|
|
12586
|
+
const orphans = mine.filter((k) => !reachable.has(k));
|
|
12587
|
+
// A root that read as EMPTY while orphans exist is the shape of a failed
|
|
12588
|
+
// load, not of a fresh filesystem — deleting here would wipe a tree we
|
|
12589
|
+
// merely failed to reach. Refuse unless the caller insists.
|
|
12590
|
+
const rootEmpty = Object.keys(root.node.entries).length === 0;
|
|
12591
|
+
if (rootEmpty && orphans.length && !opts.force) {
|
|
12592
|
+
throw new VfsConflictError(`refusing to sweep ${orphans.length} record(s) with an empty root — ` +
|
|
12593
|
+
"this looks like a failed load rather than an empty filesystem; pass { force: true } if it really is empty");
|
|
12594
|
+
}
|
|
12595
|
+
for (const key of orphans)
|
|
12596
|
+
await this.deps.store.delete(key).catch(() => { });
|
|
12597
|
+
return { removed: orphans };
|
|
12598
|
+
}
|
|
12599
|
+
// ---- history ------------------------------------------------------------
|
|
12600
|
+
/** Prior versions of a file, newest first. One record fetch. */
|
|
12601
|
+
async history(path) {
|
|
12602
|
+
const entry = await this.expectFile(path);
|
|
12603
|
+
const record = await this.readHistory(await this.openDir(dirname(path)), entry.id);
|
|
12604
|
+
return record.versions.map(({ size, mtime }) => ({ size, mtime }));
|
|
12605
|
+
}
|
|
12606
|
+
/**
|
|
12607
|
+
* Restore a prior version, where 0 is the most recent one.
|
|
12608
|
+
*
|
|
12609
|
+
* The current version is pushed onto history first, so restoring is itself
|
|
12610
|
+
* undoable rather than a one-way door.
|
|
12611
|
+
*/
|
|
12612
|
+
async restore(path, index = 0) {
|
|
12613
|
+
const target = this.resolve(path);
|
|
12614
|
+
const name = basename(target);
|
|
12615
|
+
const parent = await this.openDir(dirname(target));
|
|
12616
|
+
const entry = parent.node.entries[name];
|
|
12617
|
+
if (!entry || entry.kind !== "file")
|
|
12618
|
+
throw new VfsNotFoundError(target);
|
|
12619
|
+
const record = await this.readHistory(parent, entry.id);
|
|
12620
|
+
const version = record.versions[index];
|
|
12621
|
+
if (!version)
|
|
12622
|
+
throw new VfsNotFoundError(`${target} version ${index}`);
|
|
12623
|
+
const versions = await this.pushHistory(parent, entry.id, entry);
|
|
12624
|
+
const restored = {
|
|
12625
|
+
kind: "file",
|
|
12626
|
+
id: entry.id,
|
|
12627
|
+
manifest: version.manifest,
|
|
12628
|
+
size: version.size,
|
|
12629
|
+
mtime: Date.now(),
|
|
12630
|
+
versions,
|
|
12631
|
+
};
|
|
12632
|
+
await this.mutate(parent, (node) => {
|
|
12633
|
+
node.entries[name] = restored;
|
|
12634
|
+
});
|
|
12635
|
+
return toStat(target, name, restored);
|
|
12636
|
+
}
|
|
12637
|
+
// ---- internals ----------------------------------------------------------
|
|
12638
|
+
async expectFile(path) {
|
|
12639
|
+
const found = await this.find(path);
|
|
12640
|
+
if (!found)
|
|
12641
|
+
throw new VfsNotFoundError(this.resolve(path));
|
|
12642
|
+
if (found.entry.kind !== "file")
|
|
12643
|
+
throw new VfsConflictError(`${this.resolve(path)} is a directory`);
|
|
12644
|
+
return found.entry;
|
|
12645
|
+
}
|
|
12646
|
+
/** Resolve a path to its entry, or null. Root has no entry of its own. */
|
|
12647
|
+
async find(path) {
|
|
12648
|
+
const target = this.resolve(path);
|
|
12649
|
+
if (target === "/")
|
|
12650
|
+
return null;
|
|
12651
|
+
const parent = await this.openDirOrNull(dirname(target));
|
|
12652
|
+
if (!parent)
|
|
12653
|
+
return null;
|
|
12654
|
+
const name = basename(target);
|
|
12655
|
+
const entry = parent.node.entries[name];
|
|
12656
|
+
return entry ? { name, entry } : null;
|
|
12657
|
+
}
|
|
12658
|
+
async root() {
|
|
12659
|
+
const seed = this.deps.seed();
|
|
12660
|
+
if (!seed)
|
|
12661
|
+
throw new VfsLockedError();
|
|
12662
|
+
if (!this.rootKey)
|
|
12663
|
+
this.rootKey = await deriveRootKey(seed);
|
|
12664
|
+
return this.openDirById(ROOT_ID, this.rootKey);
|
|
12665
|
+
}
|
|
12666
|
+
/** Open a directory by path, creating nothing. Throws if it is missing. */
|
|
12667
|
+
async openDir(path) {
|
|
12668
|
+
const dir = await this.openDirOrNull(path);
|
|
12669
|
+
if (!dir)
|
|
12670
|
+
throw new VfsNotFoundError(this.resolve(path));
|
|
12671
|
+
return dir;
|
|
12672
|
+
}
|
|
12673
|
+
async openDirOrNull(path) {
|
|
12674
|
+
let dir = await this.root();
|
|
12675
|
+
for (const name of segments(this.resolve(path))) {
|
|
12676
|
+
const entry = dir.node.entries[name];
|
|
12677
|
+
if (!entry || entry.kind !== "dir")
|
|
12678
|
+
return null;
|
|
12679
|
+
dir = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12680
|
+
}
|
|
12681
|
+
return dir;
|
|
12682
|
+
}
|
|
12683
|
+
openDirById(id, key) {
|
|
12684
|
+
const hit = this.cache.get(id);
|
|
12685
|
+
if (hit)
|
|
12686
|
+
return hit;
|
|
12687
|
+
// The PROMISE goes in the cache, not the resolved value.
|
|
12688
|
+
//
|
|
12689
|
+
// Reading a project means resolving hundreds of paths that share the
|
|
12690
|
+
// same handful of directories. Caching only the settled value leaves
|
|
12691
|
+
// every concurrent reader to miss and fire its own request for the same
|
|
12692
|
+
// record — which is how loading one project turned into thousands of
|
|
12693
|
+
// identical `vfs/d/<id>` fetches, most of them in flight at once.
|
|
12694
|
+
const pending = (async () => {
|
|
12695
|
+
const raw = await this.deps.store.get(dirKey(id));
|
|
12696
|
+
// An absent record is an EMPTY directory, not an error: the root
|
|
12697
|
+
// exists before anything has been written to it, and a directory is
|
|
12698
|
+
// created by its parent naming it rather than by its own record
|
|
12699
|
+
// appearing.
|
|
12700
|
+
const node = (await unseal(key, raw)) ?? { v: 1, entries: {}, mtime: Date.now() };
|
|
12701
|
+
return { id, key, node };
|
|
12702
|
+
})().catch((err) => {
|
|
12703
|
+
// A failed read must not be remembered, or one blip poisons the
|
|
12704
|
+
// directory for the life of the session.
|
|
12705
|
+
if (this.cache.get(id) === pending)
|
|
12706
|
+
this.cache.delete(id);
|
|
12707
|
+
throw err;
|
|
12708
|
+
});
|
|
12709
|
+
this.cache.set(id, pending);
|
|
12710
|
+
return pending;
|
|
12711
|
+
}
|
|
12712
|
+
/** Apply an edit to a directory record and persist it. */
|
|
12713
|
+
async mutate(dir, edit) {
|
|
12714
|
+
edit(dir.node);
|
|
12715
|
+
dir.node.mtime = Date.now();
|
|
12716
|
+
await this.deps.store.put(dirKey(dir.id), await seal(dir.key, dir.node));
|
|
12717
|
+
this.cache.set(dir.id, Promise.resolve(dir));
|
|
12718
|
+
}
|
|
12719
|
+
async createDir(parentPath, name) {
|
|
12720
|
+
assertValidName(name);
|
|
12721
|
+
const parent = await this.openDir(parentPath);
|
|
12722
|
+
const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
12723
|
+
await this.mutate(parent, (node) => {
|
|
12724
|
+
node.entries[name] = entry;
|
|
12725
|
+
});
|
|
12726
|
+
}
|
|
12727
|
+
/** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
|
|
12728
|
+
async purge(dir) {
|
|
12729
|
+
for (const entry of Object.values(dir.node.entries)) {
|
|
12730
|
+
if (entry.kind === "dir") {
|
|
12731
|
+
await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
|
|
12732
|
+
}
|
|
12733
|
+
else {
|
|
12734
|
+
await this.releaseFile(dir, entry);
|
|
12735
|
+
}
|
|
12736
|
+
}
|
|
12737
|
+
this.cache.delete(dir.id);
|
|
12738
|
+
await this.deps.store.delete(dirKey(dir.id)).catch(() => { });
|
|
12739
|
+
}
|
|
12740
|
+
/**
|
|
12741
|
+
* Deep-copy an entry. Files reuse their manifest (no bytes move); each
|
|
12742
|
+
* directory gets a fresh id and key, so the copy is independent of the
|
|
12743
|
+
* original — writing into one must never appear in the other.
|
|
12744
|
+
*/
|
|
12745
|
+
async cloneEntry(entry) {
|
|
12746
|
+
if (entry.kind === "file") {
|
|
12747
|
+
// Same bytes, second owner: take a reference so deleting either one
|
|
12748
|
+
// leaves the other readable.
|
|
12749
|
+
await this.deps.content.retain(entry.manifest);
|
|
12750
|
+
return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
|
|
12751
|
+
}
|
|
12752
|
+
const source = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12753
|
+
const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
12754
|
+
const node = { v: 1, entries: {}, mtime: Date.now() };
|
|
12755
|
+
for (const [name, child] of Object.entries(source.node.entries)) {
|
|
12756
|
+
node.entries[name] = await this.cloneEntry(child);
|
|
12757
|
+
}
|
|
12758
|
+
const open = { id: clone.id, key: fromBase64(clone.key), node };
|
|
12759
|
+
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
12760
|
+
this.cache.set(open.id, Promise.resolve(open));
|
|
12761
|
+
return clone;
|
|
12762
|
+
}
|
|
12763
|
+
/**
|
|
12764
|
+
* Let go of a file's bytes: the current manifest and every retained
|
|
12765
|
+
* version, then the history record itself.
|
|
12766
|
+
*/
|
|
12767
|
+
async releaseFile(parent, entry) {
|
|
12768
|
+
const record = await this.readHistory(parent, entry.id).catch(() => ({ v: 1, versions: [] }));
|
|
12769
|
+
const manifests = [entry.manifest, ...record.versions.map((v) => v.manifest)];
|
|
12770
|
+
for (const manifest of manifests)
|
|
12771
|
+
await this.deps.content.release(manifest).catch(() => { });
|
|
12772
|
+
await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
|
|
12773
|
+
}
|
|
12774
|
+
async readHistory(parent, fileId) {
|
|
12775
|
+
const raw = await this.deps.store.get(historyKey(fileId));
|
|
12776
|
+
return (await unseal(parent.key, raw)) ?? { v: 1, versions: [] };
|
|
12777
|
+
}
|
|
12778
|
+
/** Push `entry` onto its history record. Returns the new version count. */
|
|
12779
|
+
async pushHistory(parent, fileId, entry) {
|
|
12780
|
+
const record = await this.readHistory(parent, fileId);
|
|
12781
|
+
record.versions.unshift({ manifest: entry.manifest, size: entry.size, mtime: entry.mtime });
|
|
12782
|
+
// Bounded, because this is a single stored value. Unchanged chunks are
|
|
12783
|
+
// deduped by the content-addressed shard store, so the cost of keeping
|
|
12784
|
+
// versions is this record's own growth — which is what the cap bounds.
|
|
12785
|
+
const evicted = record.versions.slice(this.historyLimit);
|
|
12786
|
+
record.versions = record.versions.slice(0, this.historyLimit);
|
|
12787
|
+
// A version that falls off the end is unreachable — release its shards
|
|
12788
|
+
// or the cap bounds the record's size while the storage grows forever.
|
|
12789
|
+
for (const v of evicted)
|
|
12790
|
+
await this.deps.content.release(v.manifest).catch(() => { });
|
|
12791
|
+
await this.deps.store.put(historyKey(fileId), await seal(parent.key, record));
|
|
12792
|
+
return record.versions.length;
|
|
12793
|
+
}
|
|
12794
|
+
}
|
|
12795
|
+
function toStat(path, name, entry) {
|
|
12796
|
+
return {
|
|
12797
|
+
path,
|
|
12798
|
+
name,
|
|
12799
|
+
kind: entry.kind,
|
|
12800
|
+
size: entry.kind === "file" ? entry.size : 0,
|
|
12801
|
+
mtime: entry.mtime,
|
|
12802
|
+
versions: entry.kind === "file" ? (entry.versions ?? 0) : 0,
|
|
12803
|
+
};
|
|
12804
|
+
}
|
|
12805
|
+
/** Directories first, then alphabetical — what every file explorer does. */
|
|
12806
|
+
function byDirsThenName(a, b) {
|
|
12807
|
+
if (a.kind !== b.kind)
|
|
12808
|
+
return a.kind === "dir" ? -1 : 1;
|
|
12809
|
+
return a.name.localeCompare(b.name);
|
|
12810
|
+
}
|
|
12811
|
+
|
|
12812
|
+
/**
|
|
12813
|
+
* Content addressing for repository objects.
|
|
12814
|
+
*
|
|
12815
|
+
* The hash must depend only on MEANING, never on serialisation accidents — two
|
|
12816
|
+
* clients that build the same tree have to agree on its hash, or history forks
|
|
12817
|
+
* for no reason. So keys are sorted and the encoding is fixed here rather than
|
|
12818
|
+
* left to `JSON.stringify`'s insertion order.
|
|
12819
|
+
*/
|
|
12820
|
+
/** Canonical JSON: object keys sorted, recursively. */
|
|
12821
|
+
function canonical(value) {
|
|
12822
|
+
if (value === null || typeof value !== "object")
|
|
12823
|
+
return JSON.stringify(value) ?? "null";
|
|
12824
|
+
if (Array.isArray(value))
|
|
12825
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
12826
|
+
const entries = Object.entries(value)
|
|
12827
|
+
.filter(([, v]) => v !== undefined)
|
|
12828
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
12829
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
12830
|
+
}
|
|
12831
|
+
/** SHA-256 of an object's canonical form, hex. */
|
|
12832
|
+
async function hashObject(value) {
|
|
12833
|
+
const bytes = new TextEncoder().encode(canonical(value));
|
|
12834
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
12835
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
12836
|
+
}
|
|
12837
|
+
|
|
12838
|
+
/**
|
|
12839
|
+
* Three-way line merge.
|
|
12840
|
+
*
|
|
12841
|
+
* Given a common ancestor and two descendants, produce the combined text — or
|
|
12842
|
+
* report the regions where both sides changed the same lines differently.
|
|
12843
|
+
*
|
|
12844
|
+
* The server cannot do any of this: it holds ciphertext and cannot compare
|
|
12845
|
+
* versions, so merging is entirely a client concern and has to be exact here.
|
|
12846
|
+
*/
|
|
12847
|
+
/**
|
|
12848
|
+
* Longest common subsequence, as a list of matched index pairs.
|
|
12849
|
+
*
|
|
12850
|
+
* Classic dynamic programming. Fine for source files; a file with hundreds of
|
|
12851
|
+
* thousands of lines would want a smarter algorithm, and would also be a strange
|
|
12852
|
+
* thing to merge.
|
|
12853
|
+
*/
|
|
12854
|
+
function lcs(a, b) {
|
|
12855
|
+
const n = a.length;
|
|
12856
|
+
const m = b.length;
|
|
12857
|
+
const table = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
12858
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
12859
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
12860
|
+
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
12861
|
+
}
|
|
12862
|
+
}
|
|
12863
|
+
const pairs = [];
|
|
12864
|
+
let i = 0;
|
|
12865
|
+
let j = 0;
|
|
12866
|
+
while (i < n && j < m) {
|
|
12867
|
+
if (a[i] === b[j]) {
|
|
12868
|
+
pairs.push([i, j]);
|
|
12869
|
+
i++;
|
|
12870
|
+
j++;
|
|
12871
|
+
}
|
|
12872
|
+
else if (table[i + 1][j] >= table[i][j + 1])
|
|
12873
|
+
i++;
|
|
12874
|
+
else
|
|
12875
|
+
j++;
|
|
12876
|
+
}
|
|
12877
|
+
return pairs;
|
|
12878
|
+
}
|
|
12879
|
+
/**
|
|
12880
|
+
* Lines of `base` that BOTH sides left untouched.
|
|
12881
|
+
*
|
|
12882
|
+
* These are the anchors the merge is built around: between two consecutive
|
|
12883
|
+
* anchors, each side has some (possibly empty) replacement, and comparing those
|
|
12884
|
+
* replacements is the entire decision.
|
|
12885
|
+
*/
|
|
12886
|
+
function stableAnchors(base, ours, theirs) {
|
|
12887
|
+
const ourMatch = new Map(lcs(base, ours));
|
|
12888
|
+
const theirMatch = new Map(lcs(base, theirs));
|
|
12889
|
+
const anchors = [];
|
|
12890
|
+
for (let b = 0; b < base.length; b++) {
|
|
12891
|
+
const o = ourMatch.get(b);
|
|
12892
|
+
const t = theirMatch.get(b);
|
|
12893
|
+
if (o !== undefined && t !== undefined)
|
|
12894
|
+
anchors.push([b, o, t]);
|
|
12895
|
+
}
|
|
12896
|
+
return anchors;
|
|
12897
|
+
}
|
|
12898
|
+
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
12899
|
+
/**
|
|
12900
|
+
* Merge `ours` and `theirs` over their common ancestor `base`.
|
|
12901
|
+
*
|
|
12902
|
+
* A region where only one side changed takes that side. A region where both
|
|
12903
|
+
* made the SAME change takes it once — two people fixing the same typo is not a
|
|
12904
|
+
* conflict. Only genuinely divergent edits conflict.
|
|
12905
|
+
*/
|
|
12906
|
+
function merge3(base, ours, theirs) {
|
|
12907
|
+
const anchors = stableAnchors(base, ours, theirs);
|
|
12908
|
+
const out = [];
|
|
12909
|
+
const conflicts = [];
|
|
12910
|
+
let b = 0;
|
|
12911
|
+
let o = 0;
|
|
12912
|
+
let t = 0;
|
|
12913
|
+
const settle = (ourSlice, theirSlice, baseSlice) => {
|
|
12914
|
+
if (same(ourSlice, theirSlice)) {
|
|
12915
|
+
out.push(...ourSlice); // identical edits, or no edit
|
|
12916
|
+
}
|
|
12917
|
+
else if (same(ourSlice, baseSlice)) {
|
|
12918
|
+
out.push(...theirSlice); // only they changed it
|
|
12919
|
+
}
|
|
12920
|
+
else if (same(theirSlice, baseSlice)) {
|
|
12921
|
+
out.push(...ourSlice); // only we changed it
|
|
12922
|
+
}
|
|
12923
|
+
else {
|
|
12924
|
+
conflicts.push({ line: out.length + 1, ours: ourSlice, theirs: theirSlice });
|
|
12925
|
+
out.push(...ourSlice); // markers are added by the caller
|
|
12926
|
+
}
|
|
12927
|
+
};
|
|
12928
|
+
for (const [ab, ao, at] of anchors) {
|
|
12929
|
+
settle(ours.slice(o, ao), theirs.slice(t, at), base.slice(b, ab));
|
|
12930
|
+
out.push(base[ab]);
|
|
12931
|
+
b = ab + 1;
|
|
12932
|
+
o = ao + 1;
|
|
12933
|
+
t = at + 1;
|
|
12934
|
+
}
|
|
12935
|
+
settle(ours.slice(o), theirs.slice(t), base.slice(b));
|
|
12936
|
+
return { lines: out, conflicts };
|
|
12937
|
+
}
|
|
12938
|
+
/**
|
|
12939
|
+
* Merge to text, writing conflict markers where the sides diverged.
|
|
12940
|
+
*
|
|
12941
|
+
* Markers rather than a bespoke format: they are what every developer already
|
|
12942
|
+
* knows how to resolve, and resolving becomes ordinary editing.
|
|
12943
|
+
*/
|
|
12944
|
+
function merge3Text(base, ours, theirs, labels = { ours: "ours", theirs: "theirs" }) {
|
|
12945
|
+
const split = (s) => (s === "" ? [] : s.split("\n"));
|
|
12946
|
+
const anchors = stableAnchors(split(base), split(ours), split(theirs));
|
|
12947
|
+
const B = split(base);
|
|
12948
|
+
const O = split(ours);
|
|
12949
|
+
const T = split(theirs);
|
|
12950
|
+
const out = [];
|
|
12951
|
+
let conflicted = false;
|
|
12952
|
+
let b = 0;
|
|
12953
|
+
let o = 0;
|
|
12954
|
+
let t = 0;
|
|
12955
|
+
const settle = (ourSlice, theirSlice, baseSlice) => {
|
|
12956
|
+
if (same(ourSlice, theirSlice))
|
|
12957
|
+
out.push(...ourSlice);
|
|
12958
|
+
else if (same(ourSlice, baseSlice))
|
|
12959
|
+
out.push(...theirSlice);
|
|
12960
|
+
else if (same(theirSlice, baseSlice))
|
|
12961
|
+
out.push(...ourSlice);
|
|
12962
|
+
else {
|
|
12963
|
+
conflicted = true;
|
|
12964
|
+
out.push(`<<<<<<< ${labels.ours}`, ...ourSlice, "=======", ...theirSlice, `>>>>>>> ${labels.theirs}`);
|
|
12965
|
+
}
|
|
12966
|
+
};
|
|
12967
|
+
for (const [ab, ao, at] of anchors) {
|
|
12968
|
+
settle(O.slice(o, ao), T.slice(t, at), B.slice(b, ab));
|
|
12969
|
+
out.push(B[ab]);
|
|
12970
|
+
b = ab + 1;
|
|
12971
|
+
o = ao + 1;
|
|
12972
|
+
t = at + 1;
|
|
12973
|
+
}
|
|
12974
|
+
settle(O.slice(o), T.slice(t), B.slice(b));
|
|
12975
|
+
return { text: out.join("\n"), conflicted };
|
|
12976
|
+
}
|
|
12977
|
+
|
|
12978
|
+
/** Storage keys. Namespaced per project so repositories cannot collide. */
|
|
12979
|
+
const objectKey = (slug, hash) => `vcs/${slug}/obj/${hash}`;
|
|
12980
|
+
const refKey = (slug, name) => `vcs/${slug}/refs/${name}`;
|
|
12981
|
+
const headKey = (slug) => `vcs/${slug}/HEAD`;
|
|
12982
|
+
const refsIndexKey = (slug) => `vcs/${slug}/refs`;
|
|
12983
|
+
/** An in-progress merge: what to make the second parent when it is committed. */
|
|
12984
|
+
const mergeKey = (slug) => `vcs/${slug}/MERGE`;
|
|
12985
|
+
const DEFAULT_BRANCH = "main";
|
|
12986
|
+
class VcsError extends Error {
|
|
12987
|
+
constructor(message) {
|
|
12988
|
+
super(message);
|
|
12989
|
+
this.name = "VcsError";
|
|
12990
|
+
}
|
|
12991
|
+
}
|
|
12992
|
+
|
|
12993
|
+
/** A project's repository. One per `/apps/<slug>`. */
|
|
12994
|
+
/**
|
|
12995
|
+
* Types never worth attempting a three-way merge on.
|
|
12996
|
+
*
|
|
12997
|
+
* NOT application/octet-stream: that is the UNKNOWN case, which is what every
|
|
12998
|
+
* extensionless text file gets labelled, so it must reach the content sniff
|
|
12999
|
+
* rather than being rejected on the label.
|
|
13000
|
+
*/
|
|
13001
|
+
const BINARY_TYPE = /^(image|audio|video|font)\/|^application\/(wasm|zip|pdf|x-tar|gzip)/;
|
|
13002
|
+
/**
|
|
13003
|
+
* Does this look like text a person could resolve conflict markers in?
|
|
13004
|
+
*
|
|
13005
|
+
* A NUL byte is the classic tell and the one git uses — no textual encoding we
|
|
13006
|
+
* care about produces one. Invalid UTF-8 is the other: decoding it would
|
|
13007
|
+
* corrupt the file on write-back even if the merge itself succeeded.
|
|
13008
|
+
*/
|
|
13009
|
+
function looksTextual(bytes) {
|
|
13010
|
+
if (bytes.includes(0))
|
|
13011
|
+
return false;
|
|
13012
|
+
try {
|
|
13013
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
13014
|
+
return true;
|
|
13015
|
+
}
|
|
13016
|
+
catch {
|
|
13017
|
+
return false;
|
|
13018
|
+
}
|
|
13019
|
+
}
|
|
13020
|
+
/** Compare two path → entry maps into a change list. */
|
|
13021
|
+
function comparePaths(before, after) {
|
|
13022
|
+
const changes = [];
|
|
13023
|
+
for (const [path, entry] of after) {
|
|
13024
|
+
const was = before.get(path);
|
|
13025
|
+
if (!was)
|
|
13026
|
+
changes.push({ path, kind: "added" });
|
|
13027
|
+
else if (was.manifest.id !== entry.manifest.id)
|
|
13028
|
+
changes.push({ path, kind: "modified" });
|
|
13029
|
+
}
|
|
13030
|
+
for (const path of before.keys())
|
|
13031
|
+
if (!after.has(path))
|
|
13032
|
+
changes.push({ path, kind: "removed" });
|
|
13033
|
+
return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
13034
|
+
}
|
|
13035
|
+
class Repo {
|
|
13036
|
+
slug;
|
|
13037
|
+
deps;
|
|
13038
|
+
constructor(slug, deps) {
|
|
13039
|
+
this.slug = slug;
|
|
13040
|
+
this.deps = deps;
|
|
13041
|
+
}
|
|
13042
|
+
get root() {
|
|
13043
|
+
return `/apps/${this.slug}`;
|
|
13044
|
+
}
|
|
13045
|
+
// ---- objects ------------------------------------------------------------
|
|
13046
|
+
/** Store an object, returning its hash and whether it was new. */
|
|
13047
|
+
async put(value) {
|
|
13048
|
+
const hash = await hashObject(value);
|
|
13049
|
+
// Content-addressed, so re-writing an identical object is a no-op —
|
|
13050
|
+
// worth skipping because an unchanged directory is written on every
|
|
13051
|
+
// commit that touches any of its siblings.
|
|
13052
|
+
const existing = await this.deps.store.get(objectKey(this.slug, hash));
|
|
13053
|
+
if (existing)
|
|
13054
|
+
return { hash, created: false };
|
|
13055
|
+
await this.deps.store.put(objectKey(this.slug, hash), await seal(await this.deps.key(), value));
|
|
13056
|
+
return { hash, created: true };
|
|
13057
|
+
}
|
|
13058
|
+
async get(hash) {
|
|
13059
|
+
const raw = await this.deps.store.get(objectKey(this.slug, hash));
|
|
13060
|
+
const value = raw ? await unseal(await this.deps.key(), raw) : null;
|
|
13061
|
+
if (!value)
|
|
13062
|
+
throw new VcsError(`object ${hash.slice(0, 8)} is missing from this repository`);
|
|
13063
|
+
return value;
|
|
13064
|
+
}
|
|
13065
|
+
// ---- refs ---------------------------------------------------------------
|
|
13066
|
+
async head() {
|
|
13067
|
+
const raw = await this.deps.store.get(headKey(this.slug));
|
|
13068
|
+
const value = raw ? await unseal(await this.deps.key(), raw) : null;
|
|
13069
|
+
return value ?? { branch: DEFAULT_BRANCH };
|
|
13070
|
+
}
|
|
13071
|
+
async setHead(head) {
|
|
13072
|
+
await this.deps.store.put(headKey(this.slug), await seal(await this.deps.key(), head));
|
|
13073
|
+
}
|
|
13074
|
+
async readRef(name) {
|
|
13075
|
+
const raw = await this.deps.store.get(refKey(this.slug, name));
|
|
13076
|
+
return raw ? await unseal(await this.deps.key(), raw).then((v) => v?.commit ?? null) : null;
|
|
13077
|
+
}
|
|
13078
|
+
async writeRef(name, commit) {
|
|
13079
|
+
await this.deps.store.put(refKey(this.slug, name), await seal(await this.deps.key(), { commit }));
|
|
13080
|
+
// The personal space has no prefix listing, so branches are tracked in
|
|
13081
|
+
// an index. Kept append-only-ish: losing a name here loses the branch.
|
|
13082
|
+
const names = new Set(await this.branches());
|
|
13083
|
+
names.add(name);
|
|
13084
|
+
await this.deps.store.put(refsIndexKey(this.slug), await seal(await this.deps.key(), { names: [...names].sort() }));
|
|
13085
|
+
}
|
|
13086
|
+
/** Every branch name in this repository. */
|
|
13087
|
+
async branches() {
|
|
13088
|
+
const raw = await this.deps.store.get(refsIndexKey(this.slug));
|
|
13089
|
+
const value = raw ? await unseal(await this.deps.key(), raw) : null;
|
|
13090
|
+
return value?.names ?? [];
|
|
13091
|
+
}
|
|
13092
|
+
/** The commit HEAD resolves to, or null in an empty repository. */
|
|
13093
|
+
async current() {
|
|
13094
|
+
const head = await this.head();
|
|
13095
|
+
return "detached" in head ? head.detached : this.readRef(head.branch);
|
|
13096
|
+
}
|
|
13097
|
+
// ---- commit -------------------------------------------------------------
|
|
13098
|
+
/**
|
|
13099
|
+
* Freeze the working tree as a commit.
|
|
13100
|
+
*
|
|
13101
|
+
* Trees are written bottom-up so a directory's hash is known before its
|
|
13102
|
+
* parent references it. Nothing is written to the shard store: file entries
|
|
13103
|
+
* carry the manifest the VFS already holds.
|
|
13104
|
+
*/
|
|
13105
|
+
async commit(message, parents) {
|
|
13106
|
+
if (!message.trim())
|
|
13107
|
+
throw new VcsError("a commit needs a message");
|
|
13108
|
+
const tree = await this.writeTree(this.root);
|
|
13109
|
+
const parent = await this.current();
|
|
13110
|
+
// Finish an interrupted merge: the recorded other side becomes the
|
|
13111
|
+
// second parent. Losing this would produce a single-parent commit, and
|
|
13112
|
+
// the merge would look as though it never happened — the branches would
|
|
13113
|
+
// still appear diverged.
|
|
13114
|
+
const pending = parents ? null : await this.pendingMerge();
|
|
13115
|
+
const resolved = parents ?? (pending ? [parent, pending.theirs] : parent ? [parent] : []);
|
|
13116
|
+
if (!parents && !pending && parent) {
|
|
13117
|
+
const previous = await this.get(parent);
|
|
13118
|
+
// An empty commit is almost always a mistake — the user believes
|
|
13119
|
+
// they saved something they did not.
|
|
13120
|
+
if (previous.tree === tree)
|
|
13121
|
+
throw new VcsError("nothing to commit — the project is unchanged");
|
|
13122
|
+
}
|
|
13123
|
+
const commit = {
|
|
13124
|
+
v: 1,
|
|
13125
|
+
tree,
|
|
13126
|
+
parents: resolved,
|
|
13127
|
+
message: message.trim(),
|
|
13128
|
+
author: this.deps.author(),
|
|
13129
|
+
at: Date.now(),
|
|
13130
|
+
};
|
|
13131
|
+
const { hash } = await this.put(commit);
|
|
13132
|
+
await this.advance(hash);
|
|
13133
|
+
if (pending)
|
|
13134
|
+
await this.deps.store.delete(mergeKey(this.slug)).catch(() => { });
|
|
13135
|
+
return hash;
|
|
13136
|
+
}
|
|
13137
|
+
/** Move whatever HEAD points at onto a new commit. */
|
|
13138
|
+
async advance(hash) {
|
|
13139
|
+
const head = await this.head();
|
|
13140
|
+
if ("detached" in head)
|
|
13141
|
+
await this.setHead({ detached: hash });
|
|
13142
|
+
else
|
|
13143
|
+
await this.writeRef(head.branch, hash);
|
|
13144
|
+
}
|
|
13145
|
+
/** The other side of a merge that stopped for conflicts, if any. */
|
|
13146
|
+
async pendingMerge() {
|
|
13147
|
+
const raw = await this.deps.store.get(mergeKey(this.slug));
|
|
13148
|
+
return raw ? await unseal(await this.deps.key(), raw) : null;
|
|
13149
|
+
}
|
|
13150
|
+
/** Write a directory and everything under it, returning the tree's hash. */
|
|
13151
|
+
async writeTree(path) {
|
|
13152
|
+
const entries = {};
|
|
13153
|
+
for (const entry of await this.deps.vfs.list(path)) {
|
|
13154
|
+
if (entry.kind === "dir") {
|
|
13155
|
+
entries[entry.name] = { kind: "tree", hash: await this.writeTree(entry.path) };
|
|
13156
|
+
}
|
|
13157
|
+
else {
|
|
13158
|
+
const stat = await this.deps.vfs.statManifest(entry.path);
|
|
13159
|
+
entries[entry.name] = { kind: "file", manifest: stat.manifest, size: entry.size };
|
|
13160
|
+
}
|
|
13161
|
+
}
|
|
13162
|
+
const { hash, created } = await this.put({ v: 1, entries });
|
|
13163
|
+
// A NEW tree means this directory's contents are newly recorded, so the
|
|
13164
|
+
// repository takes its own reference to their content. Without it, a
|
|
13165
|
+
// later working-tree delete frees shards the commit still needs and the
|
|
13166
|
+
// history becomes unreadable.
|
|
13167
|
+
//
|
|
13168
|
+
// Retaining per new tree rather than per changed FILE slightly
|
|
13169
|
+
// over-retains — an unchanged file in a directory whose sibling changed
|
|
13170
|
+
// gets a second reference. That errs towards keeping content, which is
|
|
13171
|
+
// the correct direction to err in.
|
|
13172
|
+
if (created) {
|
|
13173
|
+
for (const entry of Object.values(entries)) {
|
|
13174
|
+
if (entry.kind === "file")
|
|
13175
|
+
await this.deps.vfs.retainManifest(entry.manifest);
|
|
13176
|
+
}
|
|
13177
|
+
}
|
|
13178
|
+
return hash;
|
|
13179
|
+
}
|
|
13180
|
+
// ---- history ------------------------------------------------------------
|
|
13181
|
+
/** Commits reachable from HEAD, newest first. */
|
|
13182
|
+
async log(limit = 50) {
|
|
13183
|
+
const start = await this.current();
|
|
13184
|
+
if (!start)
|
|
13185
|
+
return [];
|
|
13186
|
+
const out = [];
|
|
13187
|
+
const seen = new Set();
|
|
13188
|
+
// Breadth-first over parents so a merge's two sides both appear, and
|
|
13189
|
+
// `seen` keeps a diamond from listing the shared history twice.
|
|
13190
|
+
const queue = [start];
|
|
13191
|
+
while (queue.length && out.length < limit) {
|
|
13192
|
+
const hash = queue.shift();
|
|
13193
|
+
if (seen.has(hash))
|
|
13194
|
+
continue;
|
|
13195
|
+
seen.add(hash);
|
|
13196
|
+
const commit = await this.get(hash);
|
|
13197
|
+
out.push({
|
|
13198
|
+
hash,
|
|
13199
|
+
message: commit.message,
|
|
13200
|
+
author: commit.author,
|
|
13201
|
+
at: commit.at,
|
|
13202
|
+
parents: commit.parents,
|
|
13203
|
+
});
|
|
13204
|
+
queue.push(...commit.parents);
|
|
13205
|
+
}
|
|
13206
|
+
return out.sort((a, b) => b.at - a.at);
|
|
13207
|
+
}
|
|
13208
|
+
// ---- diff ---------------------------------------------------------------
|
|
13209
|
+
/**
|
|
13210
|
+
* What changed between two commits.
|
|
13211
|
+
*
|
|
13212
|
+
* Identical subtree hashes prune the walk: an untouched directory is one
|
|
13213
|
+
* comparison regardless of how much is inside it. That is the payoff for
|
|
13214
|
+
* content-addressing the trees.
|
|
13215
|
+
*/
|
|
13216
|
+
async diff(fromHash, toHash) {
|
|
13217
|
+
const from = fromHash ? (await this.get(fromHash)).tree : null;
|
|
13218
|
+
const to = (await this.get(toHash)).tree;
|
|
13219
|
+
const changes = [];
|
|
13220
|
+
await this.diffTrees(from, to, "", changes);
|
|
13221
|
+
return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
13222
|
+
}
|
|
13223
|
+
async diffTrees(fromHash, toHash, prefix, out) {
|
|
13224
|
+
if (fromHash === toHash)
|
|
13225
|
+
return; // identical subtrees: nothing below can differ
|
|
13226
|
+
const from = fromHash ? await this.get(fromHash) : { entries: {} };
|
|
13227
|
+
const to = toHash ? await this.get(toHash) : { entries: {} };
|
|
13228
|
+
for (const name of new Set([...Object.keys(from.entries), ...Object.keys(to.entries)])) {
|
|
13229
|
+
const a = from.entries[name];
|
|
13230
|
+
const b = to.entries[name];
|
|
13231
|
+
const path = `${prefix}/${name}`;
|
|
13232
|
+
if (a && b && a.kind === "tree" && b.kind === "tree") {
|
|
13233
|
+
await this.diffTrees(a.hash, b.hash, path, out);
|
|
13234
|
+
}
|
|
13235
|
+
else if (a?.kind === "tree" || b?.kind === "tree") {
|
|
13236
|
+
// A directory replaced by a file (or the reverse) — recurse both
|
|
13237
|
+
// sides so every affected path is reported, not just the name.
|
|
13238
|
+
await this.diffTrees(a?.kind === "tree" ? a.hash : null, b?.kind === "tree" ? b.hash : null, path, out);
|
|
13239
|
+
if (a?.kind === "file")
|
|
13240
|
+
out.push({ path, kind: "removed" });
|
|
13241
|
+
if (b?.kind === "file")
|
|
13242
|
+
out.push({ path, kind: "added" });
|
|
13243
|
+
}
|
|
13244
|
+
else if (!a && b) {
|
|
13245
|
+
out.push({ path, kind: "added" });
|
|
13246
|
+
}
|
|
13247
|
+
else if (a && !b) {
|
|
13248
|
+
out.push({ path, kind: "removed" });
|
|
13249
|
+
}
|
|
13250
|
+
else if (a?.kind === "file" && b?.kind === "file" && a.manifest.id !== b.manifest.id) {
|
|
13251
|
+
out.push({ path, kind: "modified" });
|
|
13252
|
+
}
|
|
13253
|
+
}
|
|
13254
|
+
}
|
|
13255
|
+
// ---- checkout -----------------------------------------------------------
|
|
13256
|
+
/**
|
|
13257
|
+
* Replace the working tree with a commit's.
|
|
13258
|
+
*
|
|
13259
|
+
* Moves no file content: entries carry manifests the shard store already
|
|
13260
|
+
* holds, so this rewrites directory records only.
|
|
13261
|
+
*/
|
|
13262
|
+
/**
|
|
13263
|
+
* What has changed since the last commit.
|
|
13264
|
+
*
|
|
13265
|
+
* Compares the WORKING TREE against HEAD without writing any objects: the
|
|
13266
|
+
* common case is answering "anything to commit?", and a `status` that
|
|
13267
|
+
* quietly stored a tree object every time would litter the repository with
|
|
13268
|
+
* trees for states nobody chose to keep.
|
|
13269
|
+
*/
|
|
13270
|
+
async status() {
|
|
13271
|
+
const head = await this.current();
|
|
13272
|
+
const before = head ? await this.filesOf(head) : new Map();
|
|
13273
|
+
const after = await this.workingFiles();
|
|
13274
|
+
return comparePaths(before, after);
|
|
13275
|
+
}
|
|
13276
|
+
/** The working tree as a path → entry map, mirroring `filesOf`. */
|
|
13277
|
+
async workingFiles() {
|
|
13278
|
+
const out = new Map();
|
|
13279
|
+
for (const abs of await this.deps.vfs.walk(this.root)) {
|
|
13280
|
+
const stat = await this.deps.vfs.statManifest(abs);
|
|
13281
|
+
out.set(abs.slice(this.root.length), { kind: "file", manifest: stat.manifest, size: stat.size });
|
|
13282
|
+
}
|
|
13283
|
+
return out;
|
|
13284
|
+
}
|
|
13285
|
+
/**
|
|
13286
|
+
* Turn what a person typed into a commit hash.
|
|
13287
|
+
*
|
|
13288
|
+
* Accepts a branch name, `HEAD`, a full or abbreviated hash, and trailing
|
|
13289
|
+
* `^` / `~n` to walk back through parents — so `show abc123^` means "the
|
|
13290
|
+
* commit before that one" the way anyone who has used git expects.
|
|
13291
|
+
*/
|
|
13292
|
+
async resolve(rev) {
|
|
13293
|
+
const m = /^(.*?)((?:\^|~\d+)*)$/.exec(rev.trim());
|
|
13294
|
+
if (!m)
|
|
13295
|
+
throw new VcsError(`cannot understand "${rev}"`);
|
|
13296
|
+
const [, base, steps] = m;
|
|
13297
|
+
let hash = null;
|
|
13298
|
+
if (!base || base === "HEAD")
|
|
13299
|
+
hash = await this.current();
|
|
13300
|
+
else if (await this.readRef(base))
|
|
13301
|
+
hash = await this.readRef(base);
|
|
13302
|
+
else
|
|
13303
|
+
hash = await this.expand(base);
|
|
13304
|
+
if (!hash)
|
|
13305
|
+
throw new VcsError(`no commit matches "${rev}"`);
|
|
13306
|
+
for (const step of steps.match(/\^|~\d+/g) ?? []) {
|
|
13307
|
+
const back = step === "^" ? 1 : Number(step.slice(1));
|
|
13308
|
+
for (let i = 0; i < back; i++) {
|
|
13309
|
+
const parents = (await this.get(hash)).parents;
|
|
13310
|
+
if (!parents.length)
|
|
13311
|
+
throw new VcsError(`"${rev}" reaches past the first commit`);
|
|
13312
|
+
hash = parents[0]; // first parent: the branch you were on
|
|
13313
|
+
}
|
|
13314
|
+
}
|
|
13315
|
+
return hash;
|
|
13316
|
+
}
|
|
13317
|
+
/** A hash prefix a person typed → the one commit it names. */
|
|
13318
|
+
async expand(prefix) {
|
|
13319
|
+
if (!/^[0-9a-f]{4,}$/.test(prefix))
|
|
13320
|
+
return null;
|
|
13321
|
+
const keys = await this.deps.store.list();
|
|
13322
|
+
const wanted = objectKey(this.slug, prefix);
|
|
13323
|
+
const hits = keys.filter((k) => k.startsWith(wanted));
|
|
13324
|
+
// Ambiguous is an error, not a coin toss — acting on the wrong commit
|
|
13325
|
+
// is exactly the kind of mistake version control exists to prevent.
|
|
13326
|
+
if (hits.length > 1)
|
|
13327
|
+
throw new VcsError(`"${prefix}" matches ${hits.length} objects — use more characters`);
|
|
13328
|
+
if (!hits.length)
|
|
13329
|
+
return null;
|
|
13330
|
+
const hash = hits[0].slice(objectKey(this.slug, "").length);
|
|
13331
|
+
// Objects include trees; only a commit is a valid revision.
|
|
13332
|
+
const object = await this.get(hash);
|
|
13333
|
+
return object && "tree" in object ? hash : null;
|
|
13334
|
+
}
|
|
13335
|
+
/**
|
|
13336
|
+
* Put one file back to the way it was in a commit.
|
|
13337
|
+
*
|
|
13338
|
+
* Scoped to a path on purpose — recovering one file you broke should not
|
|
13339
|
+
* mean rewinding everything else you have done since.
|
|
13340
|
+
*/
|
|
13341
|
+
async restore(path, rev = "HEAD") {
|
|
13342
|
+
const hash = await this.resolve(rev);
|
|
13343
|
+
const files = await this.filesOf(hash);
|
|
13344
|
+
const key = path.startsWith(this.root) ? path.slice(this.root.length) : path.startsWith("/") ? path : `/${path}`;
|
|
13345
|
+
const entry = files.get(key);
|
|
13346
|
+
if (!entry)
|
|
13347
|
+
throw new VcsError(`${key} is not in ${rev}`);
|
|
13348
|
+
await this.deps.vfs.writeManifest(this.abs(key), entry.manifest, entry.size);
|
|
13349
|
+
}
|
|
13350
|
+
async checkout(commitHash, opts = {}) {
|
|
13351
|
+
await this.guardWorkingTree(opts.discardChanges);
|
|
13352
|
+
await this.materialise(commitHash);
|
|
13353
|
+
// DETACHED on purpose: committing from a historical state must not
|
|
13354
|
+
// silently rewrite whichever branch you happened to be on. `switchTo`
|
|
13355
|
+
// is how you re-attach.
|
|
13356
|
+
await this.setHead({ detached: commitHash });
|
|
13357
|
+
}
|
|
13358
|
+
/** Make the working tree match a commit, leaving HEAD alone. */
|
|
13359
|
+
async materialise(commitHash) {
|
|
13360
|
+
const commit = await this.get(commitHash);
|
|
13361
|
+
const wanted = new Map();
|
|
13362
|
+
await this.collect(commit.tree, "", wanted);
|
|
13363
|
+
// Remove what the commit does not have BEFORE writing what it does —
|
|
13364
|
+
// otherwise a file replaced by a directory of the same name collides.
|
|
13365
|
+
for (const abs of await this.deps.vfs.walk(this.root)) {
|
|
13366
|
+
const path = abs.slice(this.root.length);
|
|
13367
|
+
// keepContent: history still references these manifests.
|
|
13368
|
+
if (!wanted.has(path))
|
|
13369
|
+
await this.deps.vfs.delete(abs, { keepContent: true }).catch(() => { });
|
|
13370
|
+
}
|
|
13371
|
+
for (const [path, entry] of wanted) {
|
|
13372
|
+
await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
|
|
13373
|
+
}
|
|
13374
|
+
}
|
|
13375
|
+
/** Project-relative path → absolute VFS path. */
|
|
13376
|
+
abs(path) {
|
|
13377
|
+
return `${this.root}${path}`;
|
|
13378
|
+
}
|
|
13379
|
+
async collect(treeHash, prefix, out) {
|
|
13380
|
+
const tree = await this.get(treeHash);
|
|
13381
|
+
for (const [name, entry] of Object.entries(tree.entries)) {
|
|
13382
|
+
const path = `${prefix}/${name}`;
|
|
13383
|
+
if (entry.kind === "tree")
|
|
13384
|
+
await this.collect(entry.hash, path, out);
|
|
13385
|
+
else
|
|
13386
|
+
out.set(path, entry);
|
|
13387
|
+
}
|
|
13388
|
+
}
|
|
13389
|
+
// ---- branches -----------------------------------------------------------
|
|
13390
|
+
/** Create a branch at a commit (HEAD by default). Does not switch to it. */
|
|
13391
|
+
async branch(name, at) {
|
|
13392
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(name))
|
|
13393
|
+
throw new VcsError(`"${name}" is not a usable branch name`);
|
|
13394
|
+
if (await this.readRef(name))
|
|
13395
|
+
throw new VcsError(`branch "${name}" already exists`);
|
|
13396
|
+
const commit = at ?? (await this.current());
|
|
13397
|
+
if (!commit)
|
|
13398
|
+
throw new VcsError("cannot branch an empty repository — commit something first");
|
|
13399
|
+
await this.writeRef(name, commit);
|
|
13400
|
+
return commit;
|
|
13401
|
+
}
|
|
13402
|
+
/**
|
|
13403
|
+
* Move onto a branch, bringing the working tree with it.
|
|
13404
|
+
*
|
|
13405
|
+
* Unlike {@link checkout}, this ATTACHES head: later commits advance the
|
|
13406
|
+
* branch. Checking out a bare commit deliberately does not, so that
|
|
13407
|
+
* committing from a historical state cannot silently rewrite a branch.
|
|
13408
|
+
*/
|
|
13409
|
+
async switchTo(name, opts = {}) {
|
|
13410
|
+
const commit = await this.readRef(name);
|
|
13411
|
+
if (!commit)
|
|
13412
|
+
throw new VcsError(`no branch named "${name}"`);
|
|
13413
|
+
await this.guardWorkingTree(opts.discardChanges);
|
|
13414
|
+
await this.materialise(commit);
|
|
13415
|
+
await this.setHead({ branch: name });
|
|
13416
|
+
}
|
|
13417
|
+
/**
|
|
13418
|
+
* Refuse to overwrite work that was never recorded.
|
|
13419
|
+
*
|
|
13420
|
+
* Moving between commits REPLACES the working tree, so uncommitted edits
|
|
13421
|
+
* are gone — and unlike an ordinary overwrite, the person did not ask for
|
|
13422
|
+
* anything to be written. Naming the files is the point: "you have changes"
|
|
13423
|
+
* with no list leaves them guessing what they are about to lose.
|
|
13424
|
+
*/
|
|
13425
|
+
async guardWorkingTree(discard) {
|
|
13426
|
+
if (discard)
|
|
13427
|
+
return;
|
|
13428
|
+
const pending = await this.status();
|
|
13429
|
+
if (!pending.length)
|
|
13430
|
+
return;
|
|
13431
|
+
const names = pending.slice(0, 5).map((c) => c.path).join(", ");
|
|
13432
|
+
const more = pending.length > 5 ? `, and ${pending.length - 5} more` : "";
|
|
13433
|
+
throw new VcsError(`${pending.length} uncommitted change(s) would be lost: ${names}${more}. ` +
|
|
13434
|
+
"Commit them first, or pass { discardChanges: true } to throw them away.");
|
|
13435
|
+
}
|
|
13436
|
+
/** The branch HEAD is on, or null when detached. */
|
|
13437
|
+
async currentBranch() {
|
|
13438
|
+
const head = await this.head();
|
|
13439
|
+
return "detached" in head ? null : head.branch;
|
|
13440
|
+
}
|
|
13441
|
+
// ---- merge --------------------------------------------------------------
|
|
13442
|
+
/**
|
|
13443
|
+
* Merge another branch into the current one.
|
|
13444
|
+
*
|
|
13445
|
+
* Every part of this is client-side by necessity: the server holds
|
|
13446
|
+
* ciphertext and cannot compare versions, so base, ours and theirs are all
|
|
13447
|
+
* fetched and decrypted here.
|
|
13448
|
+
*/
|
|
13449
|
+
async merge(name, opts = {}) {
|
|
13450
|
+
const theirs = await this.readRef(name);
|
|
13451
|
+
if (!theirs)
|
|
13452
|
+
throw new VcsError(`no branch named "${name}"`);
|
|
13453
|
+
// A merge writes to the working tree as freely as a switch does.
|
|
13454
|
+
await this.guardWorkingTree(opts.discardChanges);
|
|
13455
|
+
const ours = await this.current();
|
|
13456
|
+
if (!ours)
|
|
13457
|
+
throw new VcsError("nothing to merge into — commit something first");
|
|
13458
|
+
if (ours === theirs)
|
|
13459
|
+
return { kind: "up-to-date", conflicts: [] };
|
|
13460
|
+
const base = await this.mergeBase(ours, theirs);
|
|
13461
|
+
if (base === theirs)
|
|
13462
|
+
return { kind: "up-to-date", conflicts: [] };
|
|
13463
|
+
if (base === ours) {
|
|
13464
|
+
// Fast-forward: our history is contained in theirs, so there is
|
|
13465
|
+
// nothing to combine — just move.
|
|
13466
|
+
await this.materialise(theirs);
|
|
13467
|
+
await this.advance(theirs);
|
|
13468
|
+
return { kind: "fast-forward", commit: theirs, conflicts: [] };
|
|
13469
|
+
}
|
|
13470
|
+
const [baseFiles, ourFiles, theirFiles] = await Promise.all([
|
|
13471
|
+
base ? this.filesOf(base) : new Map(),
|
|
13472
|
+
this.filesOf(ours),
|
|
13473
|
+
this.filesOf(theirs),
|
|
13474
|
+
]);
|
|
13475
|
+
const conflicts = [];
|
|
13476
|
+
for (const path of new Set([...ourFiles.keys(), ...theirFiles.keys(), ...baseFiles.keys()])) {
|
|
13477
|
+
const b = baseFiles.get(path);
|
|
13478
|
+
const o = ourFiles.get(path);
|
|
13479
|
+
const t = theirFiles.get(path);
|
|
13480
|
+
const conflict = await this.settlePath(path, b, o, t);
|
|
13481
|
+
if (conflict)
|
|
13482
|
+
conflicts.push(conflict);
|
|
13483
|
+
}
|
|
13484
|
+
if (conflicts.length) {
|
|
13485
|
+
// Record what to merge with, so finishing is just: fix the files,
|
|
13486
|
+
// then commit. Losing this would silently produce a commit with one
|
|
13487
|
+
// parent, and the merge would appear never to have happened.
|
|
13488
|
+
await this.deps.store.put(mergeKey(this.slug), await seal(await this.deps.key(), { theirs, name }));
|
|
13489
|
+
return { kind: "conflicted", conflicts };
|
|
13490
|
+
}
|
|
13491
|
+
const commit = await this.commit(`Merge ${name}`, [ours, theirs]);
|
|
13492
|
+
return { kind: "merged", commit, conflicts: [] };
|
|
13493
|
+
}
|
|
13494
|
+
/** Decide one path. Returns a conflict when it cannot be settled. */
|
|
13495
|
+
async settlePath(path, b, o, t) {
|
|
13496
|
+
const id = (f) => f?.manifest.id;
|
|
13497
|
+
if (id(o) === id(t))
|
|
13498
|
+
return null; // already agree
|
|
13499
|
+
if (!o && !b && t)
|
|
13500
|
+
return this.take(path, t); // only they added it
|
|
13501
|
+
if (!t && !b && o)
|
|
13502
|
+
return null; // only we added it
|
|
13503
|
+
if (id(o) === id(b)) { // only they touched it
|
|
13504
|
+
if (!t) {
|
|
13505
|
+
await this.deps.vfs.delete(this.abs(path), { keepContent: true }).catch(() => { });
|
|
13506
|
+
return null;
|
|
13507
|
+
}
|
|
13508
|
+
return this.take(path, t);
|
|
13509
|
+
}
|
|
13510
|
+
if (id(t) === id(b))
|
|
13511
|
+
return null; // only we touched it
|
|
13512
|
+
// Both sides moved. A file one side deleted and the other edited is not
|
|
13513
|
+
// a merge decision a tool should make silently.
|
|
13514
|
+
if (!o || !t)
|
|
13515
|
+
return { path, reason: "modify-delete" };
|
|
13516
|
+
// Definitely-binary types are rejected WITHOUT reading them — merging
|
|
13517
|
+
// bytes line-by-line produces corruption, not a result, and these files
|
|
13518
|
+
// are exactly the large ones.
|
|
13519
|
+
if (BINARY_TYPE.test(o.manifest.type ?? "") || BINARY_TYPE.test(t.manifest.type ?? "")) {
|
|
13520
|
+
return { path, reason: "binary" };
|
|
13521
|
+
}
|
|
13522
|
+
const [baseBytes, ourBytes, theirBytes] = await Promise.all([
|
|
13523
|
+
b ? this.deps.vfs.readByManifest(b.manifest) : Promise.resolve(new Uint8Array()),
|
|
13524
|
+
this.deps.vfs.readByManifest(o.manifest),
|
|
13525
|
+
this.deps.vfs.readByManifest(t.manifest),
|
|
13526
|
+
]);
|
|
13527
|
+
// Then judge by CONTENT, not by the declared type.
|
|
13528
|
+
//
|
|
13529
|
+
// The type is a hint we do not own: a content store may omit it, and
|
|
13530
|
+
// plenty of real text files carry no extension to derive one from
|
|
13531
|
+
// (Makefile, LICENSE, .gitignore, .env). Trusting it alone turned every
|
|
13532
|
+
// one of those into an unmergeable false conflict.
|
|
13533
|
+
if (![ourBytes, theirBytes, baseBytes].every(looksTextual))
|
|
13534
|
+
return { path, reason: "binary" };
|
|
13535
|
+
const decode = (bytes) => new TextDecoder().decode(bytes);
|
|
13536
|
+
const { text, conflicted } = merge3Text(decode(baseBytes), decode(ourBytes), decode(theirBytes));
|
|
13537
|
+
await this.deps.vfs.writeFile(this.abs(path), text);
|
|
13538
|
+
return conflicted ? { path, reason: "content" } : null;
|
|
13539
|
+
}
|
|
13540
|
+
async take(path, entry) {
|
|
13541
|
+
await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
|
|
13542
|
+
return null;
|
|
13543
|
+
}
|
|
13544
|
+
/**
|
|
13545
|
+
* The most recent commit reachable from both.
|
|
13546
|
+
*
|
|
13547
|
+
* Walks our ancestry into a set, then theirs breadth-first until it hits it —
|
|
13548
|
+
* so the first match is the nearest, which is what a three-way merge needs.
|
|
13549
|
+
*/
|
|
13550
|
+
async mergeBase(a, b) {
|
|
13551
|
+
const ancestors = new Set();
|
|
13552
|
+
const collect = async (start) => {
|
|
13553
|
+
const queue = [start];
|
|
13554
|
+
while (queue.length) {
|
|
13555
|
+
const hash = queue.shift();
|
|
13556
|
+
if (ancestors.has(hash))
|
|
13557
|
+
continue;
|
|
13558
|
+
ancestors.add(hash);
|
|
13559
|
+
queue.push(...(await this.get(hash)).parents);
|
|
13560
|
+
}
|
|
13561
|
+
};
|
|
13562
|
+
await collect(a);
|
|
13563
|
+
const queue = [b];
|
|
13564
|
+
const seen = new Set();
|
|
13565
|
+
while (queue.length) {
|
|
13566
|
+
const hash = queue.shift();
|
|
13567
|
+
if (seen.has(hash))
|
|
13568
|
+
continue;
|
|
13569
|
+
seen.add(hash);
|
|
13570
|
+
if (ancestors.has(hash))
|
|
13571
|
+
return hash;
|
|
13572
|
+
queue.push(...(await this.get(hash)).parents);
|
|
13573
|
+
}
|
|
13574
|
+
return null;
|
|
13575
|
+
}
|
|
13576
|
+
/** Every file in a commit, by path. */
|
|
13577
|
+
async filesOf(commitHash) {
|
|
13578
|
+
const commit = await this.get(commitHash);
|
|
13579
|
+
const out = new Map();
|
|
13580
|
+
await this.collect(commit.tree, "", out);
|
|
13581
|
+
return out;
|
|
13582
|
+
}
|
|
13583
|
+
}
|
|
13584
|
+
/**
|
|
13585
|
+
* Version control over the VFS.
|
|
13586
|
+
*
|
|
13587
|
+
* One repository per project, addressed by slug — the same slug the VFS stores
|
|
13588
|
+
* that project's working tree under. `client.vcs.open("my-app")` is the whole
|
|
13589
|
+
* entry point.
|
|
13590
|
+
*/
|
|
13591
|
+
class VcsNamespace {
|
|
13592
|
+
deps;
|
|
13593
|
+
repos = new Map();
|
|
13594
|
+
constructor(deps) {
|
|
13595
|
+
this.deps = deps;
|
|
13596
|
+
}
|
|
13597
|
+
/** The repository for one project. Cheap and cached; creates nothing. */
|
|
13598
|
+
open(slug) {
|
|
13599
|
+
let repo = this.repos.get(slug);
|
|
13600
|
+
if (!repo) {
|
|
13601
|
+
repo = new Repo(slug, {
|
|
13602
|
+
vfs: this.deps.vfs,
|
|
13603
|
+
store: this.deps.store,
|
|
13604
|
+
key: async () => {
|
|
13605
|
+
const seed = this.deps.seed();
|
|
13606
|
+
if (!seed)
|
|
13607
|
+
throw new VcsError("client.vcs: no session — sign in first.");
|
|
13608
|
+
return deriveRepoKey(seed, slug);
|
|
13609
|
+
},
|
|
13610
|
+
author: this.deps.author,
|
|
13611
|
+
});
|
|
13612
|
+
this.repos.set(slug, repo);
|
|
13613
|
+
}
|
|
13614
|
+
return repo;
|
|
13615
|
+
}
|
|
13616
|
+
}
|
|
13617
|
+
|
|
11658
13618
|
/**
|
|
11659
13619
|
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
11660
13620
|
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
@@ -12873,6 +14833,8 @@ class DbCache {
|
|
|
12873
14833
|
* `FileStorage`, `EncryptedSession`, `Network`, …) remain exported and usable
|
|
12874
14834
|
* directly, but the client is the supported, ergonomic surface.
|
|
12875
14835
|
*/
|
|
14836
|
+
/** Personal-space key holding the id of the space VFS content is written to. */
|
|
14837
|
+
const VFS_SPACE_KEY = "vfs/space";
|
|
12876
14838
|
/** The hosted Muhkoo Accelerator — the default {@link ClientOptions.baseUrl}. */
|
|
12877
14839
|
const DEFAULT_BASE_URL = "https://api.muhkoo.dev";
|
|
12878
14840
|
/** The hosted auth SPA — the default {@link ClientOptions.authBaseUrl}. */
|
|
@@ -12894,6 +14856,10 @@ class Client {
|
|
|
12894
14856
|
db;
|
|
12895
14857
|
/** File storage — `client.storage.writeFile(...)`, etc. */
|
|
12896
14858
|
storage;
|
|
14859
|
+
/** Filesystem over the personal space: directories, versions, real paths. */
|
|
14860
|
+
vfs;
|
|
14861
|
+
/** Version control over the VFS: commits, branches, merges — per project. */
|
|
14862
|
+
vcs;
|
|
12897
14863
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
12898
14864
|
message;
|
|
12899
14865
|
/** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
|
|
@@ -12993,6 +14959,103 @@ class Client {
|
|
|
12993
14959
|
deferShardUpload,
|
|
12994
14960
|
});
|
|
12995
14961
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
14962
|
+
// The VFS stores its metadata in the personal space — the same place
|
|
14963
|
+
// `storage.writeFile` already mirrors `{spaceId, manifest}` to, only
|
|
14964
|
+
// arranged as a navigable tree instead of a flat index keyed by file id.
|
|
14965
|
+
//
|
|
14966
|
+
// Content still goes through `storage.writeFile` untouched, into a real
|
|
14967
|
+
// Space with a real gated manifest. The space is created once, lazily,
|
|
14968
|
+
// and its id recorded in the personal space: a filesystem that has never
|
|
14969
|
+
// been written to should not cost a space.
|
|
14970
|
+
const personalStore = {
|
|
14971
|
+
get: async (key) => {
|
|
14972
|
+
const c = this.session.commitment;
|
|
14973
|
+
if (!c)
|
|
14974
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
14975
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
|
|
14976
|
+
return res?.value ?? null;
|
|
14977
|
+
},
|
|
14978
|
+
put: async (key, value) => {
|
|
14979
|
+
const c = this.session.commitment;
|
|
14980
|
+
if (!c)
|
|
14981
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
14982
|
+
await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
|
|
14983
|
+
},
|
|
14984
|
+
delete: async (key) => {
|
|
14985
|
+
const c = this.session.commitment;
|
|
14986
|
+
if (!c)
|
|
14987
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
14988
|
+
await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
|
|
14989
|
+
},
|
|
14990
|
+
list: async () => {
|
|
14991
|
+
const c = this.session.commitment;
|
|
14992
|
+
if (!c)
|
|
14993
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
14994
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
|
|
14995
|
+
return res?.keys ?? [];
|
|
14996
|
+
},
|
|
14997
|
+
};
|
|
14998
|
+
let contentSpace = null;
|
|
14999
|
+
const resolveContentSpace = () => {
|
|
15000
|
+
// Cached as the PROMISE, not the value: two concurrent first writes
|
|
15001
|
+
// would otherwise each create a space and one would be orphaned.
|
|
15002
|
+
if (!contentSpace) {
|
|
15003
|
+
contentSpace = (async () => {
|
|
15004
|
+
const existing = await personalStore.get(VFS_SPACE_KEY);
|
|
15005
|
+
if (typeof existing === "string" && existing)
|
|
15006
|
+
return this.space.get(existing);
|
|
15007
|
+
// `messaging: false` — this space only ever holds files, and
|
|
15008
|
+
// file chunks carry their own keys, so it needs no group
|
|
15009
|
+
// keyring and no socket. Creating it the normal way opens a
|
|
15010
|
+
// WebSocket, which made the first write hang indefinitely.
|
|
15011
|
+
const space = await this.space.createSpace({ private: true, messaging: false });
|
|
15012
|
+
await personalStore.put(VFS_SPACE_KEY, space.id);
|
|
15013
|
+
return space;
|
|
15014
|
+
})().catch((err) => {
|
|
15015
|
+
contentSpace = null; // let the next write retry
|
|
15016
|
+
throw err;
|
|
15017
|
+
});
|
|
15018
|
+
}
|
|
15019
|
+
return contentSpace;
|
|
15020
|
+
};
|
|
15021
|
+
this.vfs = new VfsNamespace({
|
|
15022
|
+
store: personalStore,
|
|
15023
|
+
seed: () => this.session.seed,
|
|
15024
|
+
// Share the personal space's socket with `client.kv` rather than
|
|
15025
|
+
// opening a second one to the same Durable Object.
|
|
15026
|
+
subscribe: (handler) => this.kv.onRaw(handler),
|
|
15027
|
+
content: {
|
|
15028
|
+
// `putFile` needs no WebSocket and commits the shards, so the
|
|
15029
|
+
// bytes are metered to the owning app and reference-counted for
|
|
15030
|
+
// GC. `storage.writeFile` would additionally index the manifest
|
|
15031
|
+
// in the space — a second, diverging source of truth for a file
|
|
15032
|
+
// whose handle the VFS already holds.
|
|
15033
|
+
write: async (data, meta) => {
|
|
15034
|
+
const space = await resolveContentSpace();
|
|
15035
|
+
const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
|
|
15036
|
+
return { manifest, size: stat.size };
|
|
15037
|
+
},
|
|
15038
|
+
// Reads go through the manifest alone — no space needed. That is
|
|
15039
|
+
// the capability path, so it keeps working for a file whose
|
|
15040
|
+
// handle was shared from someone else's filesystem.
|
|
15041
|
+
read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
|
|
15042
|
+
release: async (manifest) => {
|
|
15043
|
+
await (await resolveContentSpace()).releaseFile(manifest);
|
|
15044
|
+
},
|
|
15045
|
+
retain: async (manifest) => {
|
|
15046
|
+
await (await resolveContentSpace()).commitFile(manifest);
|
|
15047
|
+
},
|
|
15048
|
+
},
|
|
15049
|
+
});
|
|
15050
|
+
// Sealed into the SAME personal store as the VFS metadata, so a project
|
|
15051
|
+
// and its history live or die together — there is no way to end up with
|
|
15052
|
+
// commits whose working tree is gone, or the reverse.
|
|
15053
|
+
this.vcs = new VcsNamespace({
|
|
15054
|
+
vfs: this.vfs,
|
|
15055
|
+
store: personalStore,
|
|
15056
|
+
seed: () => this.session.seed,
|
|
15057
|
+
author: () => this.session.commitment ?? "unknown",
|
|
15058
|
+
});
|
|
12996
15059
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
12997
15060
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
12998
15061
|
// round-trip. The server only ever sees ciphertext.
|
|
@@ -13461,5 +15524,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
13461
15524
|
default: loadRsWasm
|
|
13462
15525
|
});
|
|
13463
15526
|
|
|
13464
|
-
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReauthRequiredError, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
15527
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, ReauthRequiredError, ReedSolomonCodec, Repo, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VFS_ROOT_INFO, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
13465
15528
|
//# sourceMappingURL=index.js.map
|