@muhkoo/connect 0.10.13-alpha.0 → 0.11.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.
@@ -263,7 +263,7 @@ function fromHex(hex) {
263
263
  return out;
264
264
  }
265
265
  /** Encode a byte array as a standard base64 string. */
266
- function toBase64$1(bytes) {
266
+ function toBase64$2(bytes) {
267
267
  let binary = "";
268
268
  for (let i = 0; i < bytes.length; i++) {
269
269
  binary += String.fromCharCode(bytes[i]);
@@ -271,7 +271,7 @@ function toBase64$1(bytes) {
271
271
  return btoa(binary);
272
272
  }
273
273
  /** Decode a standard base64 string to bytes. */
274
- function fromBase64$1(b64) {
274
+ function fromBase64$2(b64) {
275
275
  const binary = atob(b64);
276
276
  const ab = new ArrayBuffer(binary.length);
277
277
  const out = new Uint8Array(ab);
@@ -281,13 +281,13 @@ function fromBase64$1(b64) {
281
281
  }
282
282
  /** Encode a byte array as a base64url string (no padding). */
283
283
  function toBase64Url(bytes) {
284
- return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
284
+ return toBase64$2(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
285
285
  }
286
286
  /** Decode a base64url string (with or without padding) to bytes. */
287
287
  function fromBase64Url(b64url) {
288
288
  const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
289
289
  "=".repeat((4 - (b64url.length % 4)) % 4);
290
- return fromBase64$1(padded);
290
+ return fromBase64$2(padded);
291
291
  }
292
292
  /** Concatenate any number of byte arrays into a single Uint8Array. */
293
293
  function concatBytes(...arrays) {
@@ -898,7 +898,7 @@ function randomBytes(n) {
898
898
  /** Length of the AES key in bytes. */
899
899
  const AES_GCM_KEY_BYTES = 32;
900
900
  /** Length of the AES-GCM IV in bytes (WebCrypto-recommended). */
901
- const AES_GCM_IV_BYTES = 12;
901
+ const AES_GCM_IV_BYTES$1 = 12;
902
902
  /** Length of the AES-GCM authentication tag in bytes (appended to ciphertext). */
903
903
  const AES_GCM_TAG_BYTES = 16;
904
904
  /**
@@ -939,8 +939,8 @@ function assertKey(key) {
939
939
  }
940
940
  }
941
941
  function assertIv(iv) {
942
- if (iv.byteLength !== AES_GCM_IV_BYTES) {
943
- throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES} bytes (got ${iv.byteLength})`);
942
+ if (iv.byteLength !== AES_GCM_IV_BYTES$1) {
943
+ throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES$1} bytes (got ${iv.byteLength})`);
944
944
  }
945
945
  }
946
946
 
@@ -1118,10 +1118,10 @@ class KeyStore {
1118
1118
  async packDehydratedKeys(id) {
1119
1119
  const dehydrated = await this.dehydrateKeyPair(id);
1120
1120
  const combined = JSON.stringify(dehydrated);
1121
- return toBase64$1(new TextEncoder().encode(combined));
1121
+ return toBase64$2(new TextEncoder().encode(combined));
1122
1122
  }
1123
1123
  async hydrateFromPacked(id, masterKey) {
1124
- const decoded = utf8Decode(fromBase64$1(masterKey));
1124
+ const decoded = utf8Decode(fromBase64$2(masterKey));
1125
1125
  const dehydrated = JSON.parse(decoded);
1126
1126
  await this.hydrateKeyPair(id, dehydrated);
1127
1127
  }
@@ -1231,7 +1231,7 @@ class DoubleRatchet {
1231
1231
  }
1232
1232
  const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
1233
1233
  this.state.sendChainKey = newChainKey;
1234
- const iv = randomBytes(AES_GCM_IV_BYTES);
1234
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
1235
1235
  const encrypted = await encryptAesGcm(messageKey, iv, utf8Encode(plaintext));
1236
1236
  const cipherTextWithTag = toHex(encrypted);
1237
1237
  appLogger.debug(`Encrypt: plaintextLength=${plaintext.length}, ciphertextLength=${encrypted.byteLength}`);
@@ -1951,8 +1951,8 @@ class ChunkCipher {
1951
1951
  */
1952
1952
  generateKeyMaterial() {
1953
1953
  return {
1954
- key: toBase64$1(randomBytes(AES_GCM_KEY_BYTES)),
1955
- iv: toBase64$1(randomBytes(AES_GCM_IV_BYTES)),
1954
+ key: toBase64$2(randomBytes(AES_GCM_KEY_BYTES)),
1955
+ iv: toBase64$2(randomBytes(AES_GCM_IV_BYTES$1)),
1956
1956
  };
1957
1957
  }
1958
1958
  /**
@@ -1961,7 +1961,7 @@ class ChunkCipher {
1961
1961
  * GCM behavior).
1962
1962
  */
1963
1963
  async encryptChunk(plaintext, material) {
1964
- return encryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), plaintext);
1964
+ return encryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), plaintext);
1965
1965
  }
1966
1966
  /**
1967
1967
  * Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
@@ -1970,7 +1970,7 @@ class ChunkCipher {
1970
1970
  */
1971
1971
  async decryptChunk(ciphertext, material) {
1972
1972
  try {
1973
- return await decryptAesGcm(fromBase64$1(material.key), fromBase64$1(material.iv), ciphertext);
1973
+ return await decryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), ciphertext);
1974
1974
  }
1975
1975
  catch {
1976
1976
  // WebCrypto throws a vague OperationError on GCM tag mismatch.
@@ -2034,7 +2034,7 @@ async function wrapWithPassphrase(passphrase, plaintext) {
2034
2034
  throw new Error("wrapWithPassphrase: passphrase must be a non-empty string");
2035
2035
  }
2036
2036
  const salt = randomBytes(SALT_BYTES);
2037
- const iv = randomBytes(AES_GCM_IV_BYTES);
2037
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
2038
2038
  const key = await deriveAesKeyFromPassphrase(passphrase, salt, PBKDF2_DEFAULT_ITERATIONS);
2039
2039
  const ciphertext = await encryptAesGcm(key, iv, plaintext);
2040
2040
  return {
@@ -4541,8 +4541,10 @@ class AuthClient {
4541
4541
  /** Passkeys are per-origin: naming the caller's host returns THAT origin's
4542
4542
  * passkey instead of whichever was enrolled first (which WebAuthn would
4543
4543
  * refuse). Ignored for other factor types. */
4544
- rpId) {
4545
- const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId }));
4544
+ rpId,
4545
+ /** Device factors are selected by id — a user may have many machines. */
4546
+ factorId) {
4547
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId, factorId }));
4546
4548
  return await this.parse("vaultRead", res);
4547
4549
  }
4548
4550
  /**
@@ -4983,20 +4985,20 @@ async function wrapKeyFromBytes(keyMaterial, info) {
4983
4985
  async function wrapSeed(seed, key) {
4984
4986
  const iv = crypto.getRandomValues(new Uint8Array(12));
4985
4987
  const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
4986
- return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
4988
+ return { iv: toBase64$1(iv), ct: toBase64$1(new Uint8Array(ct)) };
4987
4989
  }
4988
4990
  /** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
4989
4991
  async function unwrapSeed(wrapped, key) {
4990
- const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
4992
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(wrapped.iv) }, key, fromBase64$1(wrapped.ct));
4991
4993
  return new Uint8Array(pt);
4992
4994
  }
4993
- function toBase64(bytes) {
4995
+ function toBase64$1(bytes) {
4994
4996
  let s = "";
4995
4997
  for (let i = 0; i < bytes.length; i++)
4996
4998
  s += String.fromCharCode(bytes[i]);
4997
4999
  return btoa(s);
4998
5000
  }
4999
- function fromBase64(b64) {
5001
+ function fromBase64$1(b64) {
5000
5002
  const s = atob(b64);
5001
5003
  const out = new Uint8Array(s.length);
5002
5004
  for (let i = 0; i < s.length; i++)
@@ -5054,20 +5056,20 @@ async function sealSeed(seed) {
5054
5056
  const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["encrypt"]);
5055
5057
  const iv = crypto.getRandomValues(new Uint8Array(12));
5056
5058
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
5057
- const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
5059
+ const envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
5058
5060
  return {
5059
- sealedKeys: toBase64(TE$1.encode(JSON.stringify(envelope))),
5061
+ sealedKeys: toBase64$1(TE$1.encode(JSON.stringify(envelope))),
5060
5062
  fragmentKey: base64UrlEncode(ktRaw),
5061
5063
  };
5062
5064
  }
5063
5065
  /** Recover the master seed from the sealed envelope + the fragment key. */
5064
5066
  async function unsealSeed(sealedKeys, fragmentKey) {
5065
- const envelope = JSON.parse(TD$1.decode(fromBase64(sealedKeys)));
5067
+ const envelope = JSON.parse(TD$1.decode(fromBase64$1(sealedKeys)));
5066
5068
  if (envelope.v !== 1)
5067
5069
  throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
5068
5070
  const ktRaw = base64UrlDecode(fragmentKey);
5069
5071
  const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["decrypt"]);
5070
- const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
5072
+ const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct));
5071
5073
  return new Uint8Array(seed);
5072
5074
  }
5073
5075
  // ---- v2: ECDH device pairing ----------------------------------------------
@@ -5118,7 +5120,7 @@ const PAIRING_CODE_ITERATIONS = 200_000;
5118
5120
  async function generateDevicePairingKeypair() {
5119
5121
  const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
5120
5122
  const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
5121
- return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
5123
+ return { publicKeyB64: toBase64$1(raw), privateKey: pair.privateKey };
5122
5124
  }
5123
5125
  /**
5124
5126
  * (Hosted-page side) Seal the master seed *to a device's* pairing public key.
@@ -5139,8 +5141,8 @@ async function sealSeedToDevice(seed, devicePublicKeyB64) {
5139
5141
  const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
5140
5142
  const iv = crypto.getRandomValues(new Uint8Array(12));
5141
5143
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
5142
- const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
5143
- return toBase64(TE$1.encode(JSON.stringify(envelope)));
5144
+ const envelope = { v: 2, epk: toBase64$1(epkRaw), iv: toBase64$1(iv), ct: toBase64$1(ct) };
5145
+ return toBase64$1(TE$1.encode(JSON.stringify(envelope)));
5144
5146
  }
5145
5147
  /**
5146
5148
  * (Device side) Recover the master seed from a v2 envelope using the ephemeral
@@ -5298,7 +5300,7 @@ function importPairingPublicKey(raw) {
5298
5300
  */
5299
5301
  function fromBase64Loose(s) {
5300
5302
  const norm = s.replace(/-/g, "+").replace(/_/g, "/");
5301
- return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
5303
+ return fromBase64$1(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
5302
5304
  }
5303
5305
  /** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
5304
5306
  async function generatePkce() {
@@ -5311,10 +5313,10 @@ function randomState() {
5311
5313
  return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
5312
5314
  }
5313
5315
  function base64UrlEncode(bytes) {
5314
- return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
5316
+ return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
5315
5317
  }
5316
5318
  function base64UrlDecode(s) {
5317
- return fromBase64(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
5319
+ return fromBase64$1(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
5318
5320
  }
5319
5321
 
5320
5322
  /**
@@ -5325,7 +5327,7 @@ function base64UrlDecode(s) {
5325
5327
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
5326
5328
  * `package.json` `version`.
5327
5329
  */
5328
- const VERSION = "0.10.13-alpha.0";
5330
+ const VERSION = "0.11.0-alpha.0";
5329
5331
 
5330
5332
  /**
5331
5333
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -5778,8 +5780,8 @@ function gatedBlind(input) {
5778
5780
  * unblind each, concatenate, HKDF with the gated-factor domain separation.
5779
5781
  */
5780
5782
  async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
5781
- const out1 = oprfFinalize(input, blind, fromBase64(evaluatedB64));
5782
- const out2 = oprfFinalize(input, blind, fromBase64(evaluated2B64));
5783
+ const out1 = oprfFinalize(input, blind, fromBase64$1(evaluatedB64));
5784
+ const out2 = oprfFinalize(input, blind, fromBase64$1(evaluated2B64));
5783
5785
  const ikm = new Uint8Array(out1.length + out2.length);
5784
5786
  ikm.set(out1);
5785
5787
  ikm.set(out2, out1.length);
@@ -5861,6 +5863,30 @@ function rpIdUsableForOrigin(rpId, host) {
5861
5863
  return false;
5862
5864
  return h === rpId || h.endsWith(`.${rpId}`);
5863
5865
  }
5866
+ /**
5867
+ * Would {@link AuthNamespace.loginWithPasskey} accept a factor recorded with
5868
+ * this `rpId`, from `host`?
5869
+ *
5870
+ * This is deliberately NOT the same as {@link rpIdUsableForOrigin}. That answers
5871
+ * a raw WebAuthn question and is `false` for a missing rpId. But a factor with
5872
+ * NO recorded rpId predates per-origin enrolment, and `loginWithPasskey`
5873
+ * resolves it as `params.rpId ?? host` — so it IS usable, from anywhere.
5874
+ *
5875
+ * Callers deciding "should I even try a passkey here?" must use this one.
5876
+ * Reaching for `rpIdUsableForOrigin` instead silently excludes every legacy
5877
+ * passkey holder, which has locked real users out of production twice.
5878
+ *
5879
+ * The `?? host` fallback applies to nullish only, matching `loginWithPasskey`:
5880
+ * an empty-string rpId stays unusable rather than resolving to the host.
5881
+ */
5882
+ function passkeyUsableFromOrigin(rpId, host) {
5883
+ const h = host ?? defaultRpId();
5884
+ if (!h)
5885
+ return false;
5886
+ if (rpId == null)
5887
+ return true;
5888
+ return rpIdUsableForOrigin(rpId, h);
5889
+ }
5864
5890
  /** Thrown when the account's passkey belongs to a different origin. Callers
5865
5891
  * should treat this as "no passkey here" and fall back to another factor. */
5866
5892
  class PasskeyOriginError extends Error {
@@ -6089,13 +6115,13 @@ async function persistDeviceIdentity(v) {
6089
6115
  const plain = TE.encode(JSON.stringify({
6090
6116
  username: v.username,
6091
6117
  commitment: v.commitment,
6092
- seed: toBase64(v.seed),
6118
+ seed: toBase64$1(v.seed),
6093
6119
  pairedAt: v.pairedAt,
6094
6120
  }));
6095
6121
  const bridge = keystoreBridge();
6096
6122
  let envelope;
6097
6123
  if (bridge) {
6098
- envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64(plain)) };
6124
+ envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64$1(plain)) };
6099
6125
  }
6100
6126
  else {
6101
6127
  const key = await wrapKey(true);
@@ -6103,7 +6129,7 @@ async function persistDeviceIdentity(v) {
6103
6129
  throw new DeviceStoreUnavailableError("no IndexedDB for the wrapping key");
6104
6130
  const iv = crypto.getRandomValues(new Uint8Array(12));
6105
6131
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plain));
6106
- envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
6132
+ envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
6107
6133
  }
6108
6134
  blobs.set(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(envelope));
6109
6135
  }
@@ -6127,18 +6153,18 @@ async function loadDeviceIdentity() {
6127
6153
  if (envelope.ks === 1) {
6128
6154
  if (!bridge)
6129
6155
  return null; // sealed by the native shell; nothing else can open it
6130
- plain = fromBase64(await bridge.unwrap(envelope.ct));
6156
+ plain = fromBase64$1(await bridge.unwrap(envelope.ct));
6131
6157
  }
6132
6158
  else {
6133
6159
  const key = await wrapKey(false);
6134
6160
  if (!key || !envelope.iv)
6135
6161
  return null;
6136
- plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct)));
6162
+ plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct)));
6137
6163
  }
6138
6164
  const parsed = JSON.parse(TD.decode(plain));
6139
6165
  if (!parsed?.username || !parsed?.commitment || !parsed?.seed)
6140
6166
  return null;
6141
- const seed = fromBase64(parsed.seed);
6167
+ const seed = fromBase64$1(parsed.seed);
6142
6168
  if (seed.length !== 32)
6143
6169
  return null;
6144
6170
  return {
@@ -6172,7 +6198,7 @@ async function clearDeviceIdentity() {
6172
6198
  async function fingerprintOf(publicKey) {
6173
6199
  const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
6174
6200
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
6175
- return toBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6201
+ return toBase64$1(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
6176
6202
  }
6177
6203
  async function loadOrCreateIdentityKey() {
6178
6204
  const vault = keyVault();
@@ -6812,9 +6838,11 @@ class HostedAuth {
6812
6838
  /**
6813
6839
  * The account's paired devices. Session-authed.
6814
6840
  *
6815
- * UI copy note (spec §10.6): removing a device signs it out and forces a full
6816
- * re-approval, but it **does not take back the encryption key the device
6817
- * already holds**. Say so — users will assume otherwise.
6841
+ * UI copy note (spec §10.6): removing a device forces a full re-approval next
6842
+ * time, but be careful what you promise. It does NOT sign the device out —
6843
+ * the server returns `session_revoked: false`, and that session runs until it
6844
+ * expires — and it cannot take back the master seed the device already holds.
6845
+ * Users assume "remove" means "locked out immediately"; it does not.
6818
6846
  */
6819
6847
  async listPairedDevices() {
6820
6848
  const res = await this.deviceApi("GET", "/api/auth/devices", undefined, true);
@@ -6832,7 +6860,15 @@ class HostedAuth {
6832
6860
  };
6833
6861
  });
6834
6862
  }
6835
- /** Remove a paired device and revoke its session. Session-authed. */
6863
+ /**
6864
+ * Un-pair a device. Session-authed.
6865
+ *
6866
+ * It stops being a "known" device, so pairing it again needs full approval
6867
+ * with a fresh factor. It does NOT end that device's current session — the
6868
+ * server reports `session_revoked: false` — and it cannot take back the
6869
+ * master seed the device already holds. Do not write UI copy promising an
6870
+ * immediate sign-out; users will assume it and be wrong.
6871
+ */
6836
6872
  async revokePairedDevice(id) {
6837
6873
  if (!id)
6838
6874
  throw new DevicePairingError("error", { message: "revokePairedDevice: `id` is required." });
@@ -7228,6 +7264,8 @@ class VaultUnavailableError extends Error {
7228
7264
  * ZK identity auth. One instance per {@link Client}; reads/writes the shared
7229
7265
  * {@link SessionState}.
7230
7266
  */
7267
+ /** HKDF label separating a device wrap key from every other use of the seed. */
7268
+ const DEVICE_WRAP_INFO = "muhkoo-device-key-v1";
7231
7269
  class ZkAuth {
7232
7270
  deps;
7233
7271
  constructor(deps) {
@@ -7405,7 +7443,34 @@ class ZkAuth {
7405
7443
  return { user, unlocked: true };
7406
7444
  try {
7407
7445
  const factors = await this.listFactors();
7408
- if (factors.some((f) => f.type === "passkey")) {
7446
+ // Only auto-attempt when a passkey is usable from THIS origin.
7447
+ //
7448
+ // Passkeys are per-origin, and an account's passkey may belong to a
7449
+ // different host entirely (hosted-auth enrolment records
7450
+ // `auth.muhkoo.dev`, so a user who added a passkey there is
7451
+ // mismatched on every app host). Attempting anyway means
7452
+ // `loginWithPasskey` does an unauthenticated vault read for a
7453
+ // passkey this origin doesn't have. Today that returns a decoy with
7454
+ // no `prfSalt`, so it fails the guard before touching WebAuthn — the
7455
+ // silence is incidental. Once the decoy is reshaped to be
7456
+ // indistinguishable (it must carry `prfSalt` to stop being an
7457
+ // account-enumeration oracle) that same call would sail through and
7458
+ // fire a modal `navigator.credentials.get()` at page load, on every
7459
+ // boot, for a credential that does not exist. `resume()` is awaited
7460
+ // inside every app's readiness gate, so that dialog lands on the
7461
+ // splash screen before the user has asked for anything.
7462
+ //
7463
+ // `listFactors` is session-authenticated and returns real records,
7464
+ // so decoys never appear here — this reads true rpIds.
7465
+ //
7466
+ // `passkeyUsableFromOrigin`, NOT `rpIdUsableForOrigin`: a factor
7467
+ // with no recorded rpId predates per-origin enrolment and is still
7468
+ // usable (`loginWithPasskey` resolves it as `params.rpId ?? host`).
7469
+ // The raw predicate is false for undefined, so using it here would
7470
+ // skip exactly those users — the regression that locked legacy
7471
+ // passkey holders out of production twice.
7472
+ const host = defaultRpId();
7473
+ if (factors.some((f) => f.type === "passkey" && passkeyUsableFromOrigin(f.rpId, host))) {
7409
7474
  await this.loginWithPasskey(user.username);
7410
7475
  }
7411
7476
  }
@@ -7492,7 +7557,7 @@ class ZkAuth {
7492
7557
  const { verifyToken, email: verified } = await this.deps.auth.emailOtpVerify({ token, code });
7493
7558
  const input = await emailFactorInput(username, verified);
7494
7559
  const { blind, blinded } = gatedBlind(input);
7495
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "enroll", verifyToken);
7560
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "enroll", verifyToken);
7496
7561
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
7497
7562
  const wrapped = await wrapSeed(seed, key);
7498
7563
  await this.deps.auth.vaultPutFactor(token, { id: "email", type: "email", wrap: wrapped.ct, iv: wrapped.iv, params: { email: verified }, createdAt: Date.now() }, verifyToken);
@@ -7520,7 +7585,7 @@ class ZkAuth {
7520
7585
  }
7521
7586
  const input = await emailFactorInput(username, email);
7522
7587
  const { blind, blinded } = gatedBlind(input);
7523
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "recover", verifyToken);
7588
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "recover", verifyToken);
7524
7589
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
7525
7590
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
7526
7591
  const identity = await deriveIdentityFromSeed(seed);
@@ -7548,7 +7613,7 @@ class ZkAuth {
7548
7613
  const { verifyToken, sub, emailHint } = await this.deps.auth.googleVerify({ idToken, token });
7549
7614
  const input = await googleFactorInput(username, sub);
7550
7615
  const { blind, blinded } = gatedBlind(input);
7551
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "enroll", verifyToken);
7616
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "enroll", verifyToken);
7552
7617
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
7553
7618
  const wrapped = await wrapSeed(seed, key);
7554
7619
  await this.deps.auth.vaultPutFactor(token, { id: "google", type: "google", wrap: wrapped.ct, iv: wrapped.iv, params: { sub, emailHint: emailHint ?? undefined }, createdAt: Date.now() }, verifyToken);
@@ -7566,7 +7631,7 @@ class ZkAuth {
7566
7631
  throw new Error("No Google account is linked to this username.");
7567
7632
  const input = await googleFactorInput(username, sub);
7568
7633
  const { blind, blinded } = gatedBlind(input);
7569
- const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "recover", verifyToken);
7634
+ const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "recover", verifyToken);
7570
7635
  const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
7571
7636
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
7572
7637
  const identity = await deriveIdentityFromSeed(seed);
@@ -7647,11 +7712,11 @@ class ZkAuth {
7647
7712
  const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
7648
7713
  const wrapped = await wrapSeed(seed, key);
7649
7714
  await this.deps.auth.vaultPutFactor(token, {
7650
- id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
7715
+ id: `passkey:${toBase64$1(credentialId).slice(0, 16)}`,
7651
7716
  type: "passkey",
7652
7717
  wrap: wrapped.ct,
7653
7718
  iv: wrapped.iv,
7654
- params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
7719
+ params: { credentialId: toBase64$1(credentialId), prfSalt: toBase64$1(prfSalt), rpId },
7655
7720
  label: opts?.label ?? "Passkey",
7656
7721
  createdAt: Date.now(),
7657
7722
  });
@@ -7676,7 +7741,7 @@ class ZkAuth {
7676
7741
  // to enroll a passkey for this origin.
7677
7742
  if (!rpIdUsableForOrigin(rpId, host))
7678
7743
  throw new PasskeyOriginError();
7679
- const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64(params.credentialId), fromBase64(params.prfSalt));
7744
+ const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64$1(params.credentialId), fromBase64$1(params.prfSalt));
7680
7745
  const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
7681
7746
  const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
7682
7747
  const identity = await deriveIdentityFromSeed(seed);
@@ -7755,7 +7820,107 @@ class ZkAuth {
7755
7820
  */
7756
7821
  get seedBase64() {
7757
7822
  const seed = this.deps.session.seed;
7758
- return seed ? toBase64(seed) : null;
7823
+ return seed ? toBase64$1(seed) : null;
7824
+ }
7825
+ /**
7826
+ * Pair this machine, so it can unlock the account later without a password.
7827
+ *
7828
+ * Generates a random 32-byte key, wraps the master seed under it, and stores
7829
+ * only the CIPHERTEXT in the vault as a `device` factor. The key is returned
7830
+ * for the caller to persist locally and is never sent anywhere.
7831
+ *
7832
+ * The split is the point: the seed itself never lands on disk, and the
7833
+ * server holds a blob it cannot open. Revoking the factor
7834
+ * ({@link revokeDevice}) makes the local key inert immediately — which a
7835
+ * stored master seed can never be, because nothing can reach out and
7836
+ * un-store it.
7837
+ *
7838
+ * Returns the factor id and the key, both base64. Losing the key just means
7839
+ * re-pairing; leaking it is equivalent to leaking the seed for as long as
7840
+ * the factor exists, so store it with the tightest permissions available.
7841
+ */
7842
+ async enrollDevice(label) {
7843
+ const seed = this.deps.session.seed;
7844
+ const token = this.deps.session.token;
7845
+ if (!seed || !token)
7846
+ throw new Error("Sign in first to pair this device.");
7847
+ const keyBytes = randomSeed(); // 32 bytes of CSPRNG
7848
+ const key = await wrapKeyFromBytes(keyBytes, DEVICE_WRAP_INFO);
7849
+ const wrapped = await wrapSeed(seed, key);
7850
+ // 128 bits of id, so reading a factor by id is not an enumeration surface.
7851
+ const factorId = `device:${toBase64$1(randomSeed()).replace(/[+/=]/g, "").slice(0, 22)}`;
7852
+ await this.deps.auth.vaultPutFactor(token, {
7853
+ id: factorId,
7854
+ type: "device",
7855
+ wrap: wrapped.ct,
7856
+ iv: wrapped.iv,
7857
+ label,
7858
+ createdAt: Date.now(),
7859
+ });
7860
+ return { factorId, deviceKey: toBase64$1(keyBytes) };
7861
+ }
7862
+ /**
7863
+ * Unlock using a key stored by {@link enrollDevice}.
7864
+ *
7865
+ * Fetches the wrapped seed and opens it locally. Establishes the encryption
7866
+ * identity only — pair it with a session for calls that need one.
7867
+ *
7868
+ * Throws distinguishably: a REVOKED factor is gone from the vault and says
7869
+ * so, where a wrong key fails to decrypt. A machine that has had its access
7870
+ * withdrawn should be able to tell you that, not just "decryption failed".
7871
+ */
7872
+ async unlockWithDevice(username, factorId, deviceKey) {
7873
+ const { factor } = await this.deps.auth.vaultRead(username, "device", undefined, undefined, factorId);
7874
+ if (!factor) {
7875
+ throw new Error("This device is no longer paired — its access was revoked. Sign in again to re-pair.");
7876
+ }
7877
+ if (!factor.wrap || !factor.iv)
7878
+ throw new Error("The stored device factor is incomplete; re-pair this device.");
7879
+ const key = await wrapKeyFromBytes(fromBase64$1(deviceKey), DEVICE_WRAP_INFO);
7880
+ const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
7881
+ this.deps.session.setSeed(seed);
7882
+ }
7883
+ /** Paired machines, newest first. Metadata only — never the ciphertext. */
7884
+ async listDevices() {
7885
+ const token = this.deps.session.token;
7886
+ if (!token)
7887
+ throw new Error("Sign in first to list paired devices.");
7888
+ const { factors } = await this.deps.auth.vaultFactors(token);
7889
+ return factors
7890
+ .filter((f) => f.type === "device")
7891
+ .map((f) => ({ id: f.id, label: f.label, createdAt: f.createdAt }))
7892
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
7893
+ }
7894
+ /** Withdraw a machine's access. Its stored key stops working immediately. */
7895
+ async revokeDevice(factorId) {
7896
+ const token = this.deps.session.token;
7897
+ if (!token)
7898
+ throw new Error("Sign in first to revoke a device.");
7899
+ const { deleted } = await this.deps.auth.vaultDeleteFactor(token, factorId);
7900
+ return deleted;
7901
+ }
7902
+ /**
7903
+ * Restore an unlocked identity from a seed you already hold.
7904
+ *
7905
+ * The inverse of {@link seedBase64}, for hosts that persist the seed
7906
+ * themselves rather than re-deriving it from a password: a CLI holding it in
7907
+ * a 0600 file, a server-side tool, a test. Without this the seed is a
7908
+ * one-way export and anything outside a browser has to run a full ZK login —
7909
+ * circuits download and proof — on every single invocation.
7910
+ *
7911
+ * Establishes the ENCRYPTION identity only. It does not authenticate: pair
7912
+ * it with a session token (`sessionStore`) for calls that need one.
7913
+ *
7914
+ * Treat a persisted seed as the master credential it is. It is not scoped
7915
+ * and cannot be revoked server-side the way a session token can — rotating
7916
+ * it means moving the identity.
7917
+ */
7918
+ unlockWithSeed(seedBase64) {
7919
+ const seed = fromBase64$1(seedBase64);
7920
+ if (seed.length !== 32) {
7921
+ throw new Error(`auth.zk.unlockWithSeed: expected a 32-byte seed, got ${seed.length}`);
7922
+ }
7923
+ this.deps.session.setSeed(seed);
7759
7924
  }
7760
7925
  // -------------------------------------------------------------------------
7761
7926
  /** Poseidon commitment for an identity — the value the server stores. */
@@ -7774,8 +7939,8 @@ class ZkAuth {
7774
7939
  async passwordWrapKey(username, password) {
7775
7940
  const pre = passwordPreHash(username, password);
7776
7941
  const { blind, blinded } = oprfBlind(pre);
7777
- const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
7778
- return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
7942
+ const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64$1(blinded));
7943
+ return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64$1(evaluated)));
7779
7944
  }
7780
7945
  /**
7781
7946
  * Try to recover the master seed from the password factor.
@@ -7881,16 +8046,16 @@ class StorageCipher {
7881
8046
  /** Seal a JSON-serializable value into an {@link EncryptedEnvelope}. */
7882
8047
  async encrypt(value) {
7883
8048
  const key = await this.keyPromise;
7884
- const iv = randomBytes(AES_GCM_IV_BYTES);
8049
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
7885
8050
  const plaintext = utf8Encode(JSON.stringify(value));
7886
8051
  const ciphertext = await encryptAesGcm(key, iv, plaintext);
7887
- return { _enc: "a256gcm", iv: toBase64$1(iv), ct: toBase64$1(ciphertext) };
8052
+ return { _enc: "a256gcm", iv: toBase64$2(iv), ct: toBase64$2(ciphertext) };
7888
8053
  }
7889
8054
  /** Open an {@link EncryptedEnvelope} back into the original value. */
7890
8055
  async decrypt(envelope) {
7891
8056
  const key = await this.keyPromise;
7892
- const iv = fromBase64$1(envelope.iv);
7893
- const ciphertext = fromBase64$1(envelope.ct);
8057
+ const iv = fromBase64$2(envelope.iv);
8058
+ const ciphertext = fromBase64$2(envelope.ct);
7894
8059
  const plaintext = await decryptAesGcm(key, iv, ciphertext);
7895
8060
  return JSON.parse(utf8Decode(plaintext));
7896
8061
  }
@@ -7929,6 +8094,8 @@ class StorageCipher {
7929
8094
  */
7930
8095
  class KvNamespace {
7931
8096
  deps;
8097
+ /** One personal-space socket, shared by every subscriber. */
8098
+ feed = null;
7932
8099
  cipher = null;
7933
8100
  cipherIdentity = null;
7934
8101
  constructor(deps) {
@@ -8053,22 +8220,64 @@ class KvNamespace {
8053
8220
  if (!token)
8054
8221
  throw new Error("client.kv.on: not signed in.");
8055
8222
  const commitment = this.commitment();
8056
- const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
8223
+ `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
8057
8224
  // Ride a WSTransport rather than a bare socket: it keeps the (otherwise
8058
8225
  // idle) personal-space feed warm with heartbeat pings, auto-reconnects
8059
8226
  // if it drops, and namespaces its events by `id` so this feed never
8060
8227
  // crosses wires with chat/space transports on the shared EventCore bus.
8061
- const transport = new WSTransport({ url, id: `personal:${commitment}` });
8062
- const onMessage = (e) => {
8063
- void this.dispatchChange(e.detail, handler);
8064
- };
8065
- transport.on(EventCoreEvents.MESSAGE, onMessage);
8066
- // Fire-and-forget: the realtime feed is best-effort, and the transport
8067
- // queues nothing it needs before CONNECTED (server→client only).
8068
- void transport.connect();
8228
+ return this.onRaw((raw) => {
8229
+ void this.dispatchChange(raw, handler);
8230
+ });
8231
+ }
8232
+ /**
8233
+ * Subscribe to the personal space's raw change feed.
8234
+ *
8235
+ * Every key this user owns crosses this socket, not only `client.kv`
8236
+ * collections — the VFS keeps its directory records here too. Consumers get
8237
+ * the frame untouched: no decryption, no offline-cache merge, because the
8238
+ * value's meaning depends on which subsystem wrote it.
8239
+ *
8240
+ * The transport is SHARED and reference-counted. `WSTransport` namespaces
8241
+ * its events by `id`, so two transports with the same id would cross wires
8242
+ * on the shared event bus — and a second socket to the same Durable Object
8243
+ * would be pure waste besides.
8244
+ */
8245
+ onRaw(handler) {
8246
+ if (typeof globalThis.WebSocket !== "function") {
8247
+ throw new Error("client.kv.onRaw: no `WebSocket` global available in this runtime.");
8248
+ }
8249
+ const token = this.deps.session.token;
8250
+ if (!token)
8251
+ throw new Error("client.kv.onRaw: not signed in.");
8252
+ const commitment = this.commitment();
8253
+ if (!this.feed) {
8254
+ const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
8255
+ // Ride a WSTransport rather than a bare socket: it keeps the
8256
+ // (otherwise idle) feed warm with heartbeat pings, auto-reconnects
8257
+ // if it drops, and namespaces its events by `id`.
8258
+ const transport = new WSTransport({ url, id: `personal:${commitment}` });
8259
+ const handlers = new Set();
8260
+ const onMessage = (e) => {
8261
+ for (const fn of handlers)
8262
+ fn(e.detail);
8263
+ };
8264
+ transport.on(EventCoreEvents.MESSAGE, onMessage);
8265
+ // Fire-and-forget: the feed is best-effort, and the transport queues
8266
+ // nothing it needs before CONNECTED (server→client only).
8267
+ void transport.connect();
8268
+ this.feed = { transport, handlers, onMessage };
8269
+ }
8270
+ const feed = this.feed;
8271
+ feed.handlers.add(handler);
8069
8272
  return () => {
8070
- transport.off(EventCoreEvents.MESSAGE, onMessage);
8071
- transport.disconnect();
8273
+ feed.handlers.delete(handler);
8274
+ // Last subscriber out closes the socket, so an app that unsubscribes
8275
+ // everything is not left holding an open connection.
8276
+ if (feed.handlers.size === 0 && this.feed === feed) {
8277
+ feed.transport.off(EventCoreEvents.MESSAGE, feed.onMessage);
8278
+ feed.transport.disconnect();
8279
+ this.feed = null;
8280
+ }
8072
8281
  };
8073
8282
  }
8074
8283
  // -------------------------------------------------------------------------
@@ -8510,12 +8719,12 @@ function canonicalMessage(fields) {
8510
8719
  /** Sign the canonical bytes with the sender's ECDSA identity private key. */
8511
8720
  async function signSpaceMessage(canonical, ecdsaPriv) {
8512
8721
  const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
8513
- return toBase64$1(new Uint8Array(sig));
8722
+ return toBase64$2(new Uint8Array(sig));
8514
8723
  }
8515
8724
  /** Verify a signature over the canonical bytes against the sender's ECDSA key. */
8516
8725
  async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
8517
8726
  try {
8518
- return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$1(sigB64), utf8Encode(canonical));
8727
+ return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$2(sigB64), utf8Encode(canonical));
8519
8728
  }
8520
8729
  catch {
8521
8730
  return false;
@@ -8546,13 +8755,13 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
8546
8755
  const eph = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
8547
8756
  const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: recipientEcdhPub }, eph.privateKey, P384_BITS));
8548
8757
  const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${epoch}`));
8549
- const iv = randomBytes(AES_GCM_IV_BYTES);
8758
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
8550
8759
  const ciphertext = await encryptAesGcm(wrapKey, iv, kSpace);
8551
8760
  return {
8552
8761
  epoch,
8553
8762
  ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
8554
- iv: toBase64$1(iv),
8555
- ciphertext: toBase64$1(ciphertext),
8763
+ iv: toBase64$2(iv),
8764
+ ciphertext: toBase64$2(ciphertext),
8556
8765
  alg: WRAP_ALG,
8557
8766
  };
8558
8767
  }
@@ -8564,23 +8773,23 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
8564
8773
  const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
8565
8774
  const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
8566
8775
  const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
8567
- return decryptAesGcm(wrapKey, fromBase64$1(wrapped.iv), fromBase64$1(wrapped.ciphertext));
8776
+ return decryptAesGcm(wrapKey, fromBase64$2(wrapped.iv), fromBase64$2(wrapped.ciphertext));
8568
8777
  }
8569
8778
  // ---------------------------------------------------------------------------
8570
8779
  // Message sealing
8571
8780
  // ---------------------------------------------------------------------------
8572
8781
  /** Seal an already-serialized message string under the group key. */
8573
8782
  async function sealSerialized(kSpace, serialized) {
8574
- const iv = randomBytes(AES_GCM_IV_BYTES);
8783
+ const iv = randomBytes(AES_GCM_IV_BYTES$1);
8575
8784
  const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
8576
- return { iv: toBase64$1(iv), ciphertext: toBase64$1(ciphertext) };
8785
+ return { iv: toBase64$2(iv), ciphertext: toBase64$2(ciphertext) };
8577
8786
  }
8578
8787
  /**
8579
8788
  * Open a sealed payload back into its serialized message string. Throws (GCM
8580
8789
  * tag failure) on the wrong group key or a tampered ciphertext.
8581
8790
  */
8582
8791
  async function openSerialized(kSpace, iv, ciphertext) {
8583
- const plaintext = await decryptAesGcm(kSpace, fromBase64$1(iv), fromBase64$1(ciphertext));
8792
+ const plaintext = await decryptAesGcm(kSpace, fromBase64$2(iv), fromBase64$2(ciphertext));
8584
8793
  return utf8Decode(plaintext);
8585
8794
  }
8586
8795
 
@@ -11084,6 +11293,16 @@ class SpaceNamespace {
11084
11293
  * Create a new space: generate its keypair (public key = id), register
11085
11294
  * metadata, mint the initial group key, and connect. Returns the open Space.
11086
11295
  */
11296
+ /**
11297
+ * Create a space.
11298
+ *
11299
+ * `messaging: false` registers the space and stops there — no group-key
11300
+ * bootstrap and no WebSocket. Use it for a space that only ever holds
11301
+ * FILES: each chunk carries its own key inside its manifest, so the group
11302
+ * keyring buys nothing, and opening a socket makes the write path depend on
11303
+ * a live connection. That dependency is wrong for a CLI, and it is what made
11304
+ * the first VFS write hang forever instead of failing.
11305
+ */
11087
11306
  async createSpace(opts = {}) {
11088
11307
  const historyPolicy = opts.historyPolicy ?? "static";
11089
11308
  const identity = await generateSpaceIdentity();
@@ -11092,7 +11311,8 @@ class SpaceNamespace {
11092
11311
  // its first member — so the metadata POST must carry the user session.
11093
11312
  await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
11094
11313
  const space = await this.build(identity.id, historyPolicy);
11095
- await space.create();
11314
+ if (opts.messaging !== false)
11315
+ await space.create();
11096
11316
  return space;
11097
11317
  }
11098
11318
  /** Join an existing space by id (its public key). Resolves once a key arrives. */
@@ -11289,7 +11509,7 @@ class SpaceNamespace {
11289
11509
  console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
11290
11510
  if (this.deps.chatKeys && seed) {
11291
11511
  try {
11292
- await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
11512
+ await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
11293
11513
  }
11294
11514
  catch (e) {
11295
11515
  if (staging)
@@ -11594,6 +11814,894 @@ class AccessTokensNamespace {
11594
11814
  }
11595
11815
  }
11596
11816
 
11817
+ /**
11818
+ * Wire types for the VFS metadata layer.
11819
+ *
11820
+ * ## What is stored where
11821
+ *
11822
+ * The filesystem is a graph of **directory records**, one per directory, each
11823
+ * held under a personal-space KV key and encrypted client-side. A record lists
11824
+ * only its immediate children — so listing a directory is one fetch, and you
11825
+ * only ever pay for the subtrees you actually open.
11826
+ *
11827
+ * File CONTENT is not here. Bytes are written with `client.storage.writeFile`
11828
+ * exactly as any other file — into a real Space, with a real gated manifest —
11829
+ * and the entry keeps the returned `FileManifest` as its handle.
11830
+ * `client.storage.readByManifest(manifest)` then reconstructs the bytes from
11831
+ * the content-addressed shard store with no space membership or directory
11832
+ * lookup needed, which is what makes a file shareable by handing over the
11833
+ * handle alone.
11834
+ *
11835
+ * This supersedes the flat personal mirror `writeFile` already maintains
11836
+ * (`{spaceId, manifest}` keyed by manifest id): the same metadata, in the same
11837
+ * personal space, arranged as a tree you can actually navigate.
11838
+ *
11839
+ * ## Why directories have stable random ids, not content hashes
11840
+ *
11841
+ * A git-style tree keys each directory by the hash of its contents, so writing
11842
+ * one file rewrites every ancestor up to the root. Here a directory's id is
11843
+ * random and permanent, so a file write touches exactly ONE record — its
11844
+ * parent — and renaming or moving a directory edits only the parent entry
11845
+ * while the whole subtree underneath stays byte-identical and untouched.
11846
+ *
11847
+ * ## Why a directory's key lives in its parent
11848
+ *
11849
+ * Only the root key is derived from the master seed. Every other directory has
11850
+ * a random key stored in its parent's entry, which chains capabilities
11851
+ * downward: handing someone `{id, key}` for `/apps/my-app` lets them walk that
11852
+ * subtree and nothing else, because no key can be derived upward. That is the
11853
+ * seam group-sharing hangs off later without re-encrypting the filesystem.
11854
+ */
11855
+ /** Personal-space KV key holding a directory record. */
11856
+ const dirKey = (id) => `vfs/d/${id}`;
11857
+ /** Personal-space KV key holding one file's prior versions. */
11858
+ const historyKey = (id) => `vfs/h/${id}`;
11859
+ /** The root directory's id is fixed — it is the one node reached without a parent. */
11860
+ const ROOT_ID = "root";
11861
+ /** HKDF label for the seed-derived root key. Sits alongside `muhkoo-storage-v1`. */
11862
+ const VFS_ROOT_INFO = "muhkoo-vfs-v1";
11863
+ /** Raised when the master seed is not held, so the root key cannot be derived. */
11864
+ class VfsLockedError extends Error {
11865
+ constructor() {
11866
+ // Named rather than generic because the caller's remedy is specific and
11867
+ // not guessable from a failed decrypt: re-authenticate to recover the
11868
+ // seed. A page reload alone loses it — it is memory-only, never stored.
11869
+ super("VFS is locked: the master seed is not in memory. Sign in again to unlock.");
11870
+ this.name = "VfsLockedError";
11871
+ }
11872
+ }
11873
+ /** Raised for a path that does not exist. */
11874
+ class VfsNotFoundError extends Error {
11875
+ constructor(path) {
11876
+ super(`VFS: ${path} does not exist`);
11877
+ this.name = "VfsNotFoundError";
11878
+ }
11879
+ }
11880
+ /** Raised when an operation would clobber or misuse an existing entry. */
11881
+ class VfsConflictError extends Error {
11882
+ constructor(message) {
11883
+ super(`VFS: ${message}`);
11884
+ this.name = "VfsConflictError";
11885
+ }
11886
+ }
11887
+
11888
+ /**
11889
+ * Sealing for VFS metadata records.
11890
+ *
11891
+ * Every directory and history record is AES-256-GCM encrypted before it leaves
11892
+ * the process, so the server holds ciphertext and never learns a filename, a
11893
+ * directory shape, or a file size. The manifests inside carry their own chunk
11894
+ * keys, which is why the metadata must be sealed too: a readable directory
11895
+ * record would hand over the keys to its files.
11896
+ */
11897
+ const AES_GCM_IV_BYTES = 12;
11898
+ const AES_KEY_BYTES = 32;
11899
+ function toBase64(bytes) {
11900
+ let s = "";
11901
+ for (const b of bytes)
11902
+ s += String.fromCharCode(b);
11903
+ return btoa(s);
11904
+ }
11905
+ function fromBase64(b64) {
11906
+ const s = atob(b64);
11907
+ const out = new Uint8Array(s.length);
11908
+ for (let i = 0; i < s.length; i++)
11909
+ out[i] = s.charCodeAt(i);
11910
+ return out;
11911
+ }
11912
+ /** A fresh random key for a newly created directory. */
11913
+ function newDirKey() {
11914
+ return randomBytes(AES_KEY_BYTES);
11915
+ }
11916
+ /**
11917
+ * A fresh random id.
11918
+ *
11919
+ * Ids are opaque and never derived from names or contents — a predictable id
11920
+ * would leak the directory structure through the KV key, which is the one part
11921
+ * of a record the server does see.
11922
+ */
11923
+ function newId() {
11924
+ return toBase64(randomBytes(16)).replace(/[+/=]/g, "").slice(0, 22);
11925
+ }
11926
+ /** The root key, derived from the master seed. The only key not stored in a parent. */
11927
+ async function deriveRootKey(seed) {
11928
+ return deriveBitsHkdf(seed, VFS_ROOT_INFO, AES_KEY_BYTES);
11929
+ }
11930
+ async function seal(key, value) {
11931
+ const iv = randomBytes(AES_GCM_IV_BYTES);
11932
+ const plaintext = new TextEncoder().encode(JSON.stringify(value));
11933
+ const ct = await encryptAesGcm(key, iv, plaintext);
11934
+ return { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
11935
+ }
11936
+ /**
11937
+ * Open a sealed record. Returns null for anything that is not a well-formed
11938
+ * envelope, so a missing or corrupt record reads as "absent" rather than
11939
+ * throwing — the caller can then rebuild it. A WRONG KEY still throws, because
11940
+ * silently treating an undecryptable directory as empty would present the user
11941
+ * with an empty filesystem and then let a write overwrite the real one.
11942
+ */
11943
+ async function unseal(key, record) {
11944
+ if (!record || typeof record !== "object")
11945
+ return null;
11946
+ const r = record;
11947
+ if (typeof r.iv !== "string" || typeof r.ct !== "string")
11948
+ return null;
11949
+ const plaintext = await decryptAesGcm(key, fromBase64(r.iv), fromBase64(r.ct));
11950
+ return JSON.parse(new TextDecoder().decode(plaintext));
11951
+ }
11952
+
11953
+ /**
11954
+ * Absolute POSIX paths for the VFS.
11955
+ *
11956
+ * A path is a lookup route, not a stored value: nothing in the metadata records
11957
+ * a full path, only a name inside its parent directory. That is what makes
11958
+ * renaming or moving a directory a single-record edit instead of a rewrite of
11959
+ * every descendant.
11960
+ */
11961
+ /** Absolute, single leading slash, no `.` / `..`, no trailing slash. Root is `/`. */
11962
+ function normalizePath(input) {
11963
+ const out = [];
11964
+ for (const part of input.split("/")) {
11965
+ if (part === "" || part === ".")
11966
+ continue;
11967
+ if (part === "..")
11968
+ out.pop();
11969
+ else
11970
+ out.push(part);
11971
+ }
11972
+ return "/" + out.join("/");
11973
+ }
11974
+ /** Path split into its segments. `/` yields `[]`. */
11975
+ function segments(path) {
11976
+ return normalizePath(path).split("/").filter(Boolean);
11977
+ }
11978
+ /**
11979
+ * Resolve `path` against a working directory, POSIX-style.
11980
+ *
11981
+ * An absolute path ignores the cwd; a relative one hangs off it; `..` climbs
11982
+ * and stops at the root rather than escaping it. This is the single place
11983
+ * relativity is interpreted, so every VFS method behaves the same way — a
11984
+ * filesystem where `cat a.txt` and `list("a")` disagreed about what "here"
11985
+ * means would be worse than having no cwd at all.
11986
+ */
11987
+ function resolveFrom(cwd, path) {
11988
+ if (path.startsWith("/"))
11989
+ return normalizePath(path);
11990
+ return normalizePath(`${cwd}/${path}`);
11991
+ }
11992
+ function dirname(path) {
11993
+ const p = normalizePath(path);
11994
+ const i = p.lastIndexOf("/");
11995
+ return i <= 0 ? "/" : p.slice(0, i);
11996
+ }
11997
+ function basename(path) {
11998
+ const p = normalizePath(path);
11999
+ return p.slice(p.lastIndexOf("/") + 1);
12000
+ }
12001
+ function join(...parts) {
12002
+ return normalizePath(parts.join("/"));
12003
+ }
12004
+ /**
12005
+ * Is `path` inside `dir` (at any depth)? A directory is never "under" itself,
12006
+ * which is what makes it usable as the guard against moving a directory into
12007
+ * its own subtree.
12008
+ */
12009
+ function isUnder(path, dir) {
12010
+ const p = normalizePath(path);
12011
+ const d = normalizePath(dir);
12012
+ if (p === d)
12013
+ return false;
12014
+ return d === "/" ? true : p.startsWith(d + "/");
12015
+ }
12016
+ /**
12017
+ * Reject names that would make a path ambiguous or unrepresentable.
12018
+ *
12019
+ * `/` would forge a nesting level, and `.` / `..` would resolve to a different
12020
+ * directory on the next lookup — a file literally named `..` could never be
12021
+ * addressed again.
12022
+ */
12023
+ function assertValidName(name) {
12024
+ if (!name)
12025
+ throw new Error("VFS: name cannot be empty");
12026
+ if (name === "." || name === "..")
12027
+ throw new Error(`VFS: "${name}" is not a usable name`);
12028
+ if (name.includes("/"))
12029
+ throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
12030
+ }
12031
+ /**
12032
+ * MIME type for a filename.
12033
+ *
12034
+ * Stored with the content rather than guessed at read time, because whatever
12035
+ * eventually serves these bytes — a preview, a static host — sends this as the
12036
+ * `Content-Type`, and a browser will refuse to run a module or apply a
12037
+ * stylesheet labelled `application/octet-stream`.
12038
+ */
12039
+ function contentTypeFor(name) {
12040
+ const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
12041
+ switch (ext) {
12042
+ case "html":
12043
+ case "htm": return "text/html; charset=utf-8";
12044
+ case "css": return "text/css; charset=utf-8";
12045
+ case "js":
12046
+ case "mjs":
12047
+ case "jsx": return "text/javascript; charset=utf-8";
12048
+ case "ts":
12049
+ case "tsx": return "text/typescript; charset=utf-8";
12050
+ case "json": return "application/json; charset=utf-8";
12051
+ case "md": return "text/markdown; charset=utf-8";
12052
+ case "txt": return "text/plain; charset=utf-8";
12053
+ case "svg": return "image/svg+xml";
12054
+ case "png": return "image/png";
12055
+ case "jpg":
12056
+ case "jpeg": return "image/jpeg";
12057
+ case "gif": return "image/gif";
12058
+ case "webp": return "image/webp";
12059
+ case "avif": return "image/avif";
12060
+ case "ico": return "image/x-icon";
12061
+ case "woff2": return "font/woff2";
12062
+ case "woff": return "font/woff";
12063
+ case "wasm": return "application/wasm";
12064
+ case "map": return "application/json";
12065
+ default: return "application/octet-stream";
12066
+ }
12067
+ }
12068
+
12069
+ /**
12070
+ * Glob matching for VFS paths.
12071
+ *
12072
+ * A deliberately small dialect — `*`, `**`, `?`, and `{a,b}` — because these
12073
+ * cover what a project actually asks for (`**\/*.tsx`, `src/*.css`) and every
12074
+ * addition beyond them is a new way for a pattern to mean something the caller
12075
+ * did not expect.
12076
+ */
12077
+ /** `*` stops at a separator; `**` crosses them. That distinction is the whole point. */
12078
+ function globToRegExp(pattern) {
12079
+ let out = "";
12080
+ for (let i = 0; i < pattern.length; i++) {
12081
+ const c = pattern[i];
12082
+ if (c === "*") {
12083
+ if (pattern[i + 1] === "*") {
12084
+ // `**/` should also match zero directories, so `/**/x` finds `/x`.
12085
+ if (pattern[i + 2] === "/") {
12086
+ out += "(?:.*/)?";
12087
+ i += 2;
12088
+ }
12089
+ else {
12090
+ out += ".*";
12091
+ i += 1;
12092
+ }
12093
+ }
12094
+ else {
12095
+ out += "[^/]*";
12096
+ }
12097
+ }
12098
+ else if (c === "?") {
12099
+ out += "[^/]";
12100
+ }
12101
+ else if (c === "{") {
12102
+ const close = pattern.indexOf("}", i);
12103
+ if (close === -1) {
12104
+ out += "\\{";
12105
+ }
12106
+ else {
12107
+ const alts = pattern.slice(i + 1, close).split(",");
12108
+ out += `(?:${alts.map(escapeLiteral).join("|")})`;
12109
+ i = close;
12110
+ }
12111
+ }
12112
+ else {
12113
+ out += escapeLiteral(c);
12114
+ }
12115
+ }
12116
+ return new RegExp(`^${out}$`);
12117
+ }
12118
+ function escapeLiteral(s) {
12119
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12120
+ }
12121
+
12122
+ /**
12123
+ * `client.vfs` — a real filesystem over Muhkoo primitives.
12124
+ *
12125
+ * Metadata is a graph of encrypted directory records in the user's personal
12126
+ * space; file content is chunked, encrypted and stored in the global
12127
+ * content-addressed shard store, referenced by manifest. See `types.ts` for
12128
+ * why directories carry stable random ids and why each key lives in its parent.
12129
+ *
12130
+ * ## Consistency
12131
+ *
12132
+ * Every mutation is a read-modify-write of exactly ONE directory record, so a
12133
+ * write cannot leave the tree half-updated: either the parent names the new
12134
+ * child or it does not. Two tabs writing different directories never conflict;
12135
+ * two tabs writing the SAME directory resolve last-writer-wins at the personal
12136
+ * space, which carries an HLC per key. Orphaned records (a directory whose
12137
+ * parent entry lost the race) are unreachable rather than corrupting — they
12138
+ * cost storage until a future sweep, which is the trade this shape makes in
12139
+ * exchange for single-record writes.
12140
+ */
12141
+ const DEFAULT_HISTORY_LIMIT = 20;
12142
+ class VfsNamespace {
12143
+ deps;
12144
+ rootKey = null;
12145
+ /**
12146
+ * Decrypted directory records, by id.
12147
+ *
12148
+ * The file tree re-lists constantly while typing, and every miss is a
12149
+ * network round trip plus a decrypt. Invalidated precisely on write rather
12150
+ * than by TTL — a stale directory listing shows files that are not there.
12151
+ */
12152
+ cache = new Map();
12153
+ historyLimit;
12154
+ constructor(deps) {
12155
+ this.deps = deps;
12156
+ this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
12157
+ }
12158
+ /**
12159
+ * The working directory relative paths resolve against.
12160
+ *
12161
+ * Kept here rather than left to each caller so that a CLI, a script and the
12162
+ * SDK all agree on what "here" means — the alternative is every consumer
12163
+ * re-implementing `cd`, and disagreeing about `..`.
12164
+ */
12165
+ _cwd = "/";
12166
+ /** The current working directory. Always absolute. */
12167
+ get cwd() {
12168
+ return this._cwd;
12169
+ }
12170
+ /**
12171
+ * Change the working directory.
12172
+ *
12173
+ * Verifies the target is a real directory, so a typo fails here — where the
12174
+ * message can name the path — rather than turning every later relative path
12175
+ * into a confusing "does not exist".
12176
+ */
12177
+ async cd(path) {
12178
+ const target = this.resolve(path);
12179
+ if (target !== "/") {
12180
+ const found = await this.find(target);
12181
+ if (!found)
12182
+ throw new VfsNotFoundError(target);
12183
+ if (found.entry.kind !== "dir")
12184
+ throw new VfsConflictError(`${target} is not a directory`);
12185
+ }
12186
+ this._cwd = target;
12187
+ return target;
12188
+ }
12189
+ /** Resolve a path against the working directory. Absolute paths pass through. */
12190
+ resolve(path) {
12191
+ return resolveFrom(this._cwd, path);
12192
+ }
12193
+ /** True when the master seed is held, so the root key can be derived. */
12194
+ get unlocked() {
12195
+ return this.deps.seed() !== null;
12196
+ }
12197
+ /**
12198
+ * Drop every cached record.
12199
+ *
12200
+ * Call after signing in as someone else — the cache is keyed by directory
12201
+ * id with no notion of whose filesystem it came from, and serving one
12202
+ * identity's directories to another would be the worst possible bug here.
12203
+ */
12204
+ clearCache() {
12205
+ this.cache.clear();
12206
+ this.rootKey = null;
12207
+ this._cwd = "/"; // a different identity's tree has different directories
12208
+ }
12209
+ /**
12210
+ * Call `handler` when this filesystem changes somewhere else — another tab,
12211
+ * another machine, the CLI.
12212
+ *
12213
+ * Deliberately a NOTIFICATION, not a diff: the frame names a record id, and
12214
+ * a record id is not a path (paths are resolved by walking, and the walk is
12215
+ * what the change may have invalidated). So the cache is dropped and the
12216
+ * caller re-reads whatever it is actually showing. Trying to translate a
12217
+ * record id back into a path would mean maintaining a reverse index that
12218
+ * could itself go stale.
12219
+ *
12220
+ * Returns an unsubscribe function. A runtime with no socket (a CLI) gets a
12221
+ * no-op rather than an error — watching is an optimisation, never required
12222
+ * for correctness.
12223
+ */
12224
+ watch(handler) {
12225
+ if (!this.deps.subscribe)
12226
+ return () => { };
12227
+ return this.deps.subscribe((raw) => {
12228
+ let frame;
12229
+ try {
12230
+ frame = typeof raw === "string" ? JSON.parse(raw) : raw;
12231
+ }
12232
+ catch {
12233
+ return;
12234
+ }
12235
+ if (frame?._t !== "change" || typeof frame.key !== "string")
12236
+ return;
12237
+ if (!frame.key.startsWith("vfs/"))
12238
+ return; // someone else's data on the shared feed
12239
+ // Our own writes echo back. Dropping the cache is cheap and keeping a
12240
+ // stale entry is not, so this does not try to filter them out.
12241
+ this.cache.clear();
12242
+ handler();
12243
+ });
12244
+ }
12245
+ // ---- reads --------------------------------------------------------------
12246
+ async exists(path) {
12247
+ return (await this.find(path)) !== null;
12248
+ }
12249
+ async stat(path) {
12250
+ const found = await this.find(path);
12251
+ if (!found)
12252
+ throw new VfsNotFoundError(this.resolve(path));
12253
+ return toStat(this.resolve(path), found.name, found.entry);
12254
+ }
12255
+ /** Immediate children of a directory. One record fetch. */
12256
+ async list(path = ".") {
12257
+ const dir = await this.openDir(path);
12258
+ const base = this.resolve(path);
12259
+ return Object.entries(dir.node.entries)
12260
+ .map(([name, entry]) => toStat(join(base, name), name, entry))
12261
+ .sort(byDirsThenName);
12262
+ }
12263
+ /**
12264
+ * Every file path under a prefix, depth-first.
12265
+ *
12266
+ * Deliberately separate from `list`: this walks the whole subtree, which is
12267
+ * one fetch per directory. Fine for a project, wrong as a default.
12268
+ */
12269
+ async walk(path = ".") {
12270
+ const out = [];
12271
+ const visit = async (dirPath) => {
12272
+ for (const entry of await this.list(dirPath)) {
12273
+ if (entry.kind === "dir")
12274
+ await visit(entry.path);
12275
+ else
12276
+ out.push(entry.path);
12277
+ }
12278
+ };
12279
+ await visit(this.resolve(path));
12280
+ return out;
12281
+ }
12282
+ async readFile(path) {
12283
+ const entry = await this.expectFile(path);
12284
+ return this.deps.content.read(entry.manifest);
12285
+ }
12286
+ async readText(path) {
12287
+ return new TextDecoder().decode(await this.readFile(path));
12288
+ }
12289
+ // ---- writes -------------------------------------------------------------
12290
+ /**
12291
+ * Create a directory. `recursive` creates missing parents and tolerates an
12292
+ * existing target, matching `mkdir -p`.
12293
+ */
12294
+ async mkdir(path, opts = {}) {
12295
+ const target = this.resolve(path);
12296
+ if (target === "/")
12297
+ return;
12298
+ const parts = segments(target);
12299
+ let current = "/";
12300
+ for (let i = 0; i < parts.length; i++) {
12301
+ const name = parts[i];
12302
+ const isLast = i === parts.length - 1;
12303
+ const next = join(current, name);
12304
+ const existing = (await this.openDir(current)).node.entries[name];
12305
+ if (existing) {
12306
+ if (existing.kind === "file")
12307
+ throw new VfsConflictError(`${next} is a file`);
12308
+ if (isLast && !opts.recursive)
12309
+ throw new VfsConflictError(`${next} already exists`);
12310
+ }
12311
+ else {
12312
+ if (!isLast && !opts.recursive)
12313
+ throw new VfsNotFoundError(current);
12314
+ await this.createDir(current, name);
12315
+ }
12316
+ current = next;
12317
+ }
12318
+ }
12319
+ /**
12320
+ * Write a file, creating parent directories as needed.
12321
+ *
12322
+ * The previous version is pushed onto the file's history record before the
12323
+ * directory entry is replaced, so an interrupted write can lose the new
12324
+ * version but never the old one.
12325
+ */
12326
+ async writeFile(path, data, opts = {}) {
12327
+ const target = this.resolve(path);
12328
+ const name = basename(target);
12329
+ assertValidName(name);
12330
+ const parentPath = dirname(target);
12331
+ await this.mkdir(parentPath, { recursive: true });
12332
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
12333
+ const { manifest, size } = await this.deps.content.write(bytes, {
12334
+ name,
12335
+ type: opts.type ?? contentTypeFor(name),
12336
+ });
12337
+ const parent = await this.openDir(parentPath);
12338
+ const prior = parent.node.entries[name];
12339
+ if (prior?.kind === "dir")
12340
+ throw new VfsConflictError(`${target} is a directory`);
12341
+ const id = prior?.id ?? newId();
12342
+ let versions = prior?.versions ?? 0;
12343
+ if (prior)
12344
+ versions = await this.pushHistory(parent, id, prior);
12345
+ const entry = { kind: "file", id, manifest, size, mtime: Date.now(), versions };
12346
+ await this.mutate(parent, (node) => {
12347
+ node.entries[name] = entry;
12348
+ });
12349
+ return toStat(target, name, entry);
12350
+ }
12351
+ /** Delete a file, or a directory when `recursive`. */
12352
+ async delete(path, opts = {}) {
12353
+ const target = this.resolve(path);
12354
+ if (target === "/")
12355
+ throw new VfsConflictError("the root directory cannot be deleted");
12356
+ const name = basename(target);
12357
+ const parent = await this.openDir(dirname(target));
12358
+ const entry = parent.node.entries[name];
12359
+ if (!entry)
12360
+ throw new VfsNotFoundError(target);
12361
+ if (entry.kind === "dir") {
12362
+ const child = await this.openDirById(entry.id, fromBase64(entry.key));
12363
+ const empty = Object.keys(child.node.entries).length === 0;
12364
+ if (!empty && !opts.recursive)
12365
+ throw new VfsConflictError(`${target} is not empty`);
12366
+ await this.purge(child);
12367
+ }
12368
+ else {
12369
+ await this.releaseFile(parent, entry);
12370
+ }
12371
+ await this.mutate(parent, (node) => {
12372
+ delete node.entries[name];
12373
+ });
12374
+ }
12375
+ /**
12376
+ * Move or rename. Works across directories, which is the same operation:
12377
+ * detach the entry from one parent and attach it to another.
12378
+ *
12379
+ * A directory move is O(1) regardless of how much is inside it — the
12380
+ * subtree records are not touched at all, because nothing in them records
12381
+ * its own path.
12382
+ */
12383
+ async rename(from, to) {
12384
+ const src = this.resolve(from);
12385
+ const dst = this.resolve(to);
12386
+ if (src === dst)
12387
+ return;
12388
+ if (src === "/")
12389
+ throw new VfsConflictError("the root directory cannot be moved");
12390
+ if (isUnder(dst, src))
12391
+ throw new VfsConflictError(`cannot move ${src} into itself`);
12392
+ const dstName = basename(dst);
12393
+ assertValidName(dstName);
12394
+ const srcParent = await this.openDir(dirname(src));
12395
+ const entry = srcParent.node.entries[basename(src)];
12396
+ if (!entry)
12397
+ throw new VfsNotFoundError(src);
12398
+ await this.mkdir(dirname(dst), { recursive: true });
12399
+ const dstParent = await this.openDir(dirname(dst));
12400
+ if (dstParent.node.entries[dstName])
12401
+ throw new VfsConflictError(`${dst} already exists`);
12402
+ // Attach before detaching. If the second write fails the entry is
12403
+ // reachable from both parents — visible and fixable. The other order
12404
+ // would lose the subtree entirely.
12405
+ const moved = { ...entry, mtime: Date.now() };
12406
+ await this.mutate(dstParent, (node) => {
12407
+ node.entries[dstName] = moved;
12408
+ });
12409
+ // Re-open: same-parent moves mutated the record we are about to edit.
12410
+ const detachFrom = await this.openDir(dirname(src));
12411
+ await this.mutate(detachFrom, (node) => {
12412
+ delete node.entries[basename(src)];
12413
+ });
12414
+ }
12415
+ /**
12416
+ * Copy a file or a directory tree.
12417
+ *
12418
+ * Copying a FILE moves no bytes: content is immutable and content-addressed,
12419
+ * so the new entry simply points at the same manifest. A 200 MB asset
12420
+ * duplicates in one small metadata write, and the shard store already holds
12421
+ * exactly one copy of it.
12422
+ *
12423
+ * The copy gets a NEW file id, so it does not inherit or share history —
12424
+ * two paths sharing an id would mean saving one silently pushed a version
12425
+ * onto the other.
12426
+ */
12427
+ async copy(from, to) {
12428
+ const src = this.resolve(from);
12429
+ const dst = this.resolve(to);
12430
+ if (src === "/")
12431
+ throw new VfsConflictError("the root directory cannot be copied");
12432
+ if (src === dst)
12433
+ throw new VfsConflictError(`${dst} already exists`);
12434
+ if (isUnder(dst, src))
12435
+ throw new VfsConflictError(`cannot copy ${src} into itself`);
12436
+ const found = await this.find(src);
12437
+ if (!found)
12438
+ throw new VfsNotFoundError(src);
12439
+ const dstName = basename(dst);
12440
+ assertValidName(dstName);
12441
+ await this.mkdir(dirname(dst), { recursive: true });
12442
+ const dstParent = await this.openDir(dirname(dst));
12443
+ if (dstParent.node.entries[dstName])
12444
+ throw new VfsConflictError(`${dst} already exists`);
12445
+ const copied = await this.cloneEntry(found.entry);
12446
+ await this.mutate(dstParent, (node) => {
12447
+ node.entries[dstName] = copied;
12448
+ });
12449
+ return toStat(dst, dstName, copied);
12450
+ }
12451
+ /**
12452
+ * Paths matching a glob, relative to the whole filesystem.
12453
+ *
12454
+ * Matching happens on the client over a walk of the tree, so the cost is one
12455
+ * record fetch per directory — cheap for a project, and worth knowing before
12456
+ * pointing it at the root of a large filesystem. Use `walk` when the prefix
12457
+ * already narrows it.
12458
+ */
12459
+ async glob(pattern, opts = {}) {
12460
+ const re = globToRegExp(this.resolve(pattern));
12461
+ return (await this.walk(opts.from ?? ".")).filter((path) => re.test(path));
12462
+ }
12463
+ /**
12464
+ * Delete metadata records no longer reachable from the root.
12465
+ *
12466
+ * These accumulate because every write touches exactly one record: if two
12467
+ * tabs edit the same directory, last-writer-wins at the personal space can
12468
+ * leave a child whose parent entry lost the race. Unreachable is harmless —
12469
+ * it is invisible and undecryptable-by-nobody — but it costs storage, so
12470
+ * this reclaims it.
12471
+ *
12472
+ * Deliberately explicit, never automatic: a sweep racing another tab's
12473
+ * in-flight write could delete a record that is about to be referenced.
12474
+ * Run it when the filesystem is idle.
12475
+ */
12476
+ async sweep(opts = {}) {
12477
+ const root = await this.root();
12478
+ const reachable = new Set();
12479
+ const visit = async (dir) => {
12480
+ reachable.add(dirKey(dir.id));
12481
+ for (const entry of Object.values(dir.node.entries)) {
12482
+ if (entry.kind === "dir") {
12483
+ await visit(await this.openDirById(entry.id, fromBase64(entry.key)));
12484
+ }
12485
+ else {
12486
+ reachable.add(historyKey(entry.id));
12487
+ }
12488
+ }
12489
+ };
12490
+ await visit(root);
12491
+ // Only ever consider OUR records: the personal space is shared with
12492
+ // chat keys, space keys and the legacy file mirror, and a sweep that
12493
+ // reached beyond its own namespace would be a data-loss bug.
12494
+ const mine = (await this.deps.store.list()).filter((k) => /^vfs\/[dh]\//.test(k));
12495
+ const orphans = mine.filter((k) => !reachable.has(k));
12496
+ // A root that read as EMPTY while orphans exist is the shape of a failed
12497
+ // load, not of a fresh filesystem — deleting here would wipe a tree we
12498
+ // merely failed to reach. Refuse unless the caller insists.
12499
+ const rootEmpty = Object.keys(root.node.entries).length === 0;
12500
+ if (rootEmpty && orphans.length && !opts.force) {
12501
+ throw new VfsConflictError(`refusing to sweep ${orphans.length} record(s) with an empty root — ` +
12502
+ "this looks like a failed load rather than an empty filesystem; pass { force: true } if it really is empty");
12503
+ }
12504
+ for (const key of orphans)
12505
+ await this.deps.store.delete(key).catch(() => { });
12506
+ return { removed: orphans };
12507
+ }
12508
+ // ---- history ------------------------------------------------------------
12509
+ /** Prior versions of a file, newest first. One record fetch. */
12510
+ async history(path) {
12511
+ const entry = await this.expectFile(path);
12512
+ const record = await this.readHistory(await this.openDir(dirname(path)), entry.id);
12513
+ return record.versions.map(({ size, mtime }) => ({ size, mtime }));
12514
+ }
12515
+ /**
12516
+ * Restore a prior version, where 0 is the most recent one.
12517
+ *
12518
+ * The current version is pushed onto history first, so restoring is itself
12519
+ * undoable rather than a one-way door.
12520
+ */
12521
+ async restore(path, index = 0) {
12522
+ const target = this.resolve(path);
12523
+ const name = basename(target);
12524
+ const parent = await this.openDir(dirname(target));
12525
+ const entry = parent.node.entries[name];
12526
+ if (!entry || entry.kind !== "file")
12527
+ throw new VfsNotFoundError(target);
12528
+ const record = await this.readHistory(parent, entry.id);
12529
+ const version = record.versions[index];
12530
+ if (!version)
12531
+ throw new VfsNotFoundError(`${target} version ${index}`);
12532
+ const versions = await this.pushHistory(parent, entry.id, entry);
12533
+ const restored = {
12534
+ kind: "file",
12535
+ id: entry.id,
12536
+ manifest: version.manifest,
12537
+ size: version.size,
12538
+ mtime: Date.now(),
12539
+ versions,
12540
+ };
12541
+ await this.mutate(parent, (node) => {
12542
+ node.entries[name] = restored;
12543
+ });
12544
+ return toStat(target, name, restored);
12545
+ }
12546
+ // ---- internals ----------------------------------------------------------
12547
+ async expectFile(path) {
12548
+ const found = await this.find(path);
12549
+ if (!found)
12550
+ throw new VfsNotFoundError(this.resolve(path));
12551
+ if (found.entry.kind !== "file")
12552
+ throw new VfsConflictError(`${this.resolve(path)} is a directory`);
12553
+ return found.entry;
12554
+ }
12555
+ /** Resolve a path to its entry, or null. Root has no entry of its own. */
12556
+ async find(path) {
12557
+ const target = this.resolve(path);
12558
+ if (target === "/")
12559
+ return null;
12560
+ const parent = await this.openDirOrNull(dirname(target));
12561
+ if (!parent)
12562
+ return null;
12563
+ const name = basename(target);
12564
+ const entry = parent.node.entries[name];
12565
+ return entry ? { name, entry } : null;
12566
+ }
12567
+ async root() {
12568
+ const seed = this.deps.seed();
12569
+ if (!seed)
12570
+ throw new VfsLockedError();
12571
+ if (!this.rootKey)
12572
+ this.rootKey = await deriveRootKey(seed);
12573
+ return this.openDirById(ROOT_ID, this.rootKey);
12574
+ }
12575
+ /** Open a directory by path, creating nothing. Throws if it is missing. */
12576
+ async openDir(path) {
12577
+ const dir = await this.openDirOrNull(path);
12578
+ if (!dir)
12579
+ throw new VfsNotFoundError(this.resolve(path));
12580
+ return dir;
12581
+ }
12582
+ async openDirOrNull(path) {
12583
+ let dir = await this.root();
12584
+ for (const name of segments(this.resolve(path))) {
12585
+ const entry = dir.node.entries[name];
12586
+ if (!entry || entry.kind !== "dir")
12587
+ return null;
12588
+ dir = await this.openDirById(entry.id, fromBase64(entry.key));
12589
+ }
12590
+ return dir;
12591
+ }
12592
+ async openDirById(id, key) {
12593
+ const hit = this.cache.get(id);
12594
+ if (hit)
12595
+ return hit;
12596
+ const raw = await this.deps.store.get(dirKey(id));
12597
+ // An absent record is an EMPTY directory, not an error: the root exists
12598
+ // before anything has been written to it, and a directory is created by
12599
+ // its parent naming it rather than by its own record appearing.
12600
+ const node = (await unseal(key, raw)) ?? { v: 1, entries: {}, mtime: Date.now() };
12601
+ const open = { id, key, node };
12602
+ this.cache.set(id, open);
12603
+ return open;
12604
+ }
12605
+ /** Apply an edit to a directory record and persist it. */
12606
+ async mutate(dir, edit) {
12607
+ edit(dir.node);
12608
+ dir.node.mtime = Date.now();
12609
+ await this.deps.store.put(dirKey(dir.id), await seal(dir.key, dir.node));
12610
+ this.cache.set(dir.id, dir);
12611
+ }
12612
+ async createDir(parentPath, name) {
12613
+ assertValidName(name);
12614
+ const parent = await this.openDir(parentPath);
12615
+ const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
12616
+ await this.mutate(parent, (node) => {
12617
+ node.entries[name] = entry;
12618
+ });
12619
+ }
12620
+ /** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
12621
+ async purge(dir) {
12622
+ for (const entry of Object.values(dir.node.entries)) {
12623
+ if (entry.kind === "dir") {
12624
+ await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
12625
+ }
12626
+ else {
12627
+ await this.releaseFile(dir, entry);
12628
+ }
12629
+ }
12630
+ this.cache.delete(dir.id);
12631
+ await this.deps.store.delete(dirKey(dir.id)).catch(() => { });
12632
+ }
12633
+ /**
12634
+ * Deep-copy an entry. Files reuse their manifest (no bytes move); each
12635
+ * directory gets a fresh id and key, so the copy is independent of the
12636
+ * original — writing into one must never appear in the other.
12637
+ */
12638
+ async cloneEntry(entry) {
12639
+ if (entry.kind === "file") {
12640
+ // Same bytes, second owner: take a reference so deleting either one
12641
+ // leaves the other readable.
12642
+ await this.deps.content.retain(entry.manifest);
12643
+ return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
12644
+ }
12645
+ const source = await this.openDirById(entry.id, fromBase64(entry.key));
12646
+ const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
12647
+ const node = { v: 1, entries: {}, mtime: Date.now() };
12648
+ for (const [name, child] of Object.entries(source.node.entries)) {
12649
+ node.entries[name] = await this.cloneEntry(child);
12650
+ }
12651
+ const open = { id: clone.id, key: fromBase64(clone.key), node };
12652
+ await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
12653
+ this.cache.set(open.id, open);
12654
+ return clone;
12655
+ }
12656
+ /**
12657
+ * Let go of a file's bytes: the current manifest and every retained
12658
+ * version, then the history record itself.
12659
+ */
12660
+ async releaseFile(parent, entry) {
12661
+ const record = await this.readHistory(parent, entry.id).catch(() => ({ v: 1, versions: [] }));
12662
+ const manifests = [entry.manifest, ...record.versions.map((v) => v.manifest)];
12663
+ for (const manifest of manifests)
12664
+ await this.deps.content.release(manifest).catch(() => { });
12665
+ await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
12666
+ }
12667
+ async readHistory(parent, fileId) {
12668
+ const raw = await this.deps.store.get(historyKey(fileId));
12669
+ return (await unseal(parent.key, raw)) ?? { v: 1, versions: [] };
12670
+ }
12671
+ /** Push `entry` onto its history record. Returns the new version count. */
12672
+ async pushHistory(parent, fileId, entry) {
12673
+ const record = await this.readHistory(parent, fileId);
12674
+ record.versions.unshift({ manifest: entry.manifest, size: entry.size, mtime: entry.mtime });
12675
+ // Bounded, because this is a single stored value. Unchanged chunks are
12676
+ // deduped by the content-addressed shard store, so the cost of keeping
12677
+ // versions is this record's own growth — which is what the cap bounds.
12678
+ const evicted = record.versions.slice(this.historyLimit);
12679
+ record.versions = record.versions.slice(0, this.historyLimit);
12680
+ // A version that falls off the end is unreachable — release its shards
12681
+ // or the cap bounds the record's size while the storage grows forever.
12682
+ for (const v of evicted)
12683
+ await this.deps.content.release(v.manifest).catch(() => { });
12684
+ await this.deps.store.put(historyKey(fileId), await seal(parent.key, record));
12685
+ return record.versions.length;
12686
+ }
12687
+ }
12688
+ function toStat(path, name, entry) {
12689
+ return {
12690
+ path,
12691
+ name,
12692
+ kind: entry.kind,
12693
+ size: entry.kind === "file" ? entry.size : 0,
12694
+ mtime: entry.mtime,
12695
+ versions: entry.kind === "file" ? (entry.versions ?? 0) : 0,
12696
+ };
12697
+ }
12698
+ /** Directories first, then alphabetical — what every file explorer does. */
12699
+ function byDirsThenName(a, b) {
12700
+ if (a.kind !== b.kind)
12701
+ return a.kind === "dir" ? -1 : 1;
12702
+ return a.name.localeCompare(b.name);
12703
+ }
12704
+
11597
12705
  /**
11598
12706
  * `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
11599
12707
  * keypair (the connect `KeyStore` entry keyed by the member id / username).
@@ -12812,6 +13920,8 @@ class DbCache {
12812
13920
  * `FileStorage`, `EncryptedSession`, `Network`, …) remain exported and usable
12813
13921
  * directly, but the client is the supported, ergonomic surface.
12814
13922
  */
13923
+ /** Personal-space key holding the id of the space VFS content is written to. */
13924
+ const VFS_SPACE_KEY = "vfs/space";
12815
13925
  /** The hosted Muhkoo Accelerator — the default {@link ClientOptions.baseUrl}. */
12816
13926
  const DEFAULT_BASE_URL = "https://api.muhkoo.dev";
12817
13927
  /** The hosted auth SPA — the default {@link ClientOptions.authBaseUrl}. */
@@ -12833,6 +13943,8 @@ class Client {
12833
13943
  db;
12834
13944
  /** File storage — `client.storage.writeFile(...)`, etc. */
12835
13945
  storage;
13946
+ /** Filesystem over the personal space: directories, versions, real paths. */
13947
+ vfs;
12836
13948
  /** Realtime messaging — `client.message.subscribe(...)`, etc. */
12837
13949
  message;
12838
13950
  /** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
@@ -12932,6 +14044,94 @@ class Client {
12932
14044
  deferShardUpload,
12933
14045
  });
12934
14046
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
14047
+ // The VFS stores its metadata in the personal space — the same place
14048
+ // `storage.writeFile` already mirrors `{spaceId, manifest}` to, only
14049
+ // arranged as a navigable tree instead of a flat index keyed by file id.
14050
+ //
14051
+ // Content still goes through `storage.writeFile` untouched, into a real
14052
+ // Space with a real gated manifest. The space is created once, lazily,
14053
+ // and its id recorded in the personal space: a filesystem that has never
14054
+ // been written to should not cost a space.
14055
+ const personalStore = {
14056
+ get: async (key) => {
14057
+ const c = this.session.commitment;
14058
+ if (!c)
14059
+ throw new Error("client.vfs: no session — sign in first.");
14060
+ const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
14061
+ return res?.value ?? null;
14062
+ },
14063
+ put: async (key, value) => {
14064
+ const c = this.session.commitment;
14065
+ if (!c)
14066
+ throw new Error("client.vfs: no session — sign in first.");
14067
+ await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
14068
+ },
14069
+ delete: async (key) => {
14070
+ const c = this.session.commitment;
14071
+ if (!c)
14072
+ throw new Error("client.vfs: no session — sign in first.");
14073
+ await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
14074
+ },
14075
+ list: async () => {
14076
+ const c = this.session.commitment;
14077
+ if (!c)
14078
+ throw new Error("client.vfs: no session — sign in first.");
14079
+ const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
14080
+ return res?.keys ?? [];
14081
+ },
14082
+ };
14083
+ let contentSpace = null;
14084
+ const resolveContentSpace = () => {
14085
+ // Cached as the PROMISE, not the value: two concurrent first writes
14086
+ // would otherwise each create a space and one would be orphaned.
14087
+ if (!contentSpace) {
14088
+ contentSpace = (async () => {
14089
+ const existing = await personalStore.get(VFS_SPACE_KEY);
14090
+ if (typeof existing === "string" && existing)
14091
+ return this.space.get(existing);
14092
+ // `messaging: false` — this space only ever holds files, and
14093
+ // file chunks carry their own keys, so it needs no group
14094
+ // keyring and no socket. Creating it the normal way opens a
14095
+ // WebSocket, which made the first write hang indefinitely.
14096
+ const space = await this.space.createSpace({ private: true, messaging: false });
14097
+ await personalStore.put(VFS_SPACE_KEY, space.id);
14098
+ return space;
14099
+ })().catch((err) => {
14100
+ contentSpace = null; // let the next write retry
14101
+ throw err;
14102
+ });
14103
+ }
14104
+ return contentSpace;
14105
+ };
14106
+ this.vfs = new VfsNamespace({
14107
+ store: personalStore,
14108
+ seed: () => this.session.seed,
14109
+ // Share the personal space's socket with `client.kv` rather than
14110
+ // opening a second one to the same Durable Object.
14111
+ subscribe: (handler) => this.kv.onRaw(handler),
14112
+ content: {
14113
+ // `putFile` needs no WebSocket and commits the shards, so the
14114
+ // bytes are metered to the owning app and reference-counted for
14115
+ // GC. `storage.writeFile` would additionally index the manifest
14116
+ // in the space — a second, diverging source of truth for a file
14117
+ // whose handle the VFS already holds.
14118
+ write: async (data, meta) => {
14119
+ const space = await resolveContentSpace();
14120
+ const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
14121
+ return { manifest, size: stat.size };
14122
+ },
14123
+ // Reads go through the manifest alone — no space needed. That is
14124
+ // the capability path, so it keeps working for a file whose
14125
+ // handle was shared from someone else's filesystem.
14126
+ read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
14127
+ release: async (manifest) => {
14128
+ await (await resolveContentSpace()).releaseFile(manifest);
14129
+ },
14130
+ retain: async (manifest) => {
14131
+ await (await resolveContentSpace()).commitFile(manifest);
14132
+ },
14133
+ },
14134
+ });
12935
14135
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
12936
14136
  // by client.kv), so a returning member hydrates without a fresh keyring
12937
14137
  // round-trip. The server only ever sees ciphertext.
@@ -13400,5 +14600,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
13400
14600
  default: loadRsWasm
13401
14601
  });
13402
14602
 
13403
- export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReauthRequiredError, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
14603
+ export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ReauthRequiredError, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, generateAuthProof, generateDevicePairingKeypair, generatePkce, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
13404
14604
  //# sourceMappingURL=index.js.map