@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.
@@ -72,7 +72,7 @@ function randomBytes(n) {
72
72
  /** Length of the AES key in bytes. */
73
73
  const AES_GCM_KEY_BYTES = 32;
74
74
  /** Length of the AES-GCM IV in bytes (WebCrypto-recommended). */
75
- const AES_GCM_IV_BYTES = 12;
75
+ const AES_GCM_IV_BYTES$1 = 12;
76
76
  /** Length of the AES-GCM authentication tag in bytes (appended to ciphertext). */
77
77
  const AES_GCM_TAG_BYTES = 16;
78
78
  /**
@@ -113,8 +113,8 @@ function assertKey(key) {
113
113
  }
114
114
  }
115
115
  function assertIv(iv) {
116
- if (iv.byteLength !== AES_GCM_IV_BYTES) {
117
- throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES} bytes (got ${iv.byteLength})`);
116
+ if (iv.byteLength !== AES_GCM_IV_BYTES$1) {
117
+ throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES$1} bytes (got ${iv.byteLength})`);
118
118
  }
119
119
  }
120
120
 
@@ -443,7 +443,7 @@ function fromHex(hex) {
443
443
  return out;
444
444
  }
445
445
  /** Encode a byte array as a standard base64 string. */
446
- function toBase64$1(bytes) {
446
+ function toBase64$2(bytes) {
447
447
  let binary = "";
448
448
  for (let i = 0; i < bytes.length; i++) {
449
449
  binary += String.fromCharCode(bytes[i]);
@@ -451,7 +451,7 @@ function toBase64$1(bytes) {
451
451
  return btoa(binary);
452
452
  }
453
453
  /** Decode a standard base64 string to bytes. */
454
- function fromBase64$1(b64) {
454
+ function fromBase64$2(b64) {
455
455
  const binary = atob(b64);
456
456
  const ab = new ArrayBuffer(binary.length);
457
457
  const out = new Uint8Array(ab);
@@ -461,13 +461,13 @@ function fromBase64$1(b64) {
461
461
  }
462
462
  /** Encode a byte array as a base64url string (no padding). */
463
463
  function toBase64Url(bytes) {
464
- return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
464
+ return toBase64$2(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
465
465
  }
466
466
  /** Decode a base64url string (with or without padding) to bytes. */
467
467
  function fromBase64Url(b64url) {
468
468
  const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
469
469
  "=".repeat((4 - (b64url.length % 4)) % 4);
470
- return fromBase64$1(padded);
470
+ return fromBase64$2(padded);
471
471
  }
472
472
  /** Concatenate any number of byte arrays into a single Uint8Array. */
473
473
  function concatBytes(...arrays) {
@@ -1045,10 +1045,10 @@ class KeyStore {
1045
1045
  async packDehydratedKeys(id) {
1046
1046
  const dehydrated = await this.dehydrateKeyPair(id);
1047
1047
  const combined = JSON.stringify(dehydrated);
1048
- return toBase64$1(new TextEncoder().encode(combined));
1048
+ return toBase64$2(new TextEncoder().encode(combined));
1049
1049
  }
1050
1050
  async hydrateFromPacked(id, masterKey) {
1051
- const decoded = utf8Decode(fromBase64$1(masterKey));
1051
+ const decoded = utf8Decode(fromBase64$2(masterKey));
1052
1052
  const dehydrated = JSON.parse(decoded);
1053
1053
  await this.hydrateKeyPair(id, dehydrated);
1054
1054
  }
@@ -1158,7 +1158,7 @@ class DoubleRatchet {
1158
1158
  }
1159
1159
  const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
1160
1160
  this.state.sendChainKey = newChainKey;
1161
- const iv = randomBytes(AES_GCM_IV_BYTES);
1161
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
1162
1162
  const encrypted = await encryptAesGcm(messageKey, iv, utf8Encode(plaintext));
1163
1163
  const cipherTextWithTag = toHex(encrypted);
1164
1164
  appLogger.debug(`Encrypt: plaintextLength=${plaintext.length}, ciphertextLength=${encrypted.byteLength}`);
@@ -1878,8 +1878,8 @@ class ChunkCipher {
1878
1878
  */
1879
1879
  generateKeyMaterial() {
1880
1880
  return {
1881
- key: toBase64$1(randomBytes(AES_GCM_KEY_BYTES)),
1882
- iv: toBase64$1(randomBytes(AES_GCM_IV_BYTES)),
1881
+ key: toBase64$2(randomBytes(AES_GCM_KEY_BYTES)),
1882
+ iv: toBase64$2(randomBytes(AES_GCM_IV_BYTES$1)),
1883
1883
  };
1884
1884
  }
1885
1885
  /**
@@ -1888,7 +1888,7 @@ class ChunkCipher {
1888
1888
  * GCM behavior).
1889
1889
  */
1890
1890
  async encryptChunk(plaintext, material) {
1891
- return encryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), plaintext);
1891
+ return encryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), plaintext);
1892
1892
  }
1893
1893
  /**
1894
1894
  * Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
@@ -1897,7 +1897,7 @@ class ChunkCipher {
1897
1897
  */
1898
1898
  async decryptChunk(ciphertext, material) {
1899
1899
  try {
1900
- return await decryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), ciphertext);
1900
+ return await decryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), ciphertext);
1901
1901
  }
1902
1902
  catch {
1903
1903
  // WebCrypto throws a vague OperationError on GCM tag mismatch.
@@ -1961,7 +1961,7 @@ async function wrapWithPassphrase(passphrase, plaintext) {
1961
1961
  throw new Error("wrapWithPassphrase: passphrase must be a non-empty string");
1962
1962
  }
1963
1963
  const salt = randomBytes(SALT_BYTES);
1964
- const iv = randomBytes(AES_GCM_IV_BYTES);
1964
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
1965
1965
  const key = await deriveAesKeyFromPassphrase(passphrase, salt, PBKDF2_DEFAULT_ITERATIONS);
1966
1966
  const ciphertext = await encryptAesGcm(key, iv, plaintext);
1967
1967
  return {
@@ -2449,8 +2449,10 @@ class AuthClient {
2449
2449
  /** Passkeys are per-origin: naming the caller's host returns THAT origin's
2450
2450
  * passkey instead of whichever was enrolled first (which WebAuthn would
2451
2451
  * refuse). Ignored for other factor types. */
2452
- rpId) {
2453
- const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId }));
2452
+ rpId,
2453
+ /** Device factors are selected by id — a user may have many machines. */
2454
+ factorId) {
2455
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId, factorId }));
2454
2456
  return await this.parse("vaultRead", res);
2455
2457
  }
2456
2458
  /**
@@ -2891,20 +2893,20 @@ async function wrapKeyFromBytes(keyMaterial, info) {
2891
2893
  async function wrapSeed(seed, key) {
2892
2894
  const iv = crypto.getRandomValues(new Uint8Array(12));
2893
2895
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
2894
- return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
2896
+ return { iv: toBase64$1(iv), ct: toBase64$1(new Uint8Array(ct)) };
2895
2897
  }
2896
2898
  /** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
2897
2899
  async function unwrapSeed(wrapped, key) {
2898
- const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
2900
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(wrapped.iv) }, key, fromBase64$1(wrapped.ct));
2899
2901
  return new Uint8Array(pt);
2900
2902
  }
2901
- function toBase64(bytes) {
2903
+ function toBase64$1(bytes) {
2902
2904
  let s = "";
2903
2905
  for (let i = 0; i < bytes.length; i++)
2904
2906
  s += String.fromCharCode(bytes[i]);
2905
2907
  return btoa(s);
2906
2908
  }
2907
- function fromBase64(b64) {
2909
+ function fromBase64$1(b64) {
2908
2910
  const s = atob(b64);
2909
2911
  const out = new Uint8Array(s.length);
2910
2912
  for (let i = 0; i < s.length; i++)
@@ -2962,20 +2964,20 @@ async function sealSeed(seed) {
2962
2964
  const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["encrypt"]);
2963
2965
  const iv = crypto.getRandomValues(new Uint8Array(12));
2964
2966
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
2965
- const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
2967
+ const envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
2966
2968
  return {
2967
- sealedKeys: toBase64(TE$1.encode(JSON.stringify(envelope))),
2969
+ sealedKeys: toBase64$1(TE$1.encode(JSON.stringify(envelope))),
2968
2970
  fragmentKey: base64UrlEncode(ktRaw),
2969
2971
  };
2970
2972
  }
2971
2973
  /** Recover the master seed from the sealed envelope + the fragment key. */
2972
2974
  async function unsealSeed(sealedKeys, fragmentKey) {
2973
- const envelope = JSON.parse(TD$1.decode(fromBase64(sealedKeys)));
2975
+ const envelope = JSON.parse(TD$1.decode(fromBase64$1(sealedKeys)));
2974
2976
  if (envelope.v !== 1)
2975
2977
  throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
2976
2978
  const ktRaw = base64UrlDecode(fragmentKey);
2977
2979
  const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["decrypt"]);
2978
- const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
2980
+ const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct));
2979
2981
  return new Uint8Array(seed);
2980
2982
  }
2981
2983
  // ---- v2: ECDH device pairing ----------------------------------------------
@@ -3026,7 +3028,7 @@ const PAIRING_CODE_ITERATIONS = 200_000;
3026
3028
  async function generateDevicePairingKeypair() {
3027
3029
  const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
3028
3030
  const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
3029
- return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
3031
+ return { publicKeyB64: toBase64$1(raw), privateKey: pair.privateKey };
3030
3032
  }
3031
3033
  /**
3032
3034
  * (Hosted-page side) Seal the master seed *to a device's* pairing public key.
@@ -3047,8 +3049,8 @@ async function sealSeedToDevice(seed, devicePublicKeyB64) {
3047
3049
  const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
3048
3050
  const iv = crypto.getRandomValues(new Uint8Array(12));
3049
3051
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
3050
- const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
3051
- return toBase64(TE$1.encode(JSON.stringify(envelope)));
3052
+ const envelope = { v: 2, epk: toBase64$1(epkRaw), iv: toBase64$1(iv), ct: toBase64$1(ct) };
3053
+ return toBase64$1(TE$1.encode(JSON.stringify(envelope)));
3052
3054
  }
3053
3055
  /**
3054
3056
  * (Device side) Recover the master seed from a v2 envelope using the ephemeral
@@ -3206,7 +3208,7 @@ function importPairingPublicKey(raw) {
3206
3208
  */
3207
3209
  function fromBase64Loose(s) {
3208
3210
  const norm = s.replace(/-/g, "+").replace(/_/g, "/");
3209
- return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
3211
+ return fromBase64$1(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
3210
3212
  }
3211
3213
  /** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
3212
3214
  async function generatePkce() {
@@ -3219,10 +3221,10 @@ function randomState() {
3219
3221
  return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
3220
3222
  }
3221
3223
  function base64UrlEncode(bytes) {
3222
- return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3224
+ return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3223
3225
  }
3224
3226
  function base64UrlDecode(s) {
3225
- return fromBase64(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
3227
+ return fromBase64$1(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
3226
3228
  }
3227
3229
 
3228
3230
  /**
@@ -3233,7 +3235,7 @@ function base64UrlDecode(s) {
3233
3235
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
3234
3236
  * `package.json` `version`.
3235
3237
  */
3236
- const VERSION = "0.10.14-alpha.0";
3238
+ const VERSION = "0.12.0-alpha.0";
3237
3239
 
3238
3240
  /**
3239
3241
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -3686,8 +3688,8 @@ function gatedBlind(input) {
3686
3688
  * unblind each, concatenate, HKDF with the gated-factor domain separation.
3687
3689
  */
3688
3690
  async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
3689
- const out1 = oprfFinalize(input, blind, fromBase64(evaluatedB64));
3690
- const out2 = oprfFinalize(input, blind, fromBase64(evaluated2B64));
3691
+ const out1 = oprfFinalize(input, blind, fromBase64$1(evaluatedB64));
3692
+ const out2 = oprfFinalize(input, blind, fromBase64$1(evaluated2B64));
3691
3693
  const ikm = new Uint8Array(out1.length + out2.length);
3692
3694
  ikm.set(out1);
3693
3695
  ikm.set(out2, out1.length);
@@ -4021,13 +4023,13 @@ async function persistDeviceIdentity(v) {
4021
4023
  const plain = TE.encode(JSON.stringify({
4022
4024
  username: v.username,
4023
4025
  commitment: v.commitment,
4024
- seed: toBase64(v.seed),
4026
+ seed: toBase64$1(v.seed),
4025
4027
  pairedAt: v.pairedAt,
4026
4028
  }));
4027
4029
  const bridge = keystoreBridge();
4028
4030
  let envelope;
4029
4031
  if (bridge) {
4030
- envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64(plain)) };
4032
+ envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64$1(plain)) };
4031
4033
  }
4032
4034
  else {
4033
4035
  const key = await wrapKey(true);
@@ -4035,7 +4037,7 @@ async function persistDeviceIdentity(v) {
4035
4037
  throw new DeviceStoreUnavailableError("no IndexedDB for the wrapping key");
4036
4038
  const iv = crypto.getRandomValues(new Uint8Array(12));
4037
4039
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plain));
4038
- envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
4040
+ envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
4039
4041
  }
4040
4042
  blobs.set(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(envelope));
4041
4043
  }
@@ -4059,18 +4061,18 @@ async function loadDeviceIdentity() {
4059
4061
  if (envelope.ks === 1) {
4060
4062
  if (!bridge)
4061
4063
  return null; // sealed by the native shell; nothing else can open it
4062
- plain = fromBase64(await bridge.unwrap(envelope.ct));
4064
+ plain = fromBase64$1(await bridge.unwrap(envelope.ct));
4063
4065
  }
4064
4066
  else {
4065
4067
  const key = await wrapKey(false);
4066
4068
  if (!key || !envelope.iv)
4067
4069
  return null;
4068
- plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct)));
4070
+ plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct)));
4069
4071
  }
4070
4072
  const parsed = JSON.parse(TD.decode(plain));
4071
4073
  if (!parsed?.username || !parsed?.commitment || !parsed?.seed)
4072
4074
  return null;
4073
- const seed = fromBase64(parsed.seed);
4075
+ const seed = fromBase64$1(parsed.seed);
4074
4076
  if (seed.length !== 32)
4075
4077
  return null;
4076
4078
  return {
@@ -4104,7 +4106,7 @@ async function clearDeviceIdentity() {
4104
4106
  async function fingerprintOf(publicKey) {
4105
4107
  const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
4106
4108
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
4107
- return toBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4109
+ return toBase64$1(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4108
4110
  }
4109
4111
  async function loadOrCreateIdentityKey() {
4110
4112
  const vault = keyVault();
@@ -5170,6 +5172,8 @@ class VaultUnavailableError extends Error {
5170
5172
  * ZK identity auth. One instance per {@link Client}; reads/writes the shared
5171
5173
  * {@link SessionState}.
5172
5174
  */
5175
+ /** HKDF label separating a device wrap key from every other use of the seed. */
5176
+ const DEVICE_WRAP_INFO = "muhkoo-device-key-v1";
5173
5177
  class ZkAuth {
5174
5178
  deps;
5175
5179
  constructor(deps) {
@@ -5461,7 +5465,7 @@ class ZkAuth {
5461
5465
  const { verifyToken, email: verified } = await this.deps.auth.emailOtpVerify({ token, code });
5462
5466
  const input = await emailFactorInput(username, verified);
5463
5467
  const { blind, blinded } = gatedBlind(input);
5464
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "enroll", verifyToken);
5468
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "enroll", verifyToken);
5465
5469
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5466
5470
  const wrapped = await wrapSeed(seed, key);
5467
5471
  await this.deps.auth.vaultPutFactor(token, { id: "email", type: "email", wrap: wrapped.ct, iv: wrapped.iv, params: { email: verified }, createdAt: Date.now() }, verifyToken);
@@ -5489,7 +5493,7 @@ class ZkAuth {
5489
5493
  }
5490
5494
  const input = await emailFactorInput(username, email);
5491
5495
  const { blind, blinded } = gatedBlind(input);
5492
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "recover", verifyToken);
5496
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "recover", verifyToken);
5493
5497
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5494
5498
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
5495
5499
  const identity = await deriveIdentityFromSeed(seed);
@@ -5517,7 +5521,7 @@ class ZkAuth {
5517
5521
  const { verifyToken, sub, emailHint } = await this.deps.auth.googleVerify({ idToken, token });
5518
5522
  const input = await googleFactorInput(username, sub);
5519
5523
  const { blind, blinded } = gatedBlind(input);
5520
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "enroll", verifyToken);
5524
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "enroll", verifyToken);
5521
5525
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5522
5526
  const wrapped = await wrapSeed(seed, key);
5523
5527
  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);
@@ -5535,7 +5539,7 @@ class ZkAuth {
5535
5539
  throw new Error("No Google account is linked to this username.");
5536
5540
  const input = await googleFactorInput(username, sub);
5537
5541
  const { blind, blinded } = gatedBlind(input);
5538
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "recover", verifyToken);
5542
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "recover", verifyToken);
5539
5543
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
5540
5544
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
5541
5545
  const identity = await deriveIdentityFromSeed(seed);
@@ -5616,11 +5620,11 @@ class ZkAuth {
5616
5620
  const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
5617
5621
  const wrapped = await wrapSeed(seed, key);
5618
5622
  await this.deps.auth.vaultPutFactor(token, {
5619
- id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
5623
+ id: `passkey:${toBase64$1(credentialId).slice(0, 16)}`,
5620
5624
  type: "passkey",
5621
5625
  wrap: wrapped.ct,
5622
5626
  iv: wrapped.iv,
5623
- params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
5627
+ params: { credentialId: toBase64$1(credentialId), prfSalt: toBase64$1(prfSalt), rpId },
5624
5628
  label: opts?.label ?? "Passkey",
5625
5629
  createdAt: Date.now(),
5626
5630
  });
@@ -5645,7 +5649,7 @@ class ZkAuth {
5645
5649
  // to enroll a passkey for this origin.
5646
5650
  if (!rpIdUsableForOrigin(rpId, host))
5647
5651
  throw new PasskeyOriginError();
5648
- const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64(params.credentialId), fromBase64(params.prfSalt));
5652
+ const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64$1(params.credentialId), fromBase64$1(params.prfSalt));
5649
5653
  const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
5650
5654
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
5651
5655
  const identity = await deriveIdentityFromSeed(seed);
@@ -5724,7 +5728,107 @@ class ZkAuth {
5724
5728
  */
5725
5729
  get seedBase64() {
5726
5730
  const seed = this.deps.session.seed;
5727
- return seed ? toBase64(seed) : null;
5731
+ return seed ? toBase64$1(seed) : null;
5732
+ }
5733
+ /**
5734
+ * Pair this machine, so it can unlock the account later without a password.
5735
+ *
5736
+ * Generates a random 32-byte key, wraps the master seed under it, and stores
5737
+ * only the CIPHERTEXT in the vault as a `device` factor. The key is returned
5738
+ * for the caller to persist locally and is never sent anywhere.
5739
+ *
5740
+ * The split is the point: the seed itself never lands on disk, and the
5741
+ * server holds a blob it cannot open. Revoking the factor
5742
+ * ({@link revokeDevice}) makes the local key inert immediately — which a
5743
+ * stored master seed can never be, because nothing can reach out and
5744
+ * un-store it.
5745
+ *
5746
+ * Returns the factor id and the key, both base64. Losing the key just means
5747
+ * re-pairing; leaking it is equivalent to leaking the seed for as long as
5748
+ * the factor exists, so store it with the tightest permissions available.
5749
+ */
5750
+ async enrollDevice(label) {
5751
+ const seed = this.deps.session.seed;
5752
+ const token = this.deps.session.token;
5753
+ if (!seed || !token)
5754
+ throw new Error("Sign in first to pair this device.");
5755
+ const keyBytes = randomSeed(); // 32 bytes of CSPRNG
5756
+ const key = await wrapKeyFromBytes(keyBytes, DEVICE_WRAP_INFO);
5757
+ const wrapped = await wrapSeed(seed, key);
5758
+ // 128 bits of id, so reading a factor by id is not an enumeration surface.
5759
+ const factorId = `device:${toBase64$1(randomSeed()).replace(/[+/=]/g, "").slice(0, 22)}`;
5760
+ await this.deps.auth.vaultPutFactor(token, {
5761
+ id: factorId,
5762
+ type: "device",
5763
+ wrap: wrapped.ct,
5764
+ iv: wrapped.iv,
5765
+ label,
5766
+ createdAt: Date.now(),
5767
+ });
5768
+ return { factorId, deviceKey: toBase64$1(keyBytes) };
5769
+ }
5770
+ /**
5771
+ * Unlock using a key stored by {@link enrollDevice}.
5772
+ *
5773
+ * Fetches the wrapped seed and opens it locally. Establishes the encryption
5774
+ * identity only — pair it with a session for calls that need one.
5775
+ *
5776
+ * Throws distinguishably: a REVOKED factor is gone from the vault and says
5777
+ * so, where a wrong key fails to decrypt. A machine that has had its access
5778
+ * withdrawn should be able to tell you that, not just "decryption failed".
5779
+ */
5780
+ async unlockWithDevice(username, factorId, deviceKey) {
5781
+ const { factor } = await this.deps.auth.vaultRead(username, "device", undefined, undefined, factorId);
5782
+ if (!factor) {
5783
+ throw new Error("This device is no longer paired — its access was revoked. Sign in again to re-pair.");
5784
+ }
5785
+ if (!factor.wrap || !factor.iv)
5786
+ throw new Error("The stored device factor is incomplete; re-pair this device.");
5787
+ const key = await wrapKeyFromBytes(fromBase64$1(deviceKey), DEVICE_WRAP_INFO);
5788
+ const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
5789
+ this.deps.session.setSeed(seed);
5790
+ }
5791
+ /** Paired machines, newest first. Metadata only — never the ciphertext. */
5792
+ async listDevices() {
5793
+ const token = this.deps.session.token;
5794
+ if (!token)
5795
+ throw new Error("Sign in first to list paired devices.");
5796
+ const { factors } = await this.deps.auth.vaultFactors(token);
5797
+ return factors
5798
+ .filter((f) => f.type === "device")
5799
+ .map((f) => ({ id: f.id, label: f.label, createdAt: f.createdAt }))
5800
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
5801
+ }
5802
+ /** Withdraw a machine's access. Its stored key stops working immediately. */
5803
+ async revokeDevice(factorId) {
5804
+ const token = this.deps.session.token;
5805
+ if (!token)
5806
+ throw new Error("Sign in first to revoke a device.");
5807
+ const { deleted } = await this.deps.auth.vaultDeleteFactor(token, factorId);
5808
+ return deleted;
5809
+ }
5810
+ /**
5811
+ * Restore an unlocked identity from a seed you already hold.
5812
+ *
5813
+ * The inverse of {@link seedBase64}, for hosts that persist the seed
5814
+ * themselves rather than re-deriving it from a password: a CLI holding it in
5815
+ * a 0600 file, a server-side tool, a test. Without this the seed is a
5816
+ * one-way export and anything outside a browser has to run a full ZK login —
5817
+ * circuits download and proof — on every single invocation.
5818
+ *
5819
+ * Establishes the ENCRYPTION identity only. It does not authenticate: pair
5820
+ * it with a session token (`sessionStore`) for calls that need one.
5821
+ *
5822
+ * Treat a persisted seed as the master credential it is. It is not scoped
5823
+ * and cannot be revoked server-side the way a session token can — rotating
5824
+ * it means moving the identity.
5825
+ */
5826
+ unlockWithSeed(seedBase64) {
5827
+ const seed = fromBase64$1(seedBase64);
5828
+ if (seed.length !== 32) {
5829
+ throw new Error(`auth.zk.unlockWithSeed: expected a 32-byte seed, got ${seed.length}`);
5830
+ }
5831
+ this.deps.session.setSeed(seed);
5728
5832
  }
5729
5833
  // -------------------------------------------------------------------------
5730
5834
  /** Poseidon commitment for an identity — the value the server stores. */
@@ -5743,8 +5847,8 @@ class ZkAuth {
5743
5847
  async passwordWrapKey(username, password) {
5744
5848
  const pre = passwordPreHash(username, password);
5745
5849
  const { blind, blinded } = oprfBlind(pre);
5746
- const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
5747
- return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
5850
+ const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64$1(blinded));
5851
+ return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64$1(evaluated)));
5748
5852
  }
5749
5853
  /**
5750
5854
  * Try to recover the master seed from the password factor.
@@ -5850,16 +5954,16 @@ class StorageCipher {
5850
5954
  /** Seal a JSON-serializable value into an {@link EncryptedEnvelope}. */
5851
5955
  async encrypt(value) {
5852
5956
  const key = await this.keyPromise;
5853
- const iv = randomBytes(AES_GCM_IV_BYTES);
5957
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
5854
5958
  const plaintext = utf8Encode(JSON.stringify(value));
5855
5959
  const ciphertext = await encryptAesGcm(key, iv, plaintext);
5856
- return { _enc: "a256gcm", iv: toBase64$1(iv), ct: toBase64$1(ciphertext) };
5960
+ return { _enc: "a256gcm", iv: toBase64$2(iv), ct: toBase64$2(ciphertext) };
5857
5961
  }
5858
5962
  /** Open an {@link EncryptedEnvelope} back into the original value. */
5859
5963
  async decrypt(envelope) {
5860
5964
  const key = await this.keyPromise;
5861
- const iv = fromBase64$1(envelope.iv);
5862
- const ciphertext = fromBase64$1(envelope.ct);
5965
+ const iv = fromBase64$2(envelope.iv);
5966
+ const ciphertext = fromBase64$2(envelope.ct);
5863
5967
  const plaintext = await decryptAesGcm(key, iv, ciphertext);
5864
5968
  return JSON.parse(utf8Decode(plaintext));
5865
5969
  }
@@ -6190,6 +6294,8 @@ class WSTransport extends EventCore {
6190
6294
  */
6191
6295
  class KvNamespace {
6192
6296
  deps;
6297
+ /** One personal-space socket, shared by every subscriber. */
6298
+ feed = null;
6193
6299
  cipher = null;
6194
6300
  cipherIdentity = null;
6195
6301
  constructor(deps) {
@@ -6314,22 +6420,64 @@ class KvNamespace {
6314
6420
  if (!token)
6315
6421
  throw new Error("client.kv.on: not signed in.");
6316
6422
  const commitment = this.commitment();
6317
- const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
6423
+ `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
6318
6424
  // Ride a WSTransport rather than a bare socket: it keeps the (otherwise
6319
6425
  // idle) personal-space feed warm with heartbeat pings, auto-reconnects
6320
6426
  // if it drops, and namespaces its events by `id` so this feed never
6321
6427
  // crosses wires with chat/space transports on the shared EventCore bus.
6322
- const transport = new WSTransport({ url, id: `personal:${commitment}` });
6323
- const onMessage = (e) => {
6324
- void this.dispatchChange(e.detail, handler);
6325
- };
6326
- transport.on(EventCoreEvents.MESSAGE, onMessage);
6327
- // Fire-and-forget: the realtime feed is best-effort, and the transport
6328
- // queues nothing it needs before CONNECTED (server→client only).
6329
- void transport.connect();
6428
+ return this.onRaw((raw) => {
6429
+ void this.dispatchChange(raw, handler);
6430
+ });
6431
+ }
6432
+ /**
6433
+ * Subscribe to the personal space's raw change feed.
6434
+ *
6435
+ * Every key this user owns crosses this socket, not only `client.kv`
6436
+ * collections — the VFS keeps its directory records here too. Consumers get
6437
+ * the frame untouched: no decryption, no offline-cache merge, because the
6438
+ * value's meaning depends on which subsystem wrote it.
6439
+ *
6440
+ * The transport is SHARED and reference-counted. `WSTransport` namespaces
6441
+ * its events by `id`, so two transports with the same id would cross wires
6442
+ * on the shared event bus — and a second socket to the same Durable Object
6443
+ * would be pure waste besides.
6444
+ */
6445
+ onRaw(handler) {
6446
+ if (typeof globalThis.WebSocket !== "function") {
6447
+ throw new Error("client.kv.onRaw: no `WebSocket` global available in this runtime.");
6448
+ }
6449
+ const token = this.deps.session.token;
6450
+ if (!token)
6451
+ throw new Error("client.kv.onRaw: not signed in.");
6452
+ const commitment = this.commitment();
6453
+ if (!this.feed) {
6454
+ const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
6455
+ // Ride a WSTransport rather than a bare socket: it keeps the
6456
+ // (otherwise idle) feed warm with heartbeat pings, auto-reconnects
6457
+ // if it drops, and namespaces its events by `id`.
6458
+ const transport = new WSTransport({ url, id: `personal:${commitment}` });
6459
+ const handlers = new Set();
6460
+ const onMessage = (e) => {
6461
+ for (const fn of handlers)
6462
+ fn(e.detail);
6463
+ };
6464
+ transport.on(EventCoreEvents.MESSAGE, onMessage);
6465
+ // Fire-and-forget: the feed is best-effort, and the transport queues
6466
+ // nothing it needs before CONNECTED (server→client only).
6467
+ void transport.connect();
6468
+ this.feed = { transport, handlers, onMessage };
6469
+ }
6470
+ const feed = this.feed;
6471
+ feed.handlers.add(handler);
6330
6472
  return () => {
6331
- transport.off(EventCoreEvents.MESSAGE, onMessage);
6332
- transport.disconnect();
6473
+ feed.handlers.delete(handler);
6474
+ // Last subscriber out closes the socket, so an app that unsubscribes
6475
+ // everything is not left holding an open connection.
6476
+ if (feed.handlers.size === 0 && this.feed === feed) {
6477
+ feed.transport.off(EventCoreEvents.MESSAGE, feed.onMessage);
6478
+ feed.transport.disconnect();
6479
+ this.feed = null;
6480
+ }
6333
6481
  };
6334
6482
  }
6335
6483
  // -------------------------------------------------------------------------
@@ -8302,12 +8450,12 @@ function canonicalMessage(fields) {
8302
8450
  /** Sign the canonical bytes with the sender's ECDSA identity private key. */
8303
8451
  async function signSpaceMessage(canonical, ecdsaPriv) {
8304
8452
  const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
8305
- return toBase64$1(new Uint8Array(sig));
8453
+ return toBase64$2(new Uint8Array(sig));
8306
8454
  }
8307
8455
  /** Verify a signature over the canonical bytes against the sender's ECDSA key. */
8308
8456
  async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
8309
8457
  try {
8310
- return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$1(sigB64), utf8Encode(canonical));
8458
+ return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$2(sigB64), utf8Encode(canonical));
8311
8459
  }
8312
8460
  catch {
8313
8461
  return false;
@@ -8338,13 +8486,13 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
8338
8486
  const eph = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
8339
8487
  const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: recipientEcdhPub }, eph.privateKey, P384_BITS));
8340
8488
  const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${epoch}`));
8341
- const iv = randomBytes(AES_GCM_IV_BYTES);
8489
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
8342
8490
  const ciphertext = await encryptAesGcm(wrapKey, iv, kSpace);
8343
8491
  return {
8344
8492
  epoch,
8345
8493
  ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
8346
- iv: toBase64$1(iv),
8347
- ciphertext: toBase64$1(ciphertext),
8494
+ iv: toBase64$2(iv),
8495
+ ciphertext: toBase64$2(ciphertext),
8348
8496
  alg: WRAP_ALG,
8349
8497
  };
8350
8498
  }
@@ -8356,23 +8504,23 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
8356
8504
  const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
8357
8505
  const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
8358
8506
  const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
8359
- return decryptAesGcm(wrapKey, fromBase64$1(wrapped.iv), fromBase64$1(wrapped.ciphertext));
8507
+ return decryptAesGcm(wrapKey, fromBase64$2(wrapped.iv), fromBase64$2(wrapped.ciphertext));
8360
8508
  }
8361
8509
  // ---------------------------------------------------------------------------
8362
8510
  // Message sealing
8363
8511
  // ---------------------------------------------------------------------------
8364
8512
  /** Seal an already-serialized message string under the group key. */
8365
8513
  async function sealSerialized(kSpace, serialized) {
8366
- const iv = randomBytes(AES_GCM_IV_BYTES);
8514
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
8367
8515
  const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
8368
- return { iv: toBase64$1(iv), ciphertext: toBase64$1(ciphertext) };
8516
+ return { iv: toBase64$2(iv), ciphertext: toBase64$2(ciphertext) };
8369
8517
  }
8370
8518
  /**
8371
8519
  * Open a sealed payload back into its serialized message string. Throws (GCM
8372
8520
  * tag failure) on the wrong group key or a tampered ciphertext.
8373
8521
  */
8374
8522
  async function openSerialized(kSpace, iv, ciphertext) {
8375
- const plaintext = await decryptAesGcm(kSpace, fromBase64$1(iv), fromBase64$1(ciphertext));
8523
+ const plaintext = await decryptAesGcm(kSpace, fromBase64$2(iv), fromBase64$2(ciphertext));
8376
8524
  return utf8Decode(plaintext);
8377
8525
  }
8378
8526
 
@@ -10876,6 +11024,16 @@ class SpaceNamespace {
10876
11024
  * Create a new space: generate its keypair (public key = id), register
10877
11025
  * metadata, mint the initial group key, and connect. Returns the open Space.
10878
11026
  */
11027
+ /**
11028
+ * Create a space.
11029
+ *
11030
+ * `messaging: false` registers the space and stops there — no group-key
11031
+ * bootstrap and no WebSocket. Use it for a space that only ever holds
11032
+ * FILES: each chunk carries its own key inside its manifest, so the group
11033
+ * keyring buys nothing, and opening a socket makes the write path depend on
11034
+ * a live connection. That dependency is wrong for a CLI, and it is what made
11035
+ * the first VFS write hang forever instead of failing.
11036
+ */
10879
11037
  async createSpace(opts = {}) {
10880
11038
  const historyPolicy = opts.historyPolicy ?? "static";
10881
11039
  const identity = await generateSpaceIdentity();
@@ -10884,7 +11042,8 @@ class SpaceNamespace {
10884
11042
  // its first member — so the metadata POST must carry the user session.
10885
11043
  await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
10886
11044
  const space = await this.build(identity.id, historyPolicy);
10887
- await space.create();
11045
+ if (opts.messaging !== false)
11046
+ await space.create();
10888
11047
  return space;
10889
11048
  }
10890
11049
  /** Join an existing space by id (its public key). Resolves once a key arrives. */
@@ -11081,7 +11240,7 @@ class SpaceNamespace {
11081
11240
  console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
11082
11241
  if (this.deps.chatKeys && seed) {
11083
11242
  try {
11084
- await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
11243
+ await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
11085
11244
  }
11086
11245
  catch (e) {
11087
11246
  if (staging)
@@ -11386,6 +11545,1807 @@ class AccessTokensNamespace {
11386
11545
  }
11387
11546
  }
11388
11547
 
11548
+ /**
11549
+ * Wire types for the VFS metadata layer.
11550
+ *
11551
+ * ## What is stored where
11552
+ *
11553
+ * The filesystem is a graph of **directory records**, one per directory, each
11554
+ * held under a personal-space KV key and encrypted client-side. A record lists
11555
+ * only its immediate children — so listing a directory is one fetch, and you
11556
+ * only ever pay for the subtrees you actually open.
11557
+ *
11558
+ * File CONTENT is not here. Bytes are written with `client.storage.writeFile`
11559
+ * exactly as any other file — into a real Space, with a real gated manifest —
11560
+ * and the entry keeps the returned `FileManifest` as its handle.
11561
+ * `client.storage.readByManifest(manifest)` then reconstructs the bytes from
11562
+ * the content-addressed shard store with no space membership or directory
11563
+ * lookup needed, which is what makes a file shareable by handing over the
11564
+ * handle alone.
11565
+ *
11566
+ * This supersedes the flat personal mirror `writeFile` already maintains
11567
+ * (`{spaceId, manifest}` keyed by manifest id): the same metadata, in the same
11568
+ * personal space, arranged as a tree you can actually navigate.
11569
+ *
11570
+ * ## Why directories have stable random ids, not content hashes
11571
+ *
11572
+ * A git-style tree keys each directory by the hash of its contents, so writing
11573
+ * one file rewrites every ancestor up to the root. Here a directory's id is
11574
+ * random and permanent, so a file write touches exactly ONE record — its
11575
+ * parent — and renaming or moving a directory edits only the parent entry
11576
+ * while the whole subtree underneath stays byte-identical and untouched.
11577
+ *
11578
+ * ## Why a directory's key lives in its parent
11579
+ *
11580
+ * Only the root key is derived from the master seed. Every other directory has
11581
+ * a random key stored in its parent's entry, which chains capabilities
11582
+ * downward: handing someone `{id, key}` for `/apps/my-app` lets them walk that
11583
+ * subtree and nothing else, because no key can be derived upward. That is the
11584
+ * seam group-sharing hangs off later without re-encrypting the filesystem.
11585
+ */
11586
+ /** Personal-space KV key holding a directory record. */
11587
+ const dirKey = (id) => `vfs/d/${id}`;
11588
+ /** Personal-space KV key holding one file's prior versions. */
11589
+ const historyKey = (id) => `vfs/h/${id}`;
11590
+ /** The root directory's id is fixed — it is the one node reached without a parent. */
11591
+ const ROOT_ID = "root";
11592
+ /** HKDF label for the seed-derived root key. Sits alongside `muhkoo-storage-v1`. */
11593
+ const VFS_ROOT_INFO = "muhkoo-vfs-v1";
11594
+ /** Raised when the master seed is not held, so the root key cannot be derived. */
11595
+ class VfsLockedError extends Error {
11596
+ constructor() {
11597
+ // Named rather than generic because the caller's remedy is specific and
11598
+ // not guessable from a failed decrypt: re-authenticate to recover the
11599
+ // seed. A page reload alone loses it — it is memory-only, never stored.
11600
+ super("VFS is locked: the master seed is not in memory. Sign in again to unlock.");
11601
+ this.name = "VfsLockedError";
11602
+ }
11603
+ }
11604
+ /** Raised for a path that does not exist. */
11605
+ class VfsNotFoundError extends Error {
11606
+ constructor(path) {
11607
+ super(`VFS: ${path} does not exist`);
11608
+ this.name = "VfsNotFoundError";
11609
+ }
11610
+ }
11611
+ /** Raised when an operation would clobber or misuse an existing entry. */
11612
+ class VfsConflictError extends Error {
11613
+ constructor(message) {
11614
+ super(`VFS: ${message}`);
11615
+ this.name = "VfsConflictError";
11616
+ }
11617
+ }
11618
+
11619
+ /**
11620
+ * Sealing for VFS metadata records.
11621
+ *
11622
+ * Every directory and history record is AES-256-GCM encrypted before it leaves
11623
+ * the process, so the server holds ciphertext and never learns a filename, a
11624
+ * directory shape, or a file size. The manifests inside carry their own chunk
11625
+ * keys, which is why the metadata must be sealed too: a readable directory
11626
+ * record would hand over the keys to its files.
11627
+ */
11628
+ const AES_GCM_IV_BYTES = 12;
11629
+ const AES_KEY_BYTES = 32;
11630
+ function toBase64(bytes) {
11631
+ let s = "";
11632
+ for (const b of bytes)
11633
+ s += String.fromCharCode(b);
11634
+ return btoa(s);
11635
+ }
11636
+ function fromBase64(b64) {
11637
+ const s = atob(b64);
11638
+ const out = new Uint8Array(s.length);
11639
+ for (let i = 0; i < s.length; i++)
11640
+ out[i] = s.charCodeAt(i);
11641
+ return out;
11642
+ }
11643
+ /** A fresh random key for a newly created directory. */
11644
+ function newDirKey() {
11645
+ return randomBytes(AES_KEY_BYTES);
11646
+ }
11647
+ /**
11648
+ * A fresh random id.
11649
+ *
11650
+ * Ids are opaque and never derived from names or contents — a predictable id
11651
+ * would leak the directory structure through the KV key, which is the one part
11652
+ * of a record the server does see.
11653
+ */
11654
+ function newId() {
11655
+ return toBase64(randomBytes(16)).replace(/[+/=]/g, "").slice(0, 22);
11656
+ }
11657
+ /** The root key, derived from the master seed. The only key not stored in a parent. */
11658
+ /**
11659
+ * The key a project's repository objects are sealed with.
11660
+ *
11661
+ * Per project, not one key for everything: commits, trees and refs describe a
11662
+ * single project's history, and deriving per slug keeps that blast radius small
11663
+ * if a key is ever shared to collaborate on one project.
11664
+ */
11665
+ async function deriveRepoKey(seed, slug) {
11666
+ return deriveBitsHkdf(seed, `${VFS_ROOT_INFO}:vcs:${slug}`, AES_KEY_BYTES);
11667
+ }
11668
+ async function deriveRootKey(seed) {
11669
+ return deriveBitsHkdf(seed, VFS_ROOT_INFO, AES_KEY_BYTES);
11670
+ }
11671
+ async function seal(key, value) {
11672
+ const iv = randomBytes(AES_GCM_IV_BYTES);
11673
+ const plaintext = new TextEncoder().encode(JSON.stringify(value));
11674
+ const ct = await encryptAesGcm(key, iv, plaintext);
11675
+ return { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
11676
+ }
11677
+ /**
11678
+ * Open a sealed record. Returns null for anything that is not a well-formed
11679
+ * envelope, so a missing or corrupt record reads as "absent" rather than
11680
+ * throwing — the caller can then rebuild it. A WRONG KEY still throws, because
11681
+ * silently treating an undecryptable directory as empty would present the user
11682
+ * with an empty filesystem and then let a write overwrite the real one.
11683
+ */
11684
+ async function unseal(key, record) {
11685
+ if (!record || typeof record !== "object")
11686
+ return null;
11687
+ const r = record;
11688
+ if (typeof r.iv !== "string" || typeof r.ct !== "string")
11689
+ return null;
11690
+ const plaintext = await decryptAesGcm(key, fromBase64(r.iv), fromBase64(r.ct));
11691
+ return JSON.parse(new TextDecoder().decode(plaintext));
11692
+ }
11693
+
11694
+ /**
11695
+ * Absolute POSIX paths for the VFS.
11696
+ *
11697
+ * A path is a lookup route, not a stored value: nothing in the metadata records
11698
+ * a full path, only a name inside its parent directory. That is what makes
11699
+ * renaming or moving a directory a single-record edit instead of a rewrite of
11700
+ * every descendant.
11701
+ */
11702
+ /** Absolute, single leading slash, no `.` / `..`, no trailing slash. Root is `/`. */
11703
+ function normalizePath(input) {
11704
+ const out = [];
11705
+ for (const part of input.split("/")) {
11706
+ if (part === "" || part === ".")
11707
+ continue;
11708
+ if (part === "..")
11709
+ out.pop();
11710
+ else
11711
+ out.push(part);
11712
+ }
11713
+ return "/" + out.join("/");
11714
+ }
11715
+ /** Path split into its segments. `/` yields `[]`. */
11716
+ function segments(path) {
11717
+ return normalizePath(path).split("/").filter(Boolean);
11718
+ }
11719
+ /**
11720
+ * Resolve `path` against a working directory, POSIX-style.
11721
+ *
11722
+ * An absolute path ignores the cwd; a relative one hangs off it; `..` climbs
11723
+ * and stops at the root rather than escaping it. This is the single place
11724
+ * relativity is interpreted, so every VFS method behaves the same way — a
11725
+ * filesystem where `cat a.txt` and `list("a")` disagreed about what "here"
11726
+ * means would be worse than having no cwd at all.
11727
+ */
11728
+ function resolveFrom(cwd, path) {
11729
+ if (path.startsWith("/"))
11730
+ return normalizePath(path);
11731
+ return normalizePath(`${cwd}/${path}`);
11732
+ }
11733
+ function dirname(path) {
11734
+ const p = normalizePath(path);
11735
+ const i = p.lastIndexOf("/");
11736
+ return i <= 0 ? "/" : p.slice(0, i);
11737
+ }
11738
+ function basename(path) {
11739
+ const p = normalizePath(path);
11740
+ return p.slice(p.lastIndexOf("/") + 1);
11741
+ }
11742
+ function join(...parts) {
11743
+ return normalizePath(parts.join("/"));
11744
+ }
11745
+ /**
11746
+ * Is `path` inside `dir` (at any depth)? A directory is never "under" itself,
11747
+ * which is what makes it usable as the guard against moving a directory into
11748
+ * its own subtree.
11749
+ */
11750
+ function isUnder(path, dir) {
11751
+ const p = normalizePath(path);
11752
+ const d = normalizePath(dir);
11753
+ if (p === d)
11754
+ return false;
11755
+ return d === "/" ? true : p.startsWith(d + "/");
11756
+ }
11757
+ /**
11758
+ * Reject names that would make a path ambiguous or unrepresentable.
11759
+ *
11760
+ * `/` would forge a nesting level, and `.` / `..` would resolve to a different
11761
+ * directory on the next lookup — a file literally named `..` could never be
11762
+ * addressed again.
11763
+ */
11764
+ function assertValidName(name) {
11765
+ if (!name)
11766
+ throw new Error("VFS: name cannot be empty");
11767
+ if (name === "." || name === "..")
11768
+ throw new Error(`VFS: "${name}" is not a usable name`);
11769
+ if (name.includes("/"))
11770
+ throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
11771
+ }
11772
+ /**
11773
+ * MIME type for a filename.
11774
+ *
11775
+ * Stored with the content rather than guessed at read time, because whatever
11776
+ * eventually serves these bytes — a preview, a static host — sends this as the
11777
+ * `Content-Type`, and a browser will refuse to run a module or apply a
11778
+ * stylesheet labelled `application/octet-stream`.
11779
+ */
11780
+ function contentTypeFor(name) {
11781
+ const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
11782
+ switch (ext) {
11783
+ case "html":
11784
+ case "htm": return "text/html; charset=utf-8";
11785
+ case "css": return "text/css; charset=utf-8";
11786
+ case "js":
11787
+ case "mjs":
11788
+ case "jsx": return "text/javascript; charset=utf-8";
11789
+ case "ts":
11790
+ case "tsx": return "text/typescript; charset=utf-8";
11791
+ case "json": return "application/json; charset=utf-8";
11792
+ case "md": return "text/markdown; charset=utf-8";
11793
+ case "txt": return "text/plain; charset=utf-8";
11794
+ case "svg": return "image/svg+xml";
11795
+ case "png": return "image/png";
11796
+ case "jpg":
11797
+ case "jpeg": return "image/jpeg";
11798
+ case "gif": return "image/gif";
11799
+ case "webp": return "image/webp";
11800
+ case "avif": return "image/avif";
11801
+ case "ico": return "image/x-icon";
11802
+ case "woff2": return "font/woff2";
11803
+ case "woff": return "font/woff";
11804
+ case "wasm": return "application/wasm";
11805
+ case "map": return "application/json";
11806
+ default: return "application/octet-stream";
11807
+ }
11808
+ }
11809
+
11810
+ /**
11811
+ * Glob matching for VFS paths.
11812
+ *
11813
+ * A deliberately small dialect — `*`, `**`, `?`, and `{a,b}` — because these
11814
+ * cover what a project actually asks for (`**\/*.tsx`, `src/*.css`) and every
11815
+ * addition beyond them is a new way for a pattern to mean something the caller
11816
+ * did not expect.
11817
+ */
11818
+ /** `*` stops at a separator; `**` crosses them. That distinction is the whole point. */
11819
+ function globToRegExp(pattern) {
11820
+ let out = "";
11821
+ for (let i = 0; i < pattern.length; i++) {
11822
+ const c = pattern[i];
11823
+ if (c === "*") {
11824
+ if (pattern[i + 1] === "*") {
11825
+ // `**/` should also match zero directories, so `/**/x` finds `/x`.
11826
+ if (pattern[i + 2] === "/") {
11827
+ out += "(?:.*/)?";
11828
+ i += 2;
11829
+ }
11830
+ else {
11831
+ out += ".*";
11832
+ i += 1;
11833
+ }
11834
+ }
11835
+ else {
11836
+ out += "[^/]*";
11837
+ }
11838
+ }
11839
+ else if (c === "?") {
11840
+ out += "[^/]";
11841
+ }
11842
+ else if (c === "{") {
11843
+ const close = pattern.indexOf("}", i);
11844
+ if (close === -1) {
11845
+ out += "\\{";
11846
+ }
11847
+ else {
11848
+ const alts = pattern.slice(i + 1, close).split(",");
11849
+ out += `(?:${alts.map(escapeLiteral).join("|")})`;
11850
+ i = close;
11851
+ }
11852
+ }
11853
+ else {
11854
+ out += escapeLiteral(c);
11855
+ }
11856
+ }
11857
+ return new RegExp(`^${out}$`);
11858
+ }
11859
+ function escapeLiteral(s) {
11860
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11861
+ }
11862
+
11863
+ /**
11864
+ * `client.vfs` — a real filesystem over Muhkoo primitives.
11865
+ *
11866
+ * Metadata is a graph of encrypted directory records in the user's personal
11867
+ * space; file content is chunked, encrypted and stored in the global
11868
+ * content-addressed shard store, referenced by manifest. See `types.ts` for
11869
+ * why directories carry stable random ids and why each key lives in its parent.
11870
+ *
11871
+ * ## Consistency
11872
+ *
11873
+ * Every mutation is a read-modify-write of exactly ONE directory record, so a
11874
+ * write cannot leave the tree half-updated: either the parent names the new
11875
+ * child or it does not. Two tabs writing different directories never conflict;
11876
+ * two tabs writing the SAME directory resolve last-writer-wins at the personal
11877
+ * space, which carries an HLC per key. Orphaned records (a directory whose
11878
+ * parent entry lost the race) are unreachable rather than corrupting — they
11879
+ * cost storage until a future sweep, which is the trade this shape makes in
11880
+ * exchange for single-record writes.
11881
+ */
11882
+ const DEFAULT_HISTORY_LIMIT = 20;
11883
+ class VfsNamespace {
11884
+ deps;
11885
+ rootKey = null;
11886
+ /**
11887
+ * Decrypted directory records, by id.
11888
+ *
11889
+ * The file tree re-lists constantly while typing, and every miss is a
11890
+ * network round trip plus a decrypt. Invalidated precisely on write rather
11891
+ * than by TTL — a stale directory listing shows files that are not there.
11892
+ */
11893
+ cache = new Map();
11894
+ historyLimit;
11895
+ constructor(deps) {
11896
+ this.deps = deps;
11897
+ this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
11898
+ }
11899
+ /**
11900
+ * The working directory relative paths resolve against.
11901
+ *
11902
+ * Kept here rather than left to each caller so that a CLI, a script and the
11903
+ * SDK all agree on what "here" means — the alternative is every consumer
11904
+ * re-implementing `cd`, and disagreeing about `..`.
11905
+ */
11906
+ _cwd = "/";
11907
+ /** The current working directory. Always absolute. */
11908
+ get cwd() {
11909
+ return this._cwd;
11910
+ }
11911
+ /**
11912
+ * Change the working directory.
11913
+ *
11914
+ * Verifies the target is a real directory, so a typo fails here — where the
11915
+ * message can name the path — rather than turning every later relative path
11916
+ * into a confusing "does not exist".
11917
+ */
11918
+ async cd(path) {
11919
+ const target = this.resolve(path);
11920
+ if (target !== "/") {
11921
+ const found = await this.find(target);
11922
+ if (!found)
11923
+ throw new VfsNotFoundError(target);
11924
+ if (found.entry.kind !== "dir")
11925
+ throw new VfsConflictError(`${target} is not a directory`);
11926
+ }
11927
+ this._cwd = target;
11928
+ return target;
11929
+ }
11930
+ /** Resolve a path against the working directory. Absolute paths pass through. */
11931
+ resolve(path) {
11932
+ return resolveFrom(this._cwd, path);
11933
+ }
11934
+ /** True when the master seed is held, so the root key can be derived. */
11935
+ get unlocked() {
11936
+ return this.deps.seed() !== null;
11937
+ }
11938
+ /**
11939
+ * Drop every cached record.
11940
+ *
11941
+ * Call after signing in as someone else — the cache is keyed by directory
11942
+ * id with no notion of whose filesystem it came from, and serving one
11943
+ * identity's directories to another would be the worst possible bug here.
11944
+ */
11945
+ clearCache() {
11946
+ this.cache.clear();
11947
+ this.rootKey = null;
11948
+ this._cwd = "/"; // a different identity's tree has different directories
11949
+ }
11950
+ /**
11951
+ * Call `handler` when this filesystem changes somewhere else — another tab,
11952
+ * another machine, the CLI.
11953
+ *
11954
+ * Deliberately a NOTIFICATION, not a diff: the frame names a record id, and
11955
+ * a record id is not a path (paths are resolved by walking, and the walk is
11956
+ * what the change may have invalidated). So the cache is dropped and the
11957
+ * caller re-reads whatever it is actually showing. Trying to translate a
11958
+ * record id back into a path would mean maintaining a reverse index that
11959
+ * could itself go stale.
11960
+ *
11961
+ * Returns an unsubscribe function. A runtime with no socket (a CLI) gets a
11962
+ * no-op rather than an error — watching is an optimisation, never required
11963
+ * for correctness.
11964
+ */
11965
+ watch(handler) {
11966
+ if (!this.deps.subscribe)
11967
+ return () => { };
11968
+ return this.deps.subscribe((raw) => {
11969
+ let frame;
11970
+ try {
11971
+ frame = typeof raw === "string" ? JSON.parse(raw) : raw;
11972
+ }
11973
+ catch {
11974
+ return;
11975
+ }
11976
+ if (frame?._t !== "change" || typeof frame.key !== "string")
11977
+ return;
11978
+ if (!frame.key.startsWith("vfs/"))
11979
+ return; // someone else's data on the shared feed
11980
+ // Drop only the record that changed.
11981
+ //
11982
+ // Clearing everything looked cheap and is not: our own writes echo
11983
+ // back, so a sync that writes a few hundred files emptied the cache
11984
+ // a few hundred times, and every path resolution in flight went back
11985
+ // to the network for the root and every directory below it. The key
11986
+ // names the record, so there is no need to guess.
11987
+ const id = frame.key.startsWith("vfs/d/") ? frame.key.slice("vfs/d/".length) : null;
11988
+ if (id)
11989
+ this.cache.delete(id);
11990
+ else
11991
+ this.cache.clear(); // a shape we do not recognise: be safe
11992
+ handler();
11993
+ });
11994
+ }
11995
+ // ---- reads --------------------------------------------------------------
11996
+ async exists(path) {
11997
+ return (await this.find(path)) !== null;
11998
+ }
11999
+ async stat(path) {
12000
+ const found = await this.find(path);
12001
+ if (!found)
12002
+ throw new VfsNotFoundError(this.resolve(path));
12003
+ return toStat(this.resolve(path), found.name, found.entry);
12004
+ }
12005
+ /** Immediate children of a directory. One record fetch. */
12006
+ async list(path = ".") {
12007
+ const dir = await this.openDir(path);
12008
+ const base = this.resolve(path);
12009
+ return Object.entries(dir.node.entries)
12010
+ .map(([name, entry]) => toStat(join(base, name), name, entry))
12011
+ .sort(byDirsThenName);
12012
+ }
12013
+ /**
12014
+ * Every file path under a prefix, depth-first.
12015
+ *
12016
+ * Deliberately separate from `list`: this walks the whole subtree, which is
12017
+ * one fetch per directory. Fine for a project, wrong as a default.
12018
+ */
12019
+ async walk(path = ".") {
12020
+ const out = [];
12021
+ const visit = async (dirPath) => {
12022
+ for (const entry of await this.list(dirPath)) {
12023
+ if (entry.kind === "dir")
12024
+ await visit(entry.path);
12025
+ else
12026
+ out.push(entry.path);
12027
+ }
12028
+ };
12029
+ await visit(this.resolve(path));
12030
+ return out;
12031
+ }
12032
+ async readFile(path) {
12033
+ const entry = await this.expectFile(path);
12034
+ return this.deps.content.read(entry.manifest);
12035
+ }
12036
+ async readText(path) {
12037
+ return new TextDecoder().decode(await this.readFile(path));
12038
+ }
12039
+ /**
12040
+ * A file's manifest — the handle itself, without reading the bytes.
12041
+ *
12042
+ * Version control records manifests rather than content, so a commit costs
12043
+ * a hash and a reference, not a copy.
12044
+ */
12045
+ async statManifest(path) {
12046
+ const entry = await this.expectFile(path);
12047
+ return { manifest: entry.manifest, size: entry.size };
12048
+ }
12049
+ /**
12050
+ * Read content by its manifest, wherever it came from.
12051
+ *
12052
+ * A merge needs the COMMON ANCESTOR's version of a file, which by definition
12053
+ * is not in the working tree any more. The manifest is the whole capability,
12054
+ * so no path lookup is involved.
12055
+ */
12056
+ async readByManifest(manifest) {
12057
+ return this.deps.content.read(manifest);
12058
+ }
12059
+ /**
12060
+ * Pin content that something other than the working tree depends on.
12061
+ *
12062
+ * A commit references a manifest, so it is an OWNER of that content — without
12063
+ * a reference of its own, deleting the file from the working tree would free
12064
+ * shards the history still needs, and checking out an older commit would find
12065
+ * the content gone. (It did, in test.)
12066
+ */
12067
+ async retainManifest(manifest) {
12068
+ await this.deps.content.retain(manifest);
12069
+ }
12070
+ /**
12071
+ * Point a path at content that already exists.
12072
+ *
12073
+ * This is how a checkout restores a file: the bytes are in the shard store
12074
+ * already, so nothing is uploaded or downloaded — only the directory record
12075
+ * changes. Takes a reference, because a second path now depends on those
12076
+ * shards.
12077
+ */
12078
+ async writeManifest(path, manifest, size) {
12079
+ const target = this.resolve(path);
12080
+ const name = basename(target);
12081
+ assertValidName(name);
12082
+ await this.mkdir(dirname(target), { recursive: true });
12083
+ const parent = await this.openDir(dirname(target));
12084
+ const prior = parent.node.entries[name];
12085
+ if (prior?.kind === "dir")
12086
+ throw new VfsConflictError(`${target} is a directory`);
12087
+ await this.deps.content.retain(manifest);
12088
+ const entry = {
12089
+ kind: "file",
12090
+ id: prior?.id ?? newId(),
12091
+ manifest,
12092
+ size,
12093
+ mtime: Date.now(),
12094
+ versions: prior?.versions ?? 0,
12095
+ };
12096
+ await this.mutate(parent, (node) => {
12097
+ node.entries[name] = entry;
12098
+ });
12099
+ return toStat(target, name, entry);
12100
+ }
12101
+ // ---- writes -------------------------------------------------------------
12102
+ /**
12103
+ * Create a directory. `recursive` creates missing parents and tolerates an
12104
+ * existing target, matching `mkdir -p`.
12105
+ */
12106
+ async mkdir(path, opts = {}) {
12107
+ const target = this.resolve(path);
12108
+ if (target === "/")
12109
+ return;
12110
+ const parts = segments(target);
12111
+ let current = "/";
12112
+ for (let i = 0; i < parts.length; i++) {
12113
+ const name = parts[i];
12114
+ const isLast = i === parts.length - 1;
12115
+ const next = join(current, name);
12116
+ const existing = (await this.openDir(current)).node.entries[name];
12117
+ if (existing) {
12118
+ if (existing.kind === "file")
12119
+ throw new VfsConflictError(`${next} is a file`);
12120
+ if (isLast && !opts.recursive)
12121
+ throw new VfsConflictError(`${next} already exists`);
12122
+ }
12123
+ else {
12124
+ if (!isLast && !opts.recursive)
12125
+ throw new VfsNotFoundError(current);
12126
+ await this.createDir(current, name);
12127
+ }
12128
+ current = next;
12129
+ }
12130
+ }
12131
+ /**
12132
+ * Write a file, creating parent directories as needed.
12133
+ *
12134
+ * The previous version is pushed onto the file's history record before the
12135
+ * directory entry is replaced, so an interrupted write can lose the new
12136
+ * version but never the old one.
12137
+ */
12138
+ async writeFile(path, data, opts = {}) {
12139
+ const target = this.resolve(path);
12140
+ const name = basename(target);
12141
+ assertValidName(name);
12142
+ const parentPath = dirname(target);
12143
+ await this.mkdir(parentPath, { recursive: true });
12144
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
12145
+ const { manifest, size } = await this.deps.content.write(bytes, {
12146
+ name,
12147
+ type: opts.type ?? contentTypeFor(name),
12148
+ });
12149
+ const parent = await this.openDir(parentPath);
12150
+ const prior = parent.node.entries[name];
12151
+ if (prior?.kind === "dir")
12152
+ throw new VfsConflictError(`${target} is a directory`);
12153
+ const id = prior?.id ?? newId();
12154
+ let versions = prior?.versions ?? 0;
12155
+ if (prior)
12156
+ versions = await this.pushHistory(parent, id, prior);
12157
+ const entry = { kind: "file", id, manifest, size, mtime: Date.now(), versions };
12158
+ await this.mutate(parent, (node) => {
12159
+ node.entries[name] = entry;
12160
+ });
12161
+ return toStat(target, name, entry);
12162
+ }
12163
+ /**
12164
+ * Delete a file, or a directory when `recursive`.
12165
+ *
12166
+ * `keepContent` removes the entry WITHOUT releasing its shards. A checkout
12167
+ * needs that: the file is leaving the working tree, but commits still
12168
+ * reference the same manifest, and releasing would free content history
12169
+ * depends on.
12170
+ */
12171
+ async delete(path, opts = {}) {
12172
+ const target = this.resolve(path);
12173
+ if (target === "/")
12174
+ throw new VfsConflictError("the root directory cannot be deleted");
12175
+ const name = basename(target);
12176
+ const parent = await this.openDir(dirname(target));
12177
+ const entry = parent.node.entries[name];
12178
+ if (!entry)
12179
+ throw new VfsNotFoundError(target);
12180
+ if (entry.kind === "dir") {
12181
+ const child = await this.openDirById(entry.id, fromBase64(entry.key));
12182
+ const empty = Object.keys(child.node.entries).length === 0;
12183
+ if (!empty && !opts.recursive)
12184
+ throw new VfsConflictError(`${target} is not empty`);
12185
+ await this.purge(child);
12186
+ }
12187
+ else if (opts.keepContent) {
12188
+ await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
12189
+ }
12190
+ else {
12191
+ await this.releaseFile(parent, entry);
12192
+ }
12193
+ await this.mutate(parent, (node) => {
12194
+ delete node.entries[name];
12195
+ });
12196
+ }
12197
+ /**
12198
+ * Move or rename. Works across directories, which is the same operation:
12199
+ * detach the entry from one parent and attach it to another.
12200
+ *
12201
+ * A directory move is O(1) regardless of how much is inside it — the
12202
+ * subtree records are not touched at all, because nothing in them records
12203
+ * its own path.
12204
+ */
12205
+ async rename(from, to) {
12206
+ const src = this.resolve(from);
12207
+ const dst = this.resolve(to);
12208
+ if (src === dst)
12209
+ return;
12210
+ if (src === "/")
12211
+ throw new VfsConflictError("the root directory cannot be moved");
12212
+ if (isUnder(dst, src))
12213
+ throw new VfsConflictError(`cannot move ${src} into itself`);
12214
+ const dstName = basename(dst);
12215
+ assertValidName(dstName);
12216
+ const srcParent = await this.openDir(dirname(src));
12217
+ const entry = srcParent.node.entries[basename(src)];
12218
+ if (!entry)
12219
+ throw new VfsNotFoundError(src);
12220
+ await this.mkdir(dirname(dst), { recursive: true });
12221
+ const dstParent = await this.openDir(dirname(dst));
12222
+ if (dstParent.node.entries[dstName])
12223
+ throw new VfsConflictError(`${dst} already exists`);
12224
+ // Attach before detaching. If the second write fails the entry is
12225
+ // reachable from both parents — visible and fixable. The other order
12226
+ // would lose the subtree entirely.
12227
+ const moved = { ...entry, mtime: Date.now() };
12228
+ await this.mutate(dstParent, (node) => {
12229
+ node.entries[dstName] = moved;
12230
+ });
12231
+ // Re-open: same-parent moves mutated the record we are about to edit.
12232
+ const detachFrom = await this.openDir(dirname(src));
12233
+ await this.mutate(detachFrom, (node) => {
12234
+ delete node.entries[basename(src)];
12235
+ });
12236
+ }
12237
+ /**
12238
+ * Copy a file or a directory tree.
12239
+ *
12240
+ * Copying a FILE moves no bytes: content is immutable and content-addressed,
12241
+ * so the new entry simply points at the same manifest. A 200 MB asset
12242
+ * duplicates in one small metadata write, and the shard store already holds
12243
+ * exactly one copy of it.
12244
+ *
12245
+ * The copy gets a NEW file id, so it does not inherit or share history —
12246
+ * two paths sharing an id would mean saving one silently pushed a version
12247
+ * onto the other.
12248
+ */
12249
+ async copy(from, to) {
12250
+ const src = this.resolve(from);
12251
+ const dst = this.resolve(to);
12252
+ if (src === "/")
12253
+ throw new VfsConflictError("the root directory cannot be copied");
12254
+ if (src === dst)
12255
+ throw new VfsConflictError(`${dst} already exists`);
12256
+ if (isUnder(dst, src))
12257
+ throw new VfsConflictError(`cannot copy ${src} into itself`);
12258
+ const found = await this.find(src);
12259
+ if (!found)
12260
+ throw new VfsNotFoundError(src);
12261
+ const dstName = basename(dst);
12262
+ assertValidName(dstName);
12263
+ await this.mkdir(dirname(dst), { recursive: true });
12264
+ const dstParent = await this.openDir(dirname(dst));
12265
+ if (dstParent.node.entries[dstName])
12266
+ throw new VfsConflictError(`${dst} already exists`);
12267
+ const copied = await this.cloneEntry(found.entry);
12268
+ await this.mutate(dstParent, (node) => {
12269
+ node.entries[dstName] = copied;
12270
+ });
12271
+ return toStat(dst, dstName, copied);
12272
+ }
12273
+ /**
12274
+ * Paths matching a glob, relative to the whole filesystem.
12275
+ *
12276
+ * Matching happens on the client over a walk of the tree, so the cost is one
12277
+ * record fetch per directory — cheap for a project, and worth knowing before
12278
+ * pointing it at the root of a large filesystem. Use `walk` when the prefix
12279
+ * already narrows it.
12280
+ */
12281
+ async glob(pattern, opts = {}) {
12282
+ const re = globToRegExp(this.resolve(pattern));
12283
+ return (await this.walk(opts.from ?? ".")).filter((path) => re.test(path));
12284
+ }
12285
+ /**
12286
+ * Delete metadata records no longer reachable from the root.
12287
+ *
12288
+ * These accumulate because every write touches exactly one record: if two
12289
+ * tabs edit the same directory, last-writer-wins at the personal space can
12290
+ * leave a child whose parent entry lost the race. Unreachable is harmless —
12291
+ * it is invisible and undecryptable-by-nobody — but it costs storage, so
12292
+ * this reclaims it.
12293
+ *
12294
+ * Deliberately explicit, never automatic: a sweep racing another tab's
12295
+ * in-flight write could delete a record that is about to be referenced.
12296
+ * Run it when the filesystem is idle.
12297
+ */
12298
+ async sweep(opts = {}) {
12299
+ const root = await this.root();
12300
+ const reachable = new Set();
12301
+ const visit = async (dir) => {
12302
+ reachable.add(dirKey(dir.id));
12303
+ for (const entry of Object.values(dir.node.entries)) {
12304
+ if (entry.kind === "dir") {
12305
+ await visit(await this.openDirById(entry.id, fromBase64(entry.key)));
12306
+ }
12307
+ else {
12308
+ reachable.add(historyKey(entry.id));
12309
+ }
12310
+ }
12311
+ };
12312
+ await visit(root);
12313
+ // Only ever consider OUR records: the personal space is shared with
12314
+ // chat keys, space keys and the legacy file mirror, and a sweep that
12315
+ // reached beyond its own namespace would be a data-loss bug.
12316
+ const mine = (await this.deps.store.list()).filter((k) => /^vfs\/[dh]\//.test(k));
12317
+ const orphans = mine.filter((k) => !reachable.has(k));
12318
+ // A root that read as EMPTY while orphans exist is the shape of a failed
12319
+ // load, not of a fresh filesystem — deleting here would wipe a tree we
12320
+ // merely failed to reach. Refuse unless the caller insists.
12321
+ const rootEmpty = Object.keys(root.node.entries).length === 0;
12322
+ if (rootEmpty && orphans.length && !opts.force) {
12323
+ throw new VfsConflictError(`refusing to sweep ${orphans.length} record(s) with an empty root — ` +
12324
+ "this looks like a failed load rather than an empty filesystem; pass { force: true } if it really is empty");
12325
+ }
12326
+ for (const key of orphans)
12327
+ await this.deps.store.delete(key).catch(() => { });
12328
+ return { removed: orphans };
12329
+ }
12330
+ // ---- history ------------------------------------------------------------
12331
+ /** Prior versions of a file, newest first. One record fetch. */
12332
+ async history(path) {
12333
+ const entry = await this.expectFile(path);
12334
+ const record = await this.readHistory(await this.openDir(dirname(path)), entry.id);
12335
+ return record.versions.map(({ size, mtime }) => ({ size, mtime }));
12336
+ }
12337
+ /**
12338
+ * Restore a prior version, where 0 is the most recent one.
12339
+ *
12340
+ * The current version is pushed onto history first, so restoring is itself
12341
+ * undoable rather than a one-way door.
12342
+ */
12343
+ async restore(path, index = 0) {
12344
+ const target = this.resolve(path);
12345
+ const name = basename(target);
12346
+ const parent = await this.openDir(dirname(target));
12347
+ const entry = parent.node.entries[name];
12348
+ if (!entry || entry.kind !== "file")
12349
+ throw new VfsNotFoundError(target);
12350
+ const record = await this.readHistory(parent, entry.id);
12351
+ const version = record.versions[index];
12352
+ if (!version)
12353
+ throw new VfsNotFoundError(`${target} version ${index}`);
12354
+ const versions = await this.pushHistory(parent, entry.id, entry);
12355
+ const restored = {
12356
+ kind: "file",
12357
+ id: entry.id,
12358
+ manifest: version.manifest,
12359
+ size: version.size,
12360
+ mtime: Date.now(),
12361
+ versions,
12362
+ };
12363
+ await this.mutate(parent, (node) => {
12364
+ node.entries[name] = restored;
12365
+ });
12366
+ return toStat(target, name, restored);
12367
+ }
12368
+ // ---- internals ----------------------------------------------------------
12369
+ async expectFile(path) {
12370
+ const found = await this.find(path);
12371
+ if (!found)
12372
+ throw new VfsNotFoundError(this.resolve(path));
12373
+ if (found.entry.kind !== "file")
12374
+ throw new VfsConflictError(`${this.resolve(path)} is a directory`);
12375
+ return found.entry;
12376
+ }
12377
+ /** Resolve a path to its entry, or null. Root has no entry of its own. */
12378
+ async find(path) {
12379
+ const target = this.resolve(path);
12380
+ if (target === "/")
12381
+ return null;
12382
+ const parent = await this.openDirOrNull(dirname(target));
12383
+ if (!parent)
12384
+ return null;
12385
+ const name = basename(target);
12386
+ const entry = parent.node.entries[name];
12387
+ return entry ? { name, entry } : null;
12388
+ }
12389
+ async root() {
12390
+ const seed = this.deps.seed();
12391
+ if (!seed)
12392
+ throw new VfsLockedError();
12393
+ if (!this.rootKey)
12394
+ this.rootKey = await deriveRootKey(seed);
12395
+ return this.openDirById(ROOT_ID, this.rootKey);
12396
+ }
12397
+ /** Open a directory by path, creating nothing. Throws if it is missing. */
12398
+ async openDir(path) {
12399
+ const dir = await this.openDirOrNull(path);
12400
+ if (!dir)
12401
+ throw new VfsNotFoundError(this.resolve(path));
12402
+ return dir;
12403
+ }
12404
+ async openDirOrNull(path) {
12405
+ let dir = await this.root();
12406
+ for (const name of segments(this.resolve(path))) {
12407
+ const entry = dir.node.entries[name];
12408
+ if (!entry || entry.kind !== "dir")
12409
+ return null;
12410
+ dir = await this.openDirById(entry.id, fromBase64(entry.key));
12411
+ }
12412
+ return dir;
12413
+ }
12414
+ openDirById(id, key) {
12415
+ const hit = this.cache.get(id);
12416
+ if (hit)
12417
+ return hit;
12418
+ // The PROMISE goes in the cache, not the resolved value.
12419
+ //
12420
+ // Reading a project means resolving hundreds of paths that share the
12421
+ // same handful of directories. Caching only the settled value leaves
12422
+ // every concurrent reader to miss and fire its own request for the same
12423
+ // record — which is how loading one project turned into thousands of
12424
+ // identical `vfs/d/<id>` fetches, most of them in flight at once.
12425
+ const pending = (async () => {
12426
+ const raw = await this.deps.store.get(dirKey(id));
12427
+ // An absent record is an EMPTY directory, not an error: the root
12428
+ // exists before anything has been written to it, and a directory is
12429
+ // created by its parent naming it rather than by its own record
12430
+ // appearing.
12431
+ const node = (await unseal(key, raw)) ?? { v: 1, entries: {}, mtime: Date.now() };
12432
+ return { id, key, node };
12433
+ })().catch((err) => {
12434
+ // A failed read must not be remembered, or one blip poisons the
12435
+ // directory for the life of the session.
12436
+ if (this.cache.get(id) === pending)
12437
+ this.cache.delete(id);
12438
+ throw err;
12439
+ });
12440
+ this.cache.set(id, pending);
12441
+ return pending;
12442
+ }
12443
+ /** Apply an edit to a directory record and persist it. */
12444
+ async mutate(dir, edit) {
12445
+ edit(dir.node);
12446
+ dir.node.mtime = Date.now();
12447
+ await this.deps.store.put(dirKey(dir.id), await seal(dir.key, dir.node));
12448
+ this.cache.set(dir.id, Promise.resolve(dir));
12449
+ }
12450
+ async createDir(parentPath, name) {
12451
+ assertValidName(name);
12452
+ const parent = await this.openDir(parentPath);
12453
+ const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
12454
+ await this.mutate(parent, (node) => {
12455
+ node.entries[name] = entry;
12456
+ });
12457
+ }
12458
+ /** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
12459
+ async purge(dir) {
12460
+ for (const entry of Object.values(dir.node.entries)) {
12461
+ if (entry.kind === "dir") {
12462
+ await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
12463
+ }
12464
+ else {
12465
+ await this.releaseFile(dir, entry);
12466
+ }
12467
+ }
12468
+ this.cache.delete(dir.id);
12469
+ await this.deps.store.delete(dirKey(dir.id)).catch(() => { });
12470
+ }
12471
+ /**
12472
+ * Deep-copy an entry. Files reuse their manifest (no bytes move); each
12473
+ * directory gets a fresh id and key, so the copy is independent of the
12474
+ * original — writing into one must never appear in the other.
12475
+ */
12476
+ async cloneEntry(entry) {
12477
+ if (entry.kind === "file") {
12478
+ // Same bytes, second owner: take a reference so deleting either one
12479
+ // leaves the other readable.
12480
+ await this.deps.content.retain(entry.manifest);
12481
+ return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
12482
+ }
12483
+ const source = await this.openDirById(entry.id, fromBase64(entry.key));
12484
+ const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
12485
+ const node = { v: 1, entries: {}, mtime: Date.now() };
12486
+ for (const [name, child] of Object.entries(source.node.entries)) {
12487
+ node.entries[name] = await this.cloneEntry(child);
12488
+ }
12489
+ const open = { id: clone.id, key: fromBase64(clone.key), node };
12490
+ await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
12491
+ this.cache.set(open.id, Promise.resolve(open));
12492
+ return clone;
12493
+ }
12494
+ /**
12495
+ * Let go of a file's bytes: the current manifest and every retained
12496
+ * version, then the history record itself.
12497
+ */
12498
+ async releaseFile(parent, entry) {
12499
+ const record = await this.readHistory(parent, entry.id).catch(() => ({ v: 1, versions: [] }));
12500
+ const manifests = [entry.manifest, ...record.versions.map((v) => v.manifest)];
12501
+ for (const manifest of manifests)
12502
+ await this.deps.content.release(manifest).catch(() => { });
12503
+ await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
12504
+ }
12505
+ async readHistory(parent, fileId) {
12506
+ const raw = await this.deps.store.get(historyKey(fileId));
12507
+ return (await unseal(parent.key, raw)) ?? { v: 1, versions: [] };
12508
+ }
12509
+ /** Push `entry` onto its history record. Returns the new version count. */
12510
+ async pushHistory(parent, fileId, entry) {
12511
+ const record = await this.readHistory(parent, fileId);
12512
+ record.versions.unshift({ manifest: entry.manifest, size: entry.size, mtime: entry.mtime });
12513
+ // Bounded, because this is a single stored value. Unchanged chunks are
12514
+ // deduped by the content-addressed shard store, so the cost of keeping
12515
+ // versions is this record's own growth — which is what the cap bounds.
12516
+ const evicted = record.versions.slice(this.historyLimit);
12517
+ record.versions = record.versions.slice(0, this.historyLimit);
12518
+ // A version that falls off the end is unreachable — release its shards
12519
+ // or the cap bounds the record's size while the storage grows forever.
12520
+ for (const v of evicted)
12521
+ await this.deps.content.release(v.manifest).catch(() => { });
12522
+ await this.deps.store.put(historyKey(fileId), await seal(parent.key, record));
12523
+ return record.versions.length;
12524
+ }
12525
+ }
12526
+ function toStat(path, name, entry) {
12527
+ return {
12528
+ path,
12529
+ name,
12530
+ kind: entry.kind,
12531
+ size: entry.kind === "file" ? entry.size : 0,
12532
+ mtime: entry.mtime,
12533
+ versions: entry.kind === "file" ? (entry.versions ?? 0) : 0,
12534
+ };
12535
+ }
12536
+ /** Directories first, then alphabetical — what every file explorer does. */
12537
+ function byDirsThenName(a, b) {
12538
+ if (a.kind !== b.kind)
12539
+ return a.kind === "dir" ? -1 : 1;
12540
+ return a.name.localeCompare(b.name);
12541
+ }
12542
+
12543
+ /**
12544
+ * Content addressing for repository objects.
12545
+ *
12546
+ * The hash must depend only on MEANING, never on serialisation accidents — two
12547
+ * clients that build the same tree have to agree on its hash, or history forks
12548
+ * for no reason. So keys are sorted and the encoding is fixed here rather than
12549
+ * left to `JSON.stringify`'s insertion order.
12550
+ */
12551
+ /** Canonical JSON: object keys sorted, recursively. */
12552
+ function canonical(value) {
12553
+ if (value === null || typeof value !== "object")
12554
+ return JSON.stringify(value) ?? "null";
12555
+ if (Array.isArray(value))
12556
+ return `[${value.map(canonical).join(",")}]`;
12557
+ const entries = Object.entries(value)
12558
+ .filter(([, v]) => v !== undefined)
12559
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
12560
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
12561
+ }
12562
+ /** SHA-256 of an object's canonical form, hex. */
12563
+ async function hashObject(value) {
12564
+ const bytes = new TextEncoder().encode(canonical(value));
12565
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
12566
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
12567
+ }
12568
+
12569
+ /**
12570
+ * Three-way line merge.
12571
+ *
12572
+ * Given a common ancestor and two descendants, produce the combined text — or
12573
+ * report the regions where both sides changed the same lines differently.
12574
+ *
12575
+ * The server cannot do any of this: it holds ciphertext and cannot compare
12576
+ * versions, so merging is entirely a client concern and has to be exact here.
12577
+ */
12578
+ /**
12579
+ * Longest common subsequence, as a list of matched index pairs.
12580
+ *
12581
+ * Classic dynamic programming. Fine for source files; a file with hundreds of
12582
+ * thousands of lines would want a smarter algorithm, and would also be a strange
12583
+ * thing to merge.
12584
+ */
12585
+ function lcs(a, b) {
12586
+ const n = a.length;
12587
+ const m = b.length;
12588
+ const table = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
12589
+ for (let i = n - 1; i >= 0; i--) {
12590
+ for (let j = m - 1; j >= 0; j--) {
12591
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
12592
+ }
12593
+ }
12594
+ const pairs = [];
12595
+ let i = 0;
12596
+ let j = 0;
12597
+ while (i < n && j < m) {
12598
+ if (a[i] === b[j]) {
12599
+ pairs.push([i, j]);
12600
+ i++;
12601
+ j++;
12602
+ }
12603
+ else if (table[i + 1][j] >= table[i][j + 1])
12604
+ i++;
12605
+ else
12606
+ j++;
12607
+ }
12608
+ return pairs;
12609
+ }
12610
+ /**
12611
+ * Lines of `base` that BOTH sides left untouched.
12612
+ *
12613
+ * These are the anchors the merge is built around: between two consecutive
12614
+ * anchors, each side has some (possibly empty) replacement, and comparing those
12615
+ * replacements is the entire decision.
12616
+ */
12617
+ function stableAnchors(base, ours, theirs) {
12618
+ const ourMatch = new Map(lcs(base, ours));
12619
+ const theirMatch = new Map(lcs(base, theirs));
12620
+ const anchors = [];
12621
+ for (let b = 0; b < base.length; b++) {
12622
+ const o = ourMatch.get(b);
12623
+ const t = theirMatch.get(b);
12624
+ if (o !== undefined && t !== undefined)
12625
+ anchors.push([b, o, t]);
12626
+ }
12627
+ return anchors;
12628
+ }
12629
+ const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
12630
+ /**
12631
+ * Merge `ours` and `theirs` over their common ancestor `base`.
12632
+ *
12633
+ * A region where only one side changed takes that side. A region where both
12634
+ * made the SAME change takes it once — two people fixing the same typo is not a
12635
+ * conflict. Only genuinely divergent edits conflict.
12636
+ */
12637
+ function merge3(base, ours, theirs) {
12638
+ const anchors = stableAnchors(base, ours, theirs);
12639
+ const out = [];
12640
+ const conflicts = [];
12641
+ let b = 0;
12642
+ let o = 0;
12643
+ let t = 0;
12644
+ const settle = (ourSlice, theirSlice, baseSlice) => {
12645
+ if (same(ourSlice, theirSlice)) {
12646
+ out.push(...ourSlice); // identical edits, or no edit
12647
+ }
12648
+ else if (same(ourSlice, baseSlice)) {
12649
+ out.push(...theirSlice); // only they changed it
12650
+ }
12651
+ else if (same(theirSlice, baseSlice)) {
12652
+ out.push(...ourSlice); // only we changed it
12653
+ }
12654
+ else {
12655
+ conflicts.push({ line: out.length + 1, ours: ourSlice, theirs: theirSlice });
12656
+ out.push(...ourSlice); // markers are added by the caller
12657
+ }
12658
+ };
12659
+ for (const [ab, ao, at] of anchors) {
12660
+ settle(ours.slice(o, ao), theirs.slice(t, at), base.slice(b, ab));
12661
+ out.push(base[ab]);
12662
+ b = ab + 1;
12663
+ o = ao + 1;
12664
+ t = at + 1;
12665
+ }
12666
+ settle(ours.slice(o), theirs.slice(t), base.slice(b));
12667
+ return { lines: out, conflicts };
12668
+ }
12669
+ /**
12670
+ * Merge to text, writing conflict markers where the sides diverged.
12671
+ *
12672
+ * Markers rather than a bespoke format: they are what every developer already
12673
+ * knows how to resolve, and resolving becomes ordinary editing.
12674
+ */
12675
+ function merge3Text(base, ours, theirs, labels = { ours: "ours", theirs: "theirs" }) {
12676
+ const split = (s) => (s === "" ? [] : s.split("\n"));
12677
+ const anchors = stableAnchors(split(base), split(ours), split(theirs));
12678
+ const B = split(base);
12679
+ const O = split(ours);
12680
+ const T = split(theirs);
12681
+ const out = [];
12682
+ let conflicted = false;
12683
+ let b = 0;
12684
+ let o = 0;
12685
+ let t = 0;
12686
+ const settle = (ourSlice, theirSlice, baseSlice) => {
12687
+ if (same(ourSlice, theirSlice))
12688
+ out.push(...ourSlice);
12689
+ else if (same(ourSlice, baseSlice))
12690
+ out.push(...theirSlice);
12691
+ else if (same(theirSlice, baseSlice))
12692
+ out.push(...ourSlice);
12693
+ else {
12694
+ conflicted = true;
12695
+ out.push(`<<<<<<< ${labels.ours}`, ...ourSlice, "=======", ...theirSlice, `>>>>>>> ${labels.theirs}`);
12696
+ }
12697
+ };
12698
+ for (const [ab, ao, at] of anchors) {
12699
+ settle(O.slice(o, ao), T.slice(t, at), B.slice(b, ab));
12700
+ out.push(B[ab]);
12701
+ b = ab + 1;
12702
+ o = ao + 1;
12703
+ t = at + 1;
12704
+ }
12705
+ settle(O.slice(o), T.slice(t), B.slice(b));
12706
+ return { text: out.join("\n"), conflicted };
12707
+ }
12708
+
12709
+ /** Storage keys. Namespaced per project so repositories cannot collide. */
12710
+ const objectKey = (slug, hash) => `vcs/${slug}/obj/${hash}`;
12711
+ const refKey = (slug, name) => `vcs/${slug}/refs/${name}`;
12712
+ const headKey = (slug) => `vcs/${slug}/HEAD`;
12713
+ const refsIndexKey = (slug) => `vcs/${slug}/refs`;
12714
+ /** An in-progress merge: what to make the second parent when it is committed. */
12715
+ const mergeKey = (slug) => `vcs/${slug}/MERGE`;
12716
+ const DEFAULT_BRANCH = "main";
12717
+ class VcsError extends Error {
12718
+ constructor(message) {
12719
+ super(message);
12720
+ this.name = "VcsError";
12721
+ }
12722
+ }
12723
+
12724
+ /** A project's repository. One per `/apps/<slug>`. */
12725
+ /**
12726
+ * Types never worth attempting a three-way merge on.
12727
+ *
12728
+ * NOT application/octet-stream: that is the UNKNOWN case, which is what every
12729
+ * extensionless text file gets labelled, so it must reach the content sniff
12730
+ * rather than being rejected on the label.
12731
+ */
12732
+ const BINARY_TYPE = /^(image|audio|video|font)\/|^application\/(wasm|zip|pdf|x-tar|gzip)/;
12733
+ /**
12734
+ * Does this look like text a person could resolve conflict markers in?
12735
+ *
12736
+ * A NUL byte is the classic tell and the one git uses — no textual encoding we
12737
+ * care about produces one. Invalid UTF-8 is the other: decoding it would
12738
+ * corrupt the file on write-back even if the merge itself succeeded.
12739
+ */
12740
+ function looksTextual(bytes) {
12741
+ if (bytes.includes(0))
12742
+ return false;
12743
+ try {
12744
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
12745
+ return true;
12746
+ }
12747
+ catch {
12748
+ return false;
12749
+ }
12750
+ }
12751
+ /** Compare two path → entry maps into a change list. */
12752
+ function comparePaths(before, after) {
12753
+ const changes = [];
12754
+ for (const [path, entry] of after) {
12755
+ const was = before.get(path);
12756
+ if (!was)
12757
+ changes.push({ path, kind: "added" });
12758
+ else if (was.manifest.id !== entry.manifest.id)
12759
+ changes.push({ path, kind: "modified" });
12760
+ }
12761
+ for (const path of before.keys())
12762
+ if (!after.has(path))
12763
+ changes.push({ path, kind: "removed" });
12764
+ return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
12765
+ }
12766
+ class Repo {
12767
+ slug;
12768
+ deps;
12769
+ constructor(slug, deps) {
12770
+ this.slug = slug;
12771
+ this.deps = deps;
12772
+ }
12773
+ get root() {
12774
+ return `/apps/${this.slug}`;
12775
+ }
12776
+ // ---- objects ------------------------------------------------------------
12777
+ /** Store an object, returning its hash and whether it was new. */
12778
+ async put(value) {
12779
+ const hash = await hashObject(value);
12780
+ // Content-addressed, so re-writing an identical object is a no-op —
12781
+ // worth skipping because an unchanged directory is written on every
12782
+ // commit that touches any of its siblings.
12783
+ const existing = await this.deps.store.get(objectKey(this.slug, hash));
12784
+ if (existing)
12785
+ return { hash, created: false };
12786
+ await this.deps.store.put(objectKey(this.slug, hash), await seal(await this.deps.key(), value));
12787
+ return { hash, created: true };
12788
+ }
12789
+ async get(hash) {
12790
+ const raw = await this.deps.store.get(objectKey(this.slug, hash));
12791
+ const value = raw ? await unseal(await this.deps.key(), raw) : null;
12792
+ if (!value)
12793
+ throw new VcsError(`object ${hash.slice(0, 8)} is missing from this repository`);
12794
+ return value;
12795
+ }
12796
+ // ---- refs ---------------------------------------------------------------
12797
+ async head() {
12798
+ const raw = await this.deps.store.get(headKey(this.slug));
12799
+ const value = raw ? await unseal(await this.deps.key(), raw) : null;
12800
+ return value ?? { branch: DEFAULT_BRANCH };
12801
+ }
12802
+ async setHead(head) {
12803
+ await this.deps.store.put(headKey(this.slug), await seal(await this.deps.key(), head));
12804
+ }
12805
+ async readRef(name) {
12806
+ const raw = await this.deps.store.get(refKey(this.slug, name));
12807
+ return raw ? await unseal(await this.deps.key(), raw).then((v) => v?.commit ?? null) : null;
12808
+ }
12809
+ async writeRef(name, commit) {
12810
+ await this.deps.store.put(refKey(this.slug, name), await seal(await this.deps.key(), { commit }));
12811
+ // The personal space has no prefix listing, so branches are tracked in
12812
+ // an index. Kept append-only-ish: losing a name here loses the branch.
12813
+ const names = new Set(await this.branches());
12814
+ names.add(name);
12815
+ await this.deps.store.put(refsIndexKey(this.slug), await seal(await this.deps.key(), { names: [...names].sort() }));
12816
+ }
12817
+ /** Every branch name in this repository. */
12818
+ async branches() {
12819
+ const raw = await this.deps.store.get(refsIndexKey(this.slug));
12820
+ const value = raw ? await unseal(await this.deps.key(), raw) : null;
12821
+ return value?.names ?? [];
12822
+ }
12823
+ /** The commit HEAD resolves to, or null in an empty repository. */
12824
+ async current() {
12825
+ const head = await this.head();
12826
+ return "detached" in head ? head.detached : this.readRef(head.branch);
12827
+ }
12828
+ // ---- commit -------------------------------------------------------------
12829
+ /**
12830
+ * Freeze the working tree as a commit.
12831
+ *
12832
+ * Trees are written bottom-up so a directory's hash is known before its
12833
+ * parent references it. Nothing is written to the shard store: file entries
12834
+ * carry the manifest the VFS already holds.
12835
+ */
12836
+ async commit(message, parents) {
12837
+ if (!message.trim())
12838
+ throw new VcsError("a commit needs a message");
12839
+ const tree = await this.writeTree(this.root);
12840
+ const parent = await this.current();
12841
+ // Finish an interrupted merge: the recorded other side becomes the
12842
+ // second parent. Losing this would produce a single-parent commit, and
12843
+ // the merge would look as though it never happened — the branches would
12844
+ // still appear diverged.
12845
+ const pending = parents ? null : await this.pendingMerge();
12846
+ const resolved = parents ?? (pending ? [parent, pending.theirs] : parent ? [parent] : []);
12847
+ if (!parents && !pending && parent) {
12848
+ const previous = await this.get(parent);
12849
+ // An empty commit is almost always a mistake — the user believes
12850
+ // they saved something they did not.
12851
+ if (previous.tree === tree)
12852
+ throw new VcsError("nothing to commit — the project is unchanged");
12853
+ }
12854
+ const commit = {
12855
+ v: 1,
12856
+ tree,
12857
+ parents: resolved,
12858
+ message: message.trim(),
12859
+ author: this.deps.author(),
12860
+ at: Date.now(),
12861
+ };
12862
+ const { hash } = await this.put(commit);
12863
+ await this.advance(hash);
12864
+ if (pending)
12865
+ await this.deps.store.delete(mergeKey(this.slug)).catch(() => { });
12866
+ return hash;
12867
+ }
12868
+ /** Move whatever HEAD points at onto a new commit. */
12869
+ async advance(hash) {
12870
+ const head = await this.head();
12871
+ if ("detached" in head)
12872
+ await this.setHead({ detached: hash });
12873
+ else
12874
+ await this.writeRef(head.branch, hash);
12875
+ }
12876
+ /** The other side of a merge that stopped for conflicts, if any. */
12877
+ async pendingMerge() {
12878
+ const raw = await this.deps.store.get(mergeKey(this.slug));
12879
+ return raw ? await unseal(await this.deps.key(), raw) : null;
12880
+ }
12881
+ /** Write a directory and everything under it, returning the tree's hash. */
12882
+ async writeTree(path) {
12883
+ const entries = {};
12884
+ for (const entry of await this.deps.vfs.list(path)) {
12885
+ if (entry.kind === "dir") {
12886
+ entries[entry.name] = { kind: "tree", hash: await this.writeTree(entry.path) };
12887
+ }
12888
+ else {
12889
+ const stat = await this.deps.vfs.statManifest(entry.path);
12890
+ entries[entry.name] = { kind: "file", manifest: stat.manifest, size: entry.size };
12891
+ }
12892
+ }
12893
+ const { hash, created } = await this.put({ v: 1, entries });
12894
+ // A NEW tree means this directory's contents are newly recorded, so the
12895
+ // repository takes its own reference to their content. Without it, a
12896
+ // later working-tree delete frees shards the commit still needs and the
12897
+ // history becomes unreadable.
12898
+ //
12899
+ // Retaining per new tree rather than per changed FILE slightly
12900
+ // over-retains — an unchanged file in a directory whose sibling changed
12901
+ // gets a second reference. That errs towards keeping content, which is
12902
+ // the correct direction to err in.
12903
+ if (created) {
12904
+ for (const entry of Object.values(entries)) {
12905
+ if (entry.kind === "file")
12906
+ await this.deps.vfs.retainManifest(entry.manifest);
12907
+ }
12908
+ }
12909
+ return hash;
12910
+ }
12911
+ // ---- history ------------------------------------------------------------
12912
+ /** Commits reachable from HEAD, newest first. */
12913
+ async log(limit = 50) {
12914
+ const start = await this.current();
12915
+ if (!start)
12916
+ return [];
12917
+ const out = [];
12918
+ const seen = new Set();
12919
+ // Breadth-first over parents so a merge's two sides both appear, and
12920
+ // `seen` keeps a diamond from listing the shared history twice.
12921
+ const queue = [start];
12922
+ while (queue.length && out.length < limit) {
12923
+ const hash = queue.shift();
12924
+ if (seen.has(hash))
12925
+ continue;
12926
+ seen.add(hash);
12927
+ const commit = await this.get(hash);
12928
+ out.push({
12929
+ hash,
12930
+ message: commit.message,
12931
+ author: commit.author,
12932
+ at: commit.at,
12933
+ parents: commit.parents,
12934
+ });
12935
+ queue.push(...commit.parents);
12936
+ }
12937
+ return out.sort((a, b) => b.at - a.at);
12938
+ }
12939
+ // ---- diff ---------------------------------------------------------------
12940
+ /**
12941
+ * What changed between two commits.
12942
+ *
12943
+ * Identical subtree hashes prune the walk: an untouched directory is one
12944
+ * comparison regardless of how much is inside it. That is the payoff for
12945
+ * content-addressing the trees.
12946
+ */
12947
+ async diff(fromHash, toHash) {
12948
+ const from = fromHash ? (await this.get(fromHash)).tree : null;
12949
+ const to = (await this.get(toHash)).tree;
12950
+ const changes = [];
12951
+ await this.diffTrees(from, to, "", changes);
12952
+ return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
12953
+ }
12954
+ async diffTrees(fromHash, toHash, prefix, out) {
12955
+ if (fromHash === toHash)
12956
+ return; // identical subtrees: nothing below can differ
12957
+ const from = fromHash ? await this.get(fromHash) : { entries: {} };
12958
+ const to = toHash ? await this.get(toHash) : { entries: {} };
12959
+ for (const name of new Set([...Object.keys(from.entries), ...Object.keys(to.entries)])) {
12960
+ const a = from.entries[name];
12961
+ const b = to.entries[name];
12962
+ const path = `${prefix}/${name}`;
12963
+ if (a && b && a.kind === "tree" && b.kind === "tree") {
12964
+ await this.diffTrees(a.hash, b.hash, path, out);
12965
+ }
12966
+ else if (a?.kind === "tree" || b?.kind === "tree") {
12967
+ // A directory replaced by a file (or the reverse) — recurse both
12968
+ // sides so every affected path is reported, not just the name.
12969
+ await this.diffTrees(a?.kind === "tree" ? a.hash : null, b?.kind === "tree" ? b.hash : null, path, out);
12970
+ if (a?.kind === "file")
12971
+ out.push({ path, kind: "removed" });
12972
+ if (b?.kind === "file")
12973
+ out.push({ path, kind: "added" });
12974
+ }
12975
+ else if (!a && b) {
12976
+ out.push({ path, kind: "added" });
12977
+ }
12978
+ else if (a && !b) {
12979
+ out.push({ path, kind: "removed" });
12980
+ }
12981
+ else if (a?.kind === "file" && b?.kind === "file" && a.manifest.id !== b.manifest.id) {
12982
+ out.push({ path, kind: "modified" });
12983
+ }
12984
+ }
12985
+ }
12986
+ // ---- checkout -----------------------------------------------------------
12987
+ /**
12988
+ * Replace the working tree with a commit's.
12989
+ *
12990
+ * Moves no file content: entries carry manifests the shard store already
12991
+ * holds, so this rewrites directory records only.
12992
+ */
12993
+ /**
12994
+ * What has changed since the last commit.
12995
+ *
12996
+ * Compares the WORKING TREE against HEAD without writing any objects: the
12997
+ * common case is answering "anything to commit?", and a `status` that
12998
+ * quietly stored a tree object every time would litter the repository with
12999
+ * trees for states nobody chose to keep.
13000
+ */
13001
+ async status() {
13002
+ const head = await this.current();
13003
+ const before = head ? await this.filesOf(head) : new Map();
13004
+ const after = await this.workingFiles();
13005
+ return comparePaths(before, after);
13006
+ }
13007
+ /** The working tree as a path → entry map, mirroring `filesOf`. */
13008
+ async workingFiles() {
13009
+ const out = new Map();
13010
+ for (const abs of await this.deps.vfs.walk(this.root)) {
13011
+ const stat = await this.deps.vfs.statManifest(abs);
13012
+ out.set(abs.slice(this.root.length), { kind: "file", manifest: stat.manifest, size: stat.size });
13013
+ }
13014
+ return out;
13015
+ }
13016
+ /**
13017
+ * Turn what a person typed into a commit hash.
13018
+ *
13019
+ * Accepts a branch name, `HEAD`, a full or abbreviated hash, and trailing
13020
+ * `^` / `~n` to walk back through parents — so `show abc123^` means "the
13021
+ * commit before that one" the way anyone who has used git expects.
13022
+ */
13023
+ async resolve(rev) {
13024
+ const m = /^(.*?)((?:\^|~\d+)*)$/.exec(rev.trim());
13025
+ if (!m)
13026
+ throw new VcsError(`cannot understand "${rev}"`);
13027
+ const [, base, steps] = m;
13028
+ let hash = null;
13029
+ if (!base || base === "HEAD")
13030
+ hash = await this.current();
13031
+ else if (await this.readRef(base))
13032
+ hash = await this.readRef(base);
13033
+ else
13034
+ hash = await this.expand(base);
13035
+ if (!hash)
13036
+ throw new VcsError(`no commit matches "${rev}"`);
13037
+ for (const step of steps.match(/\^|~\d+/g) ?? []) {
13038
+ const back = step === "^" ? 1 : Number(step.slice(1));
13039
+ for (let i = 0; i < back; i++) {
13040
+ const parents = (await this.get(hash)).parents;
13041
+ if (!parents.length)
13042
+ throw new VcsError(`"${rev}" reaches past the first commit`);
13043
+ hash = parents[0]; // first parent: the branch you were on
13044
+ }
13045
+ }
13046
+ return hash;
13047
+ }
13048
+ /** A hash prefix a person typed → the one commit it names. */
13049
+ async expand(prefix) {
13050
+ if (!/^[0-9a-f]{4,}$/.test(prefix))
13051
+ return null;
13052
+ const keys = await this.deps.store.list();
13053
+ const wanted = objectKey(this.slug, prefix);
13054
+ const hits = keys.filter((k) => k.startsWith(wanted));
13055
+ // Ambiguous is an error, not a coin toss — acting on the wrong commit
13056
+ // is exactly the kind of mistake version control exists to prevent.
13057
+ if (hits.length > 1)
13058
+ throw new VcsError(`"${prefix}" matches ${hits.length} objects — use more characters`);
13059
+ if (!hits.length)
13060
+ return null;
13061
+ const hash = hits[0].slice(objectKey(this.slug, "").length);
13062
+ // Objects include trees; only a commit is a valid revision.
13063
+ const object = await this.get(hash);
13064
+ return object && "tree" in object ? hash : null;
13065
+ }
13066
+ /**
13067
+ * Put one file back to the way it was in a commit.
13068
+ *
13069
+ * Scoped to a path on purpose — recovering one file you broke should not
13070
+ * mean rewinding everything else you have done since.
13071
+ */
13072
+ async restore(path, rev = "HEAD") {
13073
+ const hash = await this.resolve(rev);
13074
+ const files = await this.filesOf(hash);
13075
+ const key = path.startsWith(this.root) ? path.slice(this.root.length) : path.startsWith("/") ? path : `/${path}`;
13076
+ const entry = files.get(key);
13077
+ if (!entry)
13078
+ throw new VcsError(`${key} is not in ${rev}`);
13079
+ await this.deps.vfs.writeManifest(this.abs(key), entry.manifest, entry.size);
13080
+ }
13081
+ async checkout(commitHash, opts = {}) {
13082
+ await this.guardWorkingTree(opts.discardChanges);
13083
+ await this.materialise(commitHash);
13084
+ // DETACHED on purpose: committing from a historical state must not
13085
+ // silently rewrite whichever branch you happened to be on. `switchTo`
13086
+ // is how you re-attach.
13087
+ await this.setHead({ detached: commitHash });
13088
+ }
13089
+ /** Make the working tree match a commit, leaving HEAD alone. */
13090
+ async materialise(commitHash) {
13091
+ const commit = await this.get(commitHash);
13092
+ const wanted = new Map();
13093
+ await this.collect(commit.tree, "", wanted);
13094
+ // Remove what the commit does not have BEFORE writing what it does —
13095
+ // otherwise a file replaced by a directory of the same name collides.
13096
+ for (const abs of await this.deps.vfs.walk(this.root)) {
13097
+ const path = abs.slice(this.root.length);
13098
+ // keepContent: history still references these manifests.
13099
+ if (!wanted.has(path))
13100
+ await this.deps.vfs.delete(abs, { keepContent: true }).catch(() => { });
13101
+ }
13102
+ for (const [path, entry] of wanted) {
13103
+ await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
13104
+ }
13105
+ }
13106
+ /** Project-relative path → absolute VFS path. */
13107
+ abs(path) {
13108
+ return `${this.root}${path}`;
13109
+ }
13110
+ async collect(treeHash, prefix, out) {
13111
+ const tree = await this.get(treeHash);
13112
+ for (const [name, entry] of Object.entries(tree.entries)) {
13113
+ const path = `${prefix}/${name}`;
13114
+ if (entry.kind === "tree")
13115
+ await this.collect(entry.hash, path, out);
13116
+ else
13117
+ out.set(path, entry);
13118
+ }
13119
+ }
13120
+ // ---- branches -----------------------------------------------------------
13121
+ /** Create a branch at a commit (HEAD by default). Does not switch to it. */
13122
+ async branch(name, at) {
13123
+ if (!/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(name))
13124
+ throw new VcsError(`"${name}" is not a usable branch name`);
13125
+ if (await this.readRef(name))
13126
+ throw new VcsError(`branch "${name}" already exists`);
13127
+ const commit = at ?? (await this.current());
13128
+ if (!commit)
13129
+ throw new VcsError("cannot branch an empty repository — commit something first");
13130
+ await this.writeRef(name, commit);
13131
+ return commit;
13132
+ }
13133
+ /**
13134
+ * Move onto a branch, bringing the working tree with it.
13135
+ *
13136
+ * Unlike {@link checkout}, this ATTACHES head: later commits advance the
13137
+ * branch. Checking out a bare commit deliberately does not, so that
13138
+ * committing from a historical state cannot silently rewrite a branch.
13139
+ */
13140
+ async switchTo(name, opts = {}) {
13141
+ const commit = await this.readRef(name);
13142
+ if (!commit)
13143
+ throw new VcsError(`no branch named "${name}"`);
13144
+ await this.guardWorkingTree(opts.discardChanges);
13145
+ await this.materialise(commit);
13146
+ await this.setHead({ branch: name });
13147
+ }
13148
+ /**
13149
+ * Refuse to overwrite work that was never recorded.
13150
+ *
13151
+ * Moving between commits REPLACES the working tree, so uncommitted edits
13152
+ * are gone — and unlike an ordinary overwrite, the person did not ask for
13153
+ * anything to be written. Naming the files is the point: "you have changes"
13154
+ * with no list leaves them guessing what they are about to lose.
13155
+ */
13156
+ async guardWorkingTree(discard) {
13157
+ if (discard)
13158
+ return;
13159
+ const pending = await this.status();
13160
+ if (!pending.length)
13161
+ return;
13162
+ const names = pending.slice(0, 5).map((c) => c.path).join(", ");
13163
+ const more = pending.length > 5 ? `, and ${pending.length - 5} more` : "";
13164
+ throw new VcsError(`${pending.length} uncommitted change(s) would be lost: ${names}${more}. ` +
13165
+ "Commit them first, or pass { discardChanges: true } to throw them away.");
13166
+ }
13167
+ /** The branch HEAD is on, or null when detached. */
13168
+ async currentBranch() {
13169
+ const head = await this.head();
13170
+ return "detached" in head ? null : head.branch;
13171
+ }
13172
+ // ---- merge --------------------------------------------------------------
13173
+ /**
13174
+ * Merge another branch into the current one.
13175
+ *
13176
+ * Every part of this is client-side by necessity: the server holds
13177
+ * ciphertext and cannot compare versions, so base, ours and theirs are all
13178
+ * fetched and decrypted here.
13179
+ */
13180
+ async merge(name, opts = {}) {
13181
+ const theirs = await this.readRef(name);
13182
+ if (!theirs)
13183
+ throw new VcsError(`no branch named "${name}"`);
13184
+ // A merge writes to the working tree as freely as a switch does.
13185
+ await this.guardWorkingTree(opts.discardChanges);
13186
+ const ours = await this.current();
13187
+ if (!ours)
13188
+ throw new VcsError("nothing to merge into — commit something first");
13189
+ if (ours === theirs)
13190
+ return { kind: "up-to-date", conflicts: [] };
13191
+ const base = await this.mergeBase(ours, theirs);
13192
+ if (base === theirs)
13193
+ return { kind: "up-to-date", conflicts: [] };
13194
+ if (base === ours) {
13195
+ // Fast-forward: our history is contained in theirs, so there is
13196
+ // nothing to combine — just move.
13197
+ await this.materialise(theirs);
13198
+ await this.advance(theirs);
13199
+ return { kind: "fast-forward", commit: theirs, conflicts: [] };
13200
+ }
13201
+ const [baseFiles, ourFiles, theirFiles] = await Promise.all([
13202
+ base ? this.filesOf(base) : new Map(),
13203
+ this.filesOf(ours),
13204
+ this.filesOf(theirs),
13205
+ ]);
13206
+ const conflicts = [];
13207
+ for (const path of new Set([...ourFiles.keys(), ...theirFiles.keys(), ...baseFiles.keys()])) {
13208
+ const b = baseFiles.get(path);
13209
+ const o = ourFiles.get(path);
13210
+ const t = theirFiles.get(path);
13211
+ const conflict = await this.settlePath(path, b, o, t);
13212
+ if (conflict)
13213
+ conflicts.push(conflict);
13214
+ }
13215
+ if (conflicts.length) {
13216
+ // Record what to merge with, so finishing is just: fix the files,
13217
+ // then commit. Losing this would silently produce a commit with one
13218
+ // parent, and the merge would appear never to have happened.
13219
+ await this.deps.store.put(mergeKey(this.slug), await seal(await this.deps.key(), { theirs, name }));
13220
+ return { kind: "conflicted", conflicts };
13221
+ }
13222
+ const commit = await this.commit(`Merge ${name}`, [ours, theirs]);
13223
+ return { kind: "merged", commit, conflicts: [] };
13224
+ }
13225
+ /** Decide one path. Returns a conflict when it cannot be settled. */
13226
+ async settlePath(path, b, o, t) {
13227
+ const id = (f) => f?.manifest.id;
13228
+ if (id(o) === id(t))
13229
+ return null; // already agree
13230
+ if (!o && !b && t)
13231
+ return this.take(path, t); // only they added it
13232
+ if (!t && !b && o)
13233
+ return null; // only we added it
13234
+ if (id(o) === id(b)) { // only they touched it
13235
+ if (!t) {
13236
+ await this.deps.vfs.delete(this.abs(path), { keepContent: true }).catch(() => { });
13237
+ return null;
13238
+ }
13239
+ return this.take(path, t);
13240
+ }
13241
+ if (id(t) === id(b))
13242
+ return null; // only we touched it
13243
+ // Both sides moved. A file one side deleted and the other edited is not
13244
+ // a merge decision a tool should make silently.
13245
+ if (!o || !t)
13246
+ return { path, reason: "modify-delete" };
13247
+ // Definitely-binary types are rejected WITHOUT reading them — merging
13248
+ // bytes line-by-line produces corruption, not a result, and these files
13249
+ // are exactly the large ones.
13250
+ if (BINARY_TYPE.test(o.manifest.type ?? "") || BINARY_TYPE.test(t.manifest.type ?? "")) {
13251
+ return { path, reason: "binary" };
13252
+ }
13253
+ const [baseBytes, ourBytes, theirBytes] = await Promise.all([
13254
+ b ? this.deps.vfs.readByManifest(b.manifest) : Promise.resolve(new Uint8Array()),
13255
+ this.deps.vfs.readByManifest(o.manifest),
13256
+ this.deps.vfs.readByManifest(t.manifest),
13257
+ ]);
13258
+ // Then judge by CONTENT, not by the declared type.
13259
+ //
13260
+ // The type is a hint we do not own: a content store may omit it, and
13261
+ // plenty of real text files carry no extension to derive one from
13262
+ // (Makefile, LICENSE, .gitignore, .env). Trusting it alone turned every
13263
+ // one of those into an unmergeable false conflict.
13264
+ if (![ourBytes, theirBytes, baseBytes].every(looksTextual))
13265
+ return { path, reason: "binary" };
13266
+ const decode = (bytes) => new TextDecoder().decode(bytes);
13267
+ const { text, conflicted } = merge3Text(decode(baseBytes), decode(ourBytes), decode(theirBytes));
13268
+ await this.deps.vfs.writeFile(this.abs(path), text);
13269
+ return conflicted ? { path, reason: "content" } : null;
13270
+ }
13271
+ async take(path, entry) {
13272
+ await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
13273
+ return null;
13274
+ }
13275
+ /**
13276
+ * The most recent commit reachable from both.
13277
+ *
13278
+ * Walks our ancestry into a set, then theirs breadth-first until it hits it —
13279
+ * so the first match is the nearest, which is what a three-way merge needs.
13280
+ */
13281
+ async mergeBase(a, b) {
13282
+ const ancestors = new Set();
13283
+ const collect = async (start) => {
13284
+ const queue = [start];
13285
+ while (queue.length) {
13286
+ const hash = queue.shift();
13287
+ if (ancestors.has(hash))
13288
+ continue;
13289
+ ancestors.add(hash);
13290
+ queue.push(...(await this.get(hash)).parents);
13291
+ }
13292
+ };
13293
+ await collect(a);
13294
+ const queue = [b];
13295
+ const seen = new Set();
13296
+ while (queue.length) {
13297
+ const hash = queue.shift();
13298
+ if (seen.has(hash))
13299
+ continue;
13300
+ seen.add(hash);
13301
+ if (ancestors.has(hash))
13302
+ return hash;
13303
+ queue.push(...(await this.get(hash)).parents);
13304
+ }
13305
+ return null;
13306
+ }
13307
+ /** Every file in a commit, by path. */
13308
+ async filesOf(commitHash) {
13309
+ const commit = await this.get(commitHash);
13310
+ const out = new Map();
13311
+ await this.collect(commit.tree, "", out);
13312
+ return out;
13313
+ }
13314
+ }
13315
+ /**
13316
+ * Version control over the VFS.
13317
+ *
13318
+ * One repository per project, addressed by slug — the same slug the VFS stores
13319
+ * that project's working tree under. `client.vcs.open("my-app")` is the whole
13320
+ * entry point.
13321
+ */
13322
+ class VcsNamespace {
13323
+ deps;
13324
+ repos = new Map();
13325
+ constructor(deps) {
13326
+ this.deps = deps;
13327
+ }
13328
+ /** The repository for one project. Cheap and cached; creates nothing. */
13329
+ open(slug) {
13330
+ let repo = this.repos.get(slug);
13331
+ if (!repo) {
13332
+ repo = new Repo(slug, {
13333
+ vfs: this.deps.vfs,
13334
+ store: this.deps.store,
13335
+ key: async () => {
13336
+ const seed = this.deps.seed();
13337
+ if (!seed)
13338
+ throw new VcsError("client.vcs: no session — sign in first.");
13339
+ return deriveRepoKey(seed, slug);
13340
+ },
13341
+ author: this.deps.author,
13342
+ });
13343
+ this.repos.set(slug, repo);
13344
+ }
13345
+ return repo;
13346
+ }
13347
+ }
13348
+
11389
13349
  /**
11390
13350
  * `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
11391
13351
  * keypair (the connect `KeyStore` entry keyed by the member id / username).
@@ -12604,6 +14564,8 @@ class DbCache {
12604
14564
  * `FileStorage`, `EncryptedSession`, `Network`, …) remain exported and usable
12605
14565
  * directly, but the client is the supported, ergonomic surface.
12606
14566
  */
14567
+ /** Personal-space key holding the id of the space VFS content is written to. */
14568
+ const VFS_SPACE_KEY = "vfs/space";
12607
14569
  /** The hosted Muhkoo Accelerator — the default {@link ClientOptions.baseUrl}. */
12608
14570
  const DEFAULT_BASE_URL = "https://api.muhkoo.dev";
12609
14571
  /** The hosted auth SPA — the default {@link ClientOptions.authBaseUrl}. */
@@ -12625,6 +14587,10 @@ class Client {
12625
14587
  db;
12626
14588
  /** File storage — `client.storage.writeFile(...)`, etc. */
12627
14589
  storage;
14590
+ /** Filesystem over the personal space: directories, versions, real paths. */
14591
+ vfs;
14592
+ /** Version control over the VFS: commits, branches, merges — per project. */
14593
+ vcs;
12628
14594
  /** Realtime messaging — `client.message.subscribe(...)`, etc. */
12629
14595
  message;
12630
14596
  /** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
@@ -12724,6 +14690,103 @@ class Client {
12724
14690
  deferShardUpload,
12725
14691
  });
12726
14692
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
14693
+ // The VFS stores its metadata in the personal space — the same place
14694
+ // `storage.writeFile` already mirrors `{spaceId, manifest}` to, only
14695
+ // arranged as a navigable tree instead of a flat index keyed by file id.
14696
+ //
14697
+ // Content still goes through `storage.writeFile` untouched, into a real
14698
+ // Space with a real gated manifest. The space is created once, lazily,
14699
+ // and its id recorded in the personal space: a filesystem that has never
14700
+ // been written to should not cost a space.
14701
+ const personalStore = {
14702
+ get: async (key) => {
14703
+ const c = this.session.commitment;
14704
+ if (!c)
14705
+ throw new Error("client.vfs: no session — sign in first.");
14706
+ const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
14707
+ return res?.value ?? null;
14708
+ },
14709
+ put: async (key, value) => {
14710
+ const c = this.session.commitment;
14711
+ if (!c)
14712
+ throw new Error("client.vfs: no session — sign in first.");
14713
+ await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
14714
+ },
14715
+ delete: async (key) => {
14716
+ const c = this.session.commitment;
14717
+ if (!c)
14718
+ throw new Error("client.vfs: no session — sign in first.");
14719
+ await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
14720
+ },
14721
+ list: async () => {
14722
+ const c = this.session.commitment;
14723
+ if (!c)
14724
+ throw new Error("client.vfs: no session — sign in first.");
14725
+ const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
14726
+ return res?.keys ?? [];
14727
+ },
14728
+ };
14729
+ let contentSpace = null;
14730
+ const resolveContentSpace = () => {
14731
+ // Cached as the PROMISE, not the value: two concurrent first writes
14732
+ // would otherwise each create a space and one would be orphaned.
14733
+ if (!contentSpace) {
14734
+ contentSpace = (async () => {
14735
+ const existing = await personalStore.get(VFS_SPACE_KEY);
14736
+ if (typeof existing === "string" && existing)
14737
+ return this.space.get(existing);
14738
+ // `messaging: false` — this space only ever holds files, and
14739
+ // file chunks carry their own keys, so it needs no group
14740
+ // keyring and no socket. Creating it the normal way opens a
14741
+ // WebSocket, which made the first write hang indefinitely.
14742
+ const space = await this.space.createSpace({ private: true, messaging: false });
14743
+ await personalStore.put(VFS_SPACE_KEY, space.id);
14744
+ return space;
14745
+ })().catch((err) => {
14746
+ contentSpace = null; // let the next write retry
14747
+ throw err;
14748
+ });
14749
+ }
14750
+ return contentSpace;
14751
+ };
14752
+ this.vfs = new VfsNamespace({
14753
+ store: personalStore,
14754
+ seed: () => this.session.seed,
14755
+ // Share the personal space's socket with `client.kv` rather than
14756
+ // opening a second one to the same Durable Object.
14757
+ subscribe: (handler) => this.kv.onRaw(handler),
14758
+ content: {
14759
+ // `putFile` needs no WebSocket and commits the shards, so the
14760
+ // bytes are metered to the owning app and reference-counted for
14761
+ // GC. `storage.writeFile` would additionally index the manifest
14762
+ // in the space — a second, diverging source of truth for a file
14763
+ // whose handle the VFS already holds.
14764
+ write: async (data, meta) => {
14765
+ const space = await resolveContentSpace();
14766
+ const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
14767
+ return { manifest, size: stat.size };
14768
+ },
14769
+ // Reads go through the manifest alone — no space needed. That is
14770
+ // the capability path, so it keeps working for a file whose
14771
+ // handle was shared from someone else's filesystem.
14772
+ read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
14773
+ release: async (manifest) => {
14774
+ await (await resolveContentSpace()).releaseFile(manifest);
14775
+ },
14776
+ retain: async (manifest) => {
14777
+ await (await resolveContentSpace()).commitFile(manifest);
14778
+ },
14779
+ },
14780
+ });
14781
+ // Sealed into the SAME personal store as the VFS metadata, so a project
14782
+ // and its history live or die together — there is no way to end up with
14783
+ // commits whose working tree is gone, or the reverse.
14784
+ this.vcs = new VcsNamespace({
14785
+ vfs: this.vfs,
14786
+ store: personalStore,
14787
+ seed: () => this.session.seed,
14788
+ author: () => this.session.commitment ?? "unknown",
14789
+ });
12727
14790
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
12728
14791
  // by client.kv), so a returning member hydrates without a fresh keyring
12729
14792
  // round-trip. The server only ever sees ciphertext.
@@ -13662,5 +15725,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
13662
15725
  default: loadRsWasm
13663
15726
  });
13664
15727
 
13665
- 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_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReauthRequiredError, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
15728
+ 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_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, 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, Logger, 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, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, VcsError, VcsNamespace, VfsLockedError, VfsNamespace, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
13666
15729
  //# sourceMappingURL=index.js.map