@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.
- package/dist/browser/index.js +1283 -83
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +434 -11
- package/dist/server/index.js +1283 -83
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -72,7 +72,7 @@ function randomBytes(n) {
|
|
|
72
72
|
/** Length of the AES key in bytes. */
|
|
73
73
|
const AES_GCM_KEY_BYTES = 32;
|
|
74
74
|
/** Length of the AES-GCM IV in bytes (WebCrypto-recommended). */
|
|
75
|
-
const AES_GCM_IV_BYTES = 12;
|
|
75
|
+
const AES_GCM_IV_BYTES$1 = 12;
|
|
76
76
|
/** Length of the AES-GCM authentication tag in bytes (appended to ciphertext). */
|
|
77
77
|
const AES_GCM_TAG_BYTES = 16;
|
|
78
78
|
/**
|
|
@@ -113,8 +113,8 @@ function assertKey(key) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
function assertIv(iv) {
|
|
116
|
-
if (iv.byteLength !== AES_GCM_IV_BYTES) {
|
|
117
|
-
throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES} bytes (got ${iv.byteLength})`);
|
|
116
|
+
if (iv.byteLength !== AES_GCM_IV_BYTES$1) {
|
|
117
|
+
throw new Error(`aes-gcm: iv must be ${AES_GCM_IV_BYTES$1} bytes (got ${iv.byteLength})`);
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -443,7 +443,7 @@ function fromHex(hex) {
|
|
|
443
443
|
return out;
|
|
444
444
|
}
|
|
445
445
|
/** Encode a byte array as a standard base64 string. */
|
|
446
|
-
function toBase64$
|
|
446
|
+
function toBase64$2(bytes) {
|
|
447
447
|
let binary = "";
|
|
448
448
|
for (let i = 0; i < bytes.length; i++) {
|
|
449
449
|
binary += String.fromCharCode(bytes[i]);
|
|
@@ -451,7 +451,7 @@ function toBase64$1(bytes) {
|
|
|
451
451
|
return btoa(binary);
|
|
452
452
|
}
|
|
453
453
|
/** Decode a standard base64 string to bytes. */
|
|
454
|
-
function fromBase64$
|
|
454
|
+
function fromBase64$2(b64) {
|
|
455
455
|
const binary = atob(b64);
|
|
456
456
|
const ab = new ArrayBuffer(binary.length);
|
|
457
457
|
const out = new Uint8Array(ab);
|
|
@@ -461,13 +461,13 @@ function fromBase64$1(b64) {
|
|
|
461
461
|
}
|
|
462
462
|
/** Encode a byte array as a base64url string (no padding). */
|
|
463
463
|
function toBase64Url(bytes) {
|
|
464
|
-
return toBase64$
|
|
464
|
+
return toBase64$2(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
465
465
|
}
|
|
466
466
|
/** Decode a base64url string (with or without padding) to bytes. */
|
|
467
467
|
function fromBase64Url(b64url) {
|
|
468
468
|
const padded = b64url.replace(/-/g, "+").replace(/_/g, "/") +
|
|
469
469
|
"=".repeat((4 - (b64url.length % 4)) % 4);
|
|
470
|
-
return fromBase64$
|
|
470
|
+
return fromBase64$2(padded);
|
|
471
471
|
}
|
|
472
472
|
/** Concatenate any number of byte arrays into a single Uint8Array. */
|
|
473
473
|
function concatBytes(...arrays) {
|
|
@@ -1045,10 +1045,10 @@ class KeyStore {
|
|
|
1045
1045
|
async packDehydratedKeys(id) {
|
|
1046
1046
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
1047
1047
|
const combined = JSON.stringify(dehydrated);
|
|
1048
|
-
return toBase64$
|
|
1048
|
+
return toBase64$2(new TextEncoder().encode(combined));
|
|
1049
1049
|
}
|
|
1050
1050
|
async hydrateFromPacked(id, masterKey) {
|
|
1051
|
-
const decoded = utf8Decode(fromBase64$
|
|
1051
|
+
const decoded = utf8Decode(fromBase64$2(masterKey));
|
|
1052
1052
|
const dehydrated = JSON.parse(decoded);
|
|
1053
1053
|
await this.hydrateKeyPair(id, dehydrated);
|
|
1054
1054
|
}
|
|
@@ -1158,7 +1158,7 @@ class DoubleRatchet {
|
|
|
1158
1158
|
}
|
|
1159
1159
|
const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
|
|
1160
1160
|
this.state.sendChainKey = newChainKey;
|
|
1161
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
1161
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
1162
1162
|
const encrypted = await encryptAesGcm(messageKey, iv, utf8Encode(plaintext));
|
|
1163
1163
|
const cipherTextWithTag = toHex(encrypted);
|
|
1164
1164
|
appLogger.debug(`Encrypt: plaintextLength=${plaintext.length}, ciphertextLength=${encrypted.byteLength}`);
|
|
@@ -1878,8 +1878,8 @@ class ChunkCipher {
|
|
|
1878
1878
|
*/
|
|
1879
1879
|
generateKeyMaterial() {
|
|
1880
1880
|
return {
|
|
1881
|
-
key: toBase64$
|
|
1882
|
-
iv: toBase64$
|
|
1881
|
+
key: toBase64$2(randomBytes(AES_GCM_KEY_BYTES)),
|
|
1882
|
+
iv: toBase64$2(randomBytes(AES_GCM_IV_BYTES$1)),
|
|
1883
1883
|
};
|
|
1884
1884
|
}
|
|
1885
1885
|
/**
|
|
@@ -1888,7 +1888,7 @@ class ChunkCipher {
|
|
|
1888
1888
|
* GCM behavior).
|
|
1889
1889
|
*/
|
|
1890
1890
|
async encryptChunk(plaintext, material) {
|
|
1891
|
-
return encryptAesGcm(fromBase64$
|
|
1891
|
+
return encryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), plaintext);
|
|
1892
1892
|
}
|
|
1893
1893
|
/**
|
|
1894
1894
|
* Inverse of {@link encryptChunk}. Throws if the auth tag fails — that
|
|
@@ -1897,7 +1897,7 @@ class ChunkCipher {
|
|
|
1897
1897
|
*/
|
|
1898
1898
|
async decryptChunk(ciphertext, material) {
|
|
1899
1899
|
try {
|
|
1900
|
-
return await decryptAesGcm(fromBase64$
|
|
1900
|
+
return await decryptAesGcm(fromBase64$2(material.key), fromBase64$2(material.iv), ciphertext);
|
|
1901
1901
|
}
|
|
1902
1902
|
catch {
|
|
1903
1903
|
// WebCrypto throws a vague OperationError on GCM tag mismatch.
|
|
@@ -1961,7 +1961,7 @@ async function wrapWithPassphrase(passphrase, plaintext) {
|
|
|
1961
1961
|
throw new Error("wrapWithPassphrase: passphrase must be a non-empty string");
|
|
1962
1962
|
}
|
|
1963
1963
|
const salt = randomBytes(SALT_BYTES);
|
|
1964
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
1964
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
1965
1965
|
const key = await deriveAesKeyFromPassphrase(passphrase, salt, PBKDF2_DEFAULT_ITERATIONS);
|
|
1966
1966
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
1967
1967
|
return {
|
|
@@ -2449,8 +2449,10 @@ class AuthClient {
|
|
|
2449
2449
|
/** Passkeys are per-origin: naming the caller's host returns THAT origin's
|
|
2450
2450
|
* passkey instead of whichever was enrolled first (which WebAuthn would
|
|
2451
2451
|
* refuse). Ignored for other factor types. */
|
|
2452
|
-
rpId
|
|
2453
|
-
|
|
2452
|
+
rpId,
|
|
2453
|
+
/** Device factors are selected by id — a user may have many machines. */
|
|
2454
|
+
factorId) {
|
|
2455
|
+
const res = await this.fetchFn(`${this.baseUrl}/api/auth/vault`, this.json("POST", { username, factorType, verifyToken, rpId, factorId }));
|
|
2454
2456
|
return await this.parse("vaultRead", res);
|
|
2455
2457
|
}
|
|
2456
2458
|
/**
|
|
@@ -2891,20 +2893,20 @@ async function wrapKeyFromBytes(keyMaterial, info) {
|
|
|
2891
2893
|
async function wrapSeed(seed, key) {
|
|
2892
2894
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2893
2895
|
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, seed);
|
|
2894
|
-
return { iv: toBase64(iv), ct: toBase64(new Uint8Array(ct)) };
|
|
2896
|
+
return { iv: toBase64$1(iv), ct: toBase64$1(new Uint8Array(ct)) };
|
|
2895
2897
|
}
|
|
2896
2898
|
/** Decrypt a wrapped seed. Throws (AES-GCM tag failure) on a wrong key. */
|
|
2897
2899
|
async function unwrapSeed(wrapped, key) {
|
|
2898
|
-
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(wrapped.iv) }, key, fromBase64(wrapped.ct));
|
|
2900
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(wrapped.iv) }, key, fromBase64$1(wrapped.ct));
|
|
2899
2901
|
return new Uint8Array(pt);
|
|
2900
2902
|
}
|
|
2901
|
-
function toBase64(bytes) {
|
|
2903
|
+
function toBase64$1(bytes) {
|
|
2902
2904
|
let s = "";
|
|
2903
2905
|
for (let i = 0; i < bytes.length; i++)
|
|
2904
2906
|
s += String.fromCharCode(bytes[i]);
|
|
2905
2907
|
return btoa(s);
|
|
2906
2908
|
}
|
|
2907
|
-
function fromBase64(b64) {
|
|
2909
|
+
function fromBase64$1(b64) {
|
|
2908
2910
|
const s = atob(b64);
|
|
2909
2911
|
const out = new Uint8Array(s.length);
|
|
2910
2912
|
for (let i = 0; i < s.length; i++)
|
|
@@ -2962,20 +2964,20 @@ async function sealSeed(seed) {
|
|
|
2962
2964
|
const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
2963
2965
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
2964
2966
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
2965
|
-
const envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
2967
|
+
const envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
|
|
2966
2968
|
return {
|
|
2967
|
-
sealedKeys: toBase64(TE$1.encode(JSON.stringify(envelope))),
|
|
2969
|
+
sealedKeys: toBase64$1(TE$1.encode(JSON.stringify(envelope))),
|
|
2968
2970
|
fragmentKey: base64UrlEncode(ktRaw),
|
|
2969
2971
|
};
|
|
2970
2972
|
}
|
|
2971
2973
|
/** Recover the master seed from the sealed envelope + the fragment key. */
|
|
2972
2974
|
async function unsealSeed(sealedKeys, fragmentKey) {
|
|
2973
|
-
const envelope = JSON.parse(TD$1.decode(fromBase64(sealedKeys)));
|
|
2975
|
+
const envelope = JSON.parse(TD$1.decode(fromBase64$1(sealedKeys)));
|
|
2974
2976
|
if (envelope.v !== 1)
|
|
2975
2977
|
throw new Error(`Unsupported handoff envelope version ${envelope.v}`);
|
|
2976
2978
|
const ktRaw = base64UrlDecode(fragmentKey);
|
|
2977
2979
|
const key = await crypto.subtle.importKey("raw", ktRaw, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
2978
|
-
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct));
|
|
2980
|
+
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct));
|
|
2979
2981
|
return new Uint8Array(seed);
|
|
2980
2982
|
}
|
|
2981
2983
|
// ---- v2: ECDH device pairing ----------------------------------------------
|
|
@@ -3026,7 +3028,7 @@ const PAIRING_CODE_ITERATIONS = 200_000;
|
|
|
3026
3028
|
async function generateDevicePairingKeypair() {
|
|
3027
3029
|
const pair = (await crypto.subtle.generateKey(PAIRING_CURVE, false, ["deriveBits"]));
|
|
3028
3030
|
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
|
|
3029
|
-
return { publicKeyB64: toBase64(raw), privateKey: pair.privateKey };
|
|
3031
|
+
return { publicKeyB64: toBase64$1(raw), privateKey: pair.privateKey };
|
|
3030
3032
|
}
|
|
3031
3033
|
/**
|
|
3032
3034
|
* (Hosted-page side) Seal the master seed *to a device's* pairing public key.
|
|
@@ -3047,8 +3049,8 @@ async function sealSeedToDevice(seed, devicePublicKeyB64) {
|
|
|
3047
3049
|
const key = await deriveDeviceSealKey(ephemeral.privateKey, devicePub, epkRaw, devicePubRaw, ["encrypt"]);
|
|
3048
3050
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
3049
3051
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, seed));
|
|
3050
|
-
const envelope = { v: 2, epk: toBase64(epkRaw), iv: toBase64(iv), ct: toBase64(ct) };
|
|
3051
|
-
return toBase64(TE$1.encode(JSON.stringify(envelope)));
|
|
3052
|
+
const envelope = { v: 2, epk: toBase64$1(epkRaw), iv: toBase64$1(iv), ct: toBase64$1(ct) };
|
|
3053
|
+
return toBase64$1(TE$1.encode(JSON.stringify(envelope)));
|
|
3052
3054
|
}
|
|
3053
3055
|
/**
|
|
3054
3056
|
* (Device side) Recover the master seed from a v2 envelope using the ephemeral
|
|
@@ -3206,7 +3208,7 @@ function importPairingPublicKey(raw) {
|
|
|
3206
3208
|
*/
|
|
3207
3209
|
function fromBase64Loose(s) {
|
|
3208
3210
|
const norm = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
3209
|
-
return fromBase64(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
|
|
3211
|
+
return fromBase64$1(norm.padEnd(Math.ceil(norm.length / 4) * 4, "="));
|
|
3210
3212
|
}
|
|
3211
3213
|
/** Generate a PKCE verifier + S256 challenge for the authorize→token exchange. */
|
|
3212
3214
|
async function generatePkce() {
|
|
@@ -3219,10 +3221,10 @@ function randomState() {
|
|
|
3219
3221
|
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
|
|
3220
3222
|
}
|
|
3221
3223
|
function base64UrlEncode(bytes) {
|
|
3222
|
-
return toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
3224
|
+
return toBase64$1(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
3223
3225
|
}
|
|
3224
3226
|
function base64UrlDecode(s) {
|
|
3225
|
-
return fromBase64(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
|
|
3227
|
+
return fromBase64$1(s.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(s.length / 4) * 4, "="));
|
|
3226
3228
|
}
|
|
3227
3229
|
|
|
3228
3230
|
/**
|
|
@@ -3233,7 +3235,7 @@ function base64UrlDecode(s) {
|
|
|
3233
3235
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
3234
3236
|
* `package.json` `version`.
|
|
3235
3237
|
*/
|
|
3236
|
-
const VERSION = "0.
|
|
3238
|
+
const VERSION = "0.11.0-alpha.0";
|
|
3237
3239
|
|
|
3238
3240
|
/**
|
|
3239
3241
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -3686,8 +3688,8 @@ function gatedBlind(input) {
|
|
|
3686
3688
|
* unblind each, concatenate, HKDF with the gated-factor domain separation.
|
|
3687
3689
|
*/
|
|
3688
3690
|
async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
|
|
3689
|
-
const out1 = oprfFinalize(input, blind, fromBase64(evaluatedB64));
|
|
3690
|
-
const out2 = oprfFinalize(input, blind, fromBase64(evaluated2B64));
|
|
3691
|
+
const out1 = oprfFinalize(input, blind, fromBase64$1(evaluatedB64));
|
|
3692
|
+
const out2 = oprfFinalize(input, blind, fromBase64$1(evaluated2B64));
|
|
3691
3693
|
const ikm = new Uint8Array(out1.length + out2.length);
|
|
3692
3694
|
ikm.set(out1);
|
|
3693
3695
|
ikm.set(out2, out1.length);
|
|
@@ -3769,6 +3771,30 @@ function rpIdUsableForOrigin(rpId, host) {
|
|
|
3769
3771
|
return false;
|
|
3770
3772
|
return h === rpId || h.endsWith(`.${rpId}`);
|
|
3771
3773
|
}
|
|
3774
|
+
/**
|
|
3775
|
+
* Would {@link AuthNamespace.loginWithPasskey} accept a factor recorded with
|
|
3776
|
+
* this `rpId`, from `host`?
|
|
3777
|
+
*
|
|
3778
|
+
* This is deliberately NOT the same as {@link rpIdUsableForOrigin}. That answers
|
|
3779
|
+
* a raw WebAuthn question and is `false` for a missing rpId. But a factor with
|
|
3780
|
+
* NO recorded rpId predates per-origin enrolment, and `loginWithPasskey`
|
|
3781
|
+
* resolves it as `params.rpId ?? host` — so it IS usable, from anywhere.
|
|
3782
|
+
*
|
|
3783
|
+
* Callers deciding "should I even try a passkey here?" must use this one.
|
|
3784
|
+
* Reaching for `rpIdUsableForOrigin` instead silently excludes every legacy
|
|
3785
|
+
* passkey holder, which has locked real users out of production twice.
|
|
3786
|
+
*
|
|
3787
|
+
* The `?? host` fallback applies to nullish only, matching `loginWithPasskey`:
|
|
3788
|
+
* an empty-string rpId stays unusable rather than resolving to the host.
|
|
3789
|
+
*/
|
|
3790
|
+
function passkeyUsableFromOrigin(rpId, host) {
|
|
3791
|
+
const h = host ?? defaultRpId();
|
|
3792
|
+
if (!h)
|
|
3793
|
+
return false;
|
|
3794
|
+
if (rpId == null)
|
|
3795
|
+
return true;
|
|
3796
|
+
return rpIdUsableForOrigin(rpId, h);
|
|
3797
|
+
}
|
|
3772
3798
|
/** Thrown when the account's passkey belongs to a different origin. Callers
|
|
3773
3799
|
* should treat this as "no passkey here" and fall back to another factor. */
|
|
3774
3800
|
class PasskeyOriginError extends Error {
|
|
@@ -3997,13 +4023,13 @@ async function persistDeviceIdentity(v) {
|
|
|
3997
4023
|
const plain = TE.encode(JSON.stringify({
|
|
3998
4024
|
username: v.username,
|
|
3999
4025
|
commitment: v.commitment,
|
|
4000
|
-
seed: toBase64(v.seed),
|
|
4026
|
+
seed: toBase64$1(v.seed),
|
|
4001
4027
|
pairedAt: v.pairedAt,
|
|
4002
4028
|
}));
|
|
4003
4029
|
const bridge = keystoreBridge();
|
|
4004
4030
|
let envelope;
|
|
4005
4031
|
if (bridge) {
|
|
4006
|
-
envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64(plain)) };
|
|
4032
|
+
envelope = { v: 1, ks: 1, ct: await bridge.wrap(toBase64$1(plain)) };
|
|
4007
4033
|
}
|
|
4008
4034
|
else {
|
|
4009
4035
|
const key = await wrapKey(true);
|
|
@@ -4011,7 +4037,7 @@ async function persistDeviceIdentity(v) {
|
|
|
4011
4037
|
throw new DeviceStoreUnavailableError("no IndexedDB for the wrapping key");
|
|
4012
4038
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
4013
4039
|
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv }, key, plain));
|
|
4014
|
-
envelope = { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
4040
|
+
envelope = { v: 1, iv: toBase64$1(iv), ct: toBase64$1(ct) };
|
|
4015
4041
|
}
|
|
4016
4042
|
blobs.set(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(envelope));
|
|
4017
4043
|
}
|
|
@@ -4035,18 +4061,18 @@ async function loadDeviceIdentity() {
|
|
|
4035
4061
|
if (envelope.ks === 1) {
|
|
4036
4062
|
if (!bridge)
|
|
4037
4063
|
return null; // sealed by the native shell; nothing else can open it
|
|
4038
|
-
plain = fromBase64(await bridge.unwrap(envelope.ct));
|
|
4064
|
+
plain = fromBase64$1(await bridge.unwrap(envelope.ct));
|
|
4039
4065
|
}
|
|
4040
4066
|
else {
|
|
4041
4067
|
const key = await wrapKey(false);
|
|
4042
4068
|
if (!key || !envelope.iv)
|
|
4043
4069
|
return null;
|
|
4044
|
-
plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64(envelope.iv) }, key, fromBase64(envelope.ct)));
|
|
4070
|
+
plain = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromBase64$1(envelope.iv) }, key, fromBase64$1(envelope.ct)));
|
|
4045
4071
|
}
|
|
4046
4072
|
const parsed = JSON.parse(TD.decode(plain));
|
|
4047
4073
|
if (!parsed?.username || !parsed?.commitment || !parsed?.seed)
|
|
4048
4074
|
return null;
|
|
4049
|
-
const seed = fromBase64(parsed.seed);
|
|
4075
|
+
const seed = fromBase64$1(parsed.seed);
|
|
4050
4076
|
if (seed.length !== 32)
|
|
4051
4077
|
return null;
|
|
4052
4078
|
return {
|
|
@@ -4080,7 +4106,7 @@ async function clearDeviceIdentity() {
|
|
|
4080
4106
|
async function fingerprintOf(publicKey) {
|
|
4081
4107
|
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey));
|
|
4082
4108
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", raw));
|
|
4083
|
-
return toBase64(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
4109
|
+
return toBase64$1(digest).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
4084
4110
|
}
|
|
4085
4111
|
async function loadOrCreateIdentityKey() {
|
|
4086
4112
|
const vault = keyVault();
|
|
@@ -4720,9 +4746,11 @@ class HostedAuth {
|
|
|
4720
4746
|
/**
|
|
4721
4747
|
* The account's paired devices. Session-authed.
|
|
4722
4748
|
*
|
|
4723
|
-
* UI copy note (spec §10.6): removing a device
|
|
4724
|
-
*
|
|
4725
|
-
*
|
|
4749
|
+
* UI copy note (spec §10.6): removing a device forces a full re-approval next
|
|
4750
|
+
* time, but be careful what you promise. It does NOT sign the device out —
|
|
4751
|
+
* the server returns `session_revoked: false`, and that session runs until it
|
|
4752
|
+
* expires — and it cannot take back the master seed the device already holds.
|
|
4753
|
+
* Users assume "remove" means "locked out immediately"; it does not.
|
|
4726
4754
|
*/
|
|
4727
4755
|
async listPairedDevices() {
|
|
4728
4756
|
const res = await this.deviceApi("GET", "/api/auth/devices", undefined, true);
|
|
@@ -4740,7 +4768,15 @@ class HostedAuth {
|
|
|
4740
4768
|
};
|
|
4741
4769
|
});
|
|
4742
4770
|
}
|
|
4743
|
-
/**
|
|
4771
|
+
/**
|
|
4772
|
+
* Un-pair a device. Session-authed.
|
|
4773
|
+
*
|
|
4774
|
+
* It stops being a "known" device, so pairing it again needs full approval
|
|
4775
|
+
* with a fresh factor. It does NOT end that device's current session — the
|
|
4776
|
+
* server reports `session_revoked: false` — and it cannot take back the
|
|
4777
|
+
* master seed the device already holds. Do not write UI copy promising an
|
|
4778
|
+
* immediate sign-out; users will assume it and be wrong.
|
|
4779
|
+
*/
|
|
4744
4780
|
async revokePairedDevice(id) {
|
|
4745
4781
|
if (!id)
|
|
4746
4782
|
throw new DevicePairingError("error", { message: "revokePairedDevice: `id` is required." });
|
|
@@ -5136,6 +5172,8 @@ class VaultUnavailableError extends Error {
|
|
|
5136
5172
|
* ZK identity auth. One instance per {@link Client}; reads/writes the shared
|
|
5137
5173
|
* {@link SessionState}.
|
|
5138
5174
|
*/
|
|
5175
|
+
/** HKDF label separating a device wrap key from every other use of the seed. */
|
|
5176
|
+
const DEVICE_WRAP_INFO = "muhkoo-device-key-v1";
|
|
5139
5177
|
class ZkAuth {
|
|
5140
5178
|
deps;
|
|
5141
5179
|
constructor(deps) {
|
|
@@ -5313,7 +5351,34 @@ class ZkAuth {
|
|
|
5313
5351
|
return { user, unlocked: true };
|
|
5314
5352
|
try {
|
|
5315
5353
|
const factors = await this.listFactors();
|
|
5316
|
-
|
|
5354
|
+
// Only auto-attempt when a passkey is usable from THIS origin.
|
|
5355
|
+
//
|
|
5356
|
+
// Passkeys are per-origin, and an account's passkey may belong to a
|
|
5357
|
+
// different host entirely (hosted-auth enrolment records
|
|
5358
|
+
// `auth.muhkoo.dev`, so a user who added a passkey there is
|
|
5359
|
+
// mismatched on every app host). Attempting anyway means
|
|
5360
|
+
// `loginWithPasskey` does an unauthenticated vault read for a
|
|
5361
|
+
// passkey this origin doesn't have. Today that returns a decoy with
|
|
5362
|
+
// no `prfSalt`, so it fails the guard before touching WebAuthn — the
|
|
5363
|
+
// silence is incidental. Once the decoy is reshaped to be
|
|
5364
|
+
// indistinguishable (it must carry `prfSalt` to stop being an
|
|
5365
|
+
// account-enumeration oracle) that same call would sail through and
|
|
5366
|
+
// fire a modal `navigator.credentials.get()` at page load, on every
|
|
5367
|
+
// boot, for a credential that does not exist. `resume()` is awaited
|
|
5368
|
+
// inside every app's readiness gate, so that dialog lands on the
|
|
5369
|
+
// splash screen before the user has asked for anything.
|
|
5370
|
+
//
|
|
5371
|
+
// `listFactors` is session-authenticated and returns real records,
|
|
5372
|
+
// so decoys never appear here — this reads true rpIds.
|
|
5373
|
+
//
|
|
5374
|
+
// `passkeyUsableFromOrigin`, NOT `rpIdUsableForOrigin`: a factor
|
|
5375
|
+
// with no recorded rpId predates per-origin enrolment and is still
|
|
5376
|
+
// usable (`loginWithPasskey` resolves it as `params.rpId ?? host`).
|
|
5377
|
+
// The raw predicate is false for undefined, so using it here would
|
|
5378
|
+
// skip exactly those users — the regression that locked legacy
|
|
5379
|
+
// passkey holders out of production twice.
|
|
5380
|
+
const host = defaultRpId();
|
|
5381
|
+
if (factors.some((f) => f.type === "passkey" && passkeyUsableFromOrigin(f.rpId, host))) {
|
|
5317
5382
|
await this.loginWithPasskey(user.username);
|
|
5318
5383
|
}
|
|
5319
5384
|
}
|
|
@@ -5400,7 +5465,7 @@ class ZkAuth {
|
|
|
5400
5465
|
const { verifyToken, email: verified } = await this.deps.auth.emailOtpVerify({ token, code });
|
|
5401
5466
|
const input = await emailFactorInput(username, verified);
|
|
5402
5467
|
const { blind, blinded } = gatedBlind(input);
|
|
5403
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "enroll", verifyToken);
|
|
5468
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "enroll", verifyToken);
|
|
5404
5469
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
5405
5470
|
const wrapped = await wrapSeed(seed, key);
|
|
5406
5471
|
await this.deps.auth.vaultPutFactor(token, { id: "email", type: "email", wrap: wrapped.ct, iv: wrapped.iv, params: { email: verified }, createdAt: Date.now() }, verifyToken);
|
|
@@ -5428,7 +5493,7 @@ class ZkAuth {
|
|
|
5428
5493
|
}
|
|
5429
5494
|
const input = await emailFactorInput(username, email);
|
|
5430
5495
|
const { blind, blinded } = gatedBlind(input);
|
|
5431
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "email", "recover", verifyToken);
|
|
5496
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "email", "recover", verifyToken);
|
|
5432
5497
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
5433
5498
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
5434
5499
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -5456,7 +5521,7 @@ class ZkAuth {
|
|
|
5456
5521
|
const { verifyToken, sub, emailHint } = await this.deps.auth.googleVerify({ idToken, token });
|
|
5457
5522
|
const input = await googleFactorInput(username, sub);
|
|
5458
5523
|
const { blind, blinded } = gatedBlind(input);
|
|
5459
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "enroll", verifyToken);
|
|
5524
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "enroll", verifyToken);
|
|
5460
5525
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
5461
5526
|
const wrapped = await wrapSeed(seed, key);
|
|
5462
5527
|
await this.deps.auth.vaultPutFactor(token, { id: "google", type: "google", wrap: wrapped.ct, iv: wrapped.iv, params: { sub, emailHint: emailHint ?? undefined }, createdAt: Date.now() }, verifyToken);
|
|
@@ -5474,7 +5539,7 @@ class ZkAuth {
|
|
|
5474
5539
|
throw new Error("No Google account is linked to this username.");
|
|
5475
5540
|
const input = await googleFactorInput(username, sub);
|
|
5476
5541
|
const { blind, blinded } = gatedBlind(input);
|
|
5477
|
-
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64(blinded), "google", "recover", verifyToken);
|
|
5542
|
+
const evals = await this.deps.auth.oprfEvaluateGated(username, toBase64$1(blinded), "google", "recover", verifyToken);
|
|
5478
5543
|
const key = await gatedWrapKey(input, blind, evals.evaluated, evals.evaluated2);
|
|
5479
5544
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
5480
5545
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -5555,11 +5620,11 @@ class ZkAuth {
|
|
|
5555
5620
|
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
5556
5621
|
const wrapped = await wrapSeed(seed, key);
|
|
5557
5622
|
await this.deps.auth.vaultPutFactor(token, {
|
|
5558
|
-
id: `passkey:${toBase64(credentialId).slice(0, 16)}`,
|
|
5623
|
+
id: `passkey:${toBase64$1(credentialId).slice(0, 16)}`,
|
|
5559
5624
|
type: "passkey",
|
|
5560
5625
|
wrap: wrapped.ct,
|
|
5561
5626
|
iv: wrapped.iv,
|
|
5562
|
-
params: { credentialId: toBase64(credentialId), prfSalt: toBase64(prfSalt), rpId },
|
|
5627
|
+
params: { credentialId: toBase64$1(credentialId), prfSalt: toBase64$1(prfSalt), rpId },
|
|
5563
5628
|
label: opts?.label ?? "Passkey",
|
|
5564
5629
|
createdAt: Date.now(),
|
|
5565
5630
|
});
|
|
@@ -5584,7 +5649,7 @@ class ZkAuth {
|
|
|
5584
5649
|
// to enroll a passkey for this origin.
|
|
5585
5650
|
if (!rpIdUsableForOrigin(rpId, host))
|
|
5586
5651
|
throw new PasskeyOriginError();
|
|
5587
|
-
const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64(params.credentialId), fromBase64(params.prfSalt));
|
|
5652
|
+
const prfOutput = await evaluatePasskeyPrf(rpId, fromBase64$1(params.credentialId), fromBase64$1(params.prfSalt));
|
|
5588
5653
|
const key = await wrapKeyFromBytes(prfOutput, "muhkoo-passkey-wrap");
|
|
5589
5654
|
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
5590
5655
|
const identity = await deriveIdentityFromSeed(seed);
|
|
@@ -5663,7 +5728,107 @@ class ZkAuth {
|
|
|
5663
5728
|
*/
|
|
5664
5729
|
get seedBase64() {
|
|
5665
5730
|
const seed = this.deps.session.seed;
|
|
5666
|
-
return seed ? toBase64(seed) : null;
|
|
5731
|
+
return seed ? toBase64$1(seed) : null;
|
|
5732
|
+
}
|
|
5733
|
+
/**
|
|
5734
|
+
* Pair this machine, so it can unlock the account later without a password.
|
|
5735
|
+
*
|
|
5736
|
+
* Generates a random 32-byte key, wraps the master seed under it, and stores
|
|
5737
|
+
* only the CIPHERTEXT in the vault as a `device` factor. The key is returned
|
|
5738
|
+
* for the caller to persist locally and is never sent anywhere.
|
|
5739
|
+
*
|
|
5740
|
+
* The split is the point: the seed itself never lands on disk, and the
|
|
5741
|
+
* server holds a blob it cannot open. Revoking the factor
|
|
5742
|
+
* ({@link revokeDevice}) makes the local key inert immediately — which a
|
|
5743
|
+
* stored master seed can never be, because nothing can reach out and
|
|
5744
|
+
* un-store it.
|
|
5745
|
+
*
|
|
5746
|
+
* Returns the factor id and the key, both base64. Losing the key just means
|
|
5747
|
+
* re-pairing; leaking it is equivalent to leaking the seed for as long as
|
|
5748
|
+
* the factor exists, so store it with the tightest permissions available.
|
|
5749
|
+
*/
|
|
5750
|
+
async enrollDevice(label) {
|
|
5751
|
+
const seed = this.deps.session.seed;
|
|
5752
|
+
const token = this.deps.session.token;
|
|
5753
|
+
if (!seed || !token)
|
|
5754
|
+
throw new Error("Sign in first to pair this device.");
|
|
5755
|
+
const keyBytes = randomSeed(); // 32 bytes of CSPRNG
|
|
5756
|
+
const key = await wrapKeyFromBytes(keyBytes, DEVICE_WRAP_INFO);
|
|
5757
|
+
const wrapped = await wrapSeed(seed, key);
|
|
5758
|
+
// 128 bits of id, so reading a factor by id is not an enumeration surface.
|
|
5759
|
+
const factorId = `device:${toBase64$1(randomSeed()).replace(/[+/=]/g, "").slice(0, 22)}`;
|
|
5760
|
+
await this.deps.auth.vaultPutFactor(token, {
|
|
5761
|
+
id: factorId,
|
|
5762
|
+
type: "device",
|
|
5763
|
+
wrap: wrapped.ct,
|
|
5764
|
+
iv: wrapped.iv,
|
|
5765
|
+
label,
|
|
5766
|
+
createdAt: Date.now(),
|
|
5767
|
+
});
|
|
5768
|
+
return { factorId, deviceKey: toBase64$1(keyBytes) };
|
|
5769
|
+
}
|
|
5770
|
+
/**
|
|
5771
|
+
* Unlock using a key stored by {@link enrollDevice}.
|
|
5772
|
+
*
|
|
5773
|
+
* Fetches the wrapped seed and opens it locally. Establishes the encryption
|
|
5774
|
+
* identity only — pair it with a session for calls that need one.
|
|
5775
|
+
*
|
|
5776
|
+
* Throws distinguishably: a REVOKED factor is gone from the vault and says
|
|
5777
|
+
* so, where a wrong key fails to decrypt. A machine that has had its access
|
|
5778
|
+
* withdrawn should be able to tell you that, not just "decryption failed".
|
|
5779
|
+
*/
|
|
5780
|
+
async unlockWithDevice(username, factorId, deviceKey) {
|
|
5781
|
+
const { factor } = await this.deps.auth.vaultRead(username, "device", undefined, undefined, factorId);
|
|
5782
|
+
if (!factor) {
|
|
5783
|
+
throw new Error("This device is no longer paired — its access was revoked. Sign in again to re-pair.");
|
|
5784
|
+
}
|
|
5785
|
+
if (!factor.wrap || !factor.iv)
|
|
5786
|
+
throw new Error("The stored device factor is incomplete; re-pair this device.");
|
|
5787
|
+
const key = await wrapKeyFromBytes(fromBase64$1(deviceKey), DEVICE_WRAP_INFO);
|
|
5788
|
+
const seed = await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
|
|
5789
|
+
this.deps.session.setSeed(seed);
|
|
5790
|
+
}
|
|
5791
|
+
/** Paired machines, newest first. Metadata only — never the ciphertext. */
|
|
5792
|
+
async listDevices() {
|
|
5793
|
+
const token = this.deps.session.token;
|
|
5794
|
+
if (!token)
|
|
5795
|
+
throw new Error("Sign in first to list paired devices.");
|
|
5796
|
+
const { factors } = await this.deps.auth.vaultFactors(token);
|
|
5797
|
+
return factors
|
|
5798
|
+
.filter((f) => f.type === "device")
|
|
5799
|
+
.map((f) => ({ id: f.id, label: f.label, createdAt: f.createdAt }))
|
|
5800
|
+
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
|
|
5801
|
+
}
|
|
5802
|
+
/** Withdraw a machine's access. Its stored key stops working immediately. */
|
|
5803
|
+
async revokeDevice(factorId) {
|
|
5804
|
+
const token = this.deps.session.token;
|
|
5805
|
+
if (!token)
|
|
5806
|
+
throw new Error("Sign in first to revoke a device.");
|
|
5807
|
+
const { deleted } = await this.deps.auth.vaultDeleteFactor(token, factorId);
|
|
5808
|
+
return deleted;
|
|
5809
|
+
}
|
|
5810
|
+
/**
|
|
5811
|
+
* Restore an unlocked identity from a seed you already hold.
|
|
5812
|
+
*
|
|
5813
|
+
* The inverse of {@link seedBase64}, for hosts that persist the seed
|
|
5814
|
+
* themselves rather than re-deriving it from a password: a CLI holding it in
|
|
5815
|
+
* a 0600 file, a server-side tool, a test. Without this the seed is a
|
|
5816
|
+
* one-way export and anything outside a browser has to run a full ZK login —
|
|
5817
|
+
* circuits download and proof — on every single invocation.
|
|
5818
|
+
*
|
|
5819
|
+
* Establishes the ENCRYPTION identity only. It does not authenticate: pair
|
|
5820
|
+
* it with a session token (`sessionStore`) for calls that need one.
|
|
5821
|
+
*
|
|
5822
|
+
* Treat a persisted seed as the master credential it is. It is not scoped
|
|
5823
|
+
* and cannot be revoked server-side the way a session token can — rotating
|
|
5824
|
+
* it means moving the identity.
|
|
5825
|
+
*/
|
|
5826
|
+
unlockWithSeed(seedBase64) {
|
|
5827
|
+
const seed = fromBase64$1(seedBase64);
|
|
5828
|
+
if (seed.length !== 32) {
|
|
5829
|
+
throw new Error(`auth.zk.unlockWithSeed: expected a 32-byte seed, got ${seed.length}`);
|
|
5830
|
+
}
|
|
5831
|
+
this.deps.session.setSeed(seed);
|
|
5667
5832
|
}
|
|
5668
5833
|
// -------------------------------------------------------------------------
|
|
5669
5834
|
/** Poseidon commitment for an identity — the value the server stores. */
|
|
@@ -5682,8 +5847,8 @@ class ZkAuth {
|
|
|
5682
5847
|
async passwordWrapKey(username, password) {
|
|
5683
5848
|
const pre = passwordPreHash(username, password);
|
|
5684
5849
|
const { blind, blinded } = oprfBlind(pre);
|
|
5685
|
-
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64(blinded));
|
|
5686
|
-
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64(evaluated)));
|
|
5850
|
+
const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64$1(blinded));
|
|
5851
|
+
return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64$1(evaluated)));
|
|
5687
5852
|
}
|
|
5688
5853
|
/**
|
|
5689
5854
|
* Try to recover the master seed from the password factor.
|
|
@@ -5789,16 +5954,16 @@ class StorageCipher {
|
|
|
5789
5954
|
/** Seal a JSON-serializable value into an {@link EncryptedEnvelope}. */
|
|
5790
5955
|
async encrypt(value) {
|
|
5791
5956
|
const key = await this.keyPromise;
|
|
5792
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
5957
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
5793
5958
|
const plaintext = utf8Encode(JSON.stringify(value));
|
|
5794
5959
|
const ciphertext = await encryptAesGcm(key, iv, plaintext);
|
|
5795
|
-
return { _enc: "a256gcm", iv: toBase64$
|
|
5960
|
+
return { _enc: "a256gcm", iv: toBase64$2(iv), ct: toBase64$2(ciphertext) };
|
|
5796
5961
|
}
|
|
5797
5962
|
/** Open an {@link EncryptedEnvelope} back into the original value. */
|
|
5798
5963
|
async decrypt(envelope) {
|
|
5799
5964
|
const key = await this.keyPromise;
|
|
5800
|
-
const iv = fromBase64$
|
|
5801
|
-
const ciphertext = fromBase64$
|
|
5965
|
+
const iv = fromBase64$2(envelope.iv);
|
|
5966
|
+
const ciphertext = fromBase64$2(envelope.ct);
|
|
5802
5967
|
const plaintext = await decryptAesGcm(key, iv, ciphertext);
|
|
5803
5968
|
return JSON.parse(utf8Decode(plaintext));
|
|
5804
5969
|
}
|
|
@@ -6129,6 +6294,8 @@ class WSTransport extends EventCore {
|
|
|
6129
6294
|
*/
|
|
6130
6295
|
class KvNamespace {
|
|
6131
6296
|
deps;
|
|
6297
|
+
/** One personal-space socket, shared by every subscriber. */
|
|
6298
|
+
feed = null;
|
|
6132
6299
|
cipher = null;
|
|
6133
6300
|
cipherIdentity = null;
|
|
6134
6301
|
constructor(deps) {
|
|
@@ -6253,22 +6420,64 @@ class KvNamespace {
|
|
|
6253
6420
|
if (!token)
|
|
6254
6421
|
throw new Error("client.kv.on: not signed in.");
|
|
6255
6422
|
const commitment = this.commitment();
|
|
6256
|
-
|
|
6423
|
+
`${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
6257
6424
|
// Ride a WSTransport rather than a bare socket: it keeps the (otherwise
|
|
6258
6425
|
// idle) personal-space feed warm with heartbeat pings, auto-reconnects
|
|
6259
6426
|
// if it drops, and namespaces its events by `id` so this feed never
|
|
6260
6427
|
// crosses wires with chat/space transports on the shared EventCore bus.
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6265
|
-
|
|
6266
|
-
|
|
6267
|
-
|
|
6268
|
-
|
|
6428
|
+
return this.onRaw((raw) => {
|
|
6429
|
+
void this.dispatchChange(raw, handler);
|
|
6430
|
+
});
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* Subscribe to the personal space's raw change feed.
|
|
6434
|
+
*
|
|
6435
|
+
* Every key this user owns crosses this socket, not only `client.kv`
|
|
6436
|
+
* collections — the VFS keeps its directory records here too. Consumers get
|
|
6437
|
+
* the frame untouched: no decryption, no offline-cache merge, because the
|
|
6438
|
+
* value's meaning depends on which subsystem wrote it.
|
|
6439
|
+
*
|
|
6440
|
+
* The transport is SHARED and reference-counted. `WSTransport` namespaces
|
|
6441
|
+
* its events by `id`, so two transports with the same id would cross wires
|
|
6442
|
+
* on the shared event bus — and a second socket to the same Durable Object
|
|
6443
|
+
* would be pure waste besides.
|
|
6444
|
+
*/
|
|
6445
|
+
onRaw(handler) {
|
|
6446
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
6447
|
+
throw new Error("client.kv.onRaw: no `WebSocket` global available in this runtime.");
|
|
6448
|
+
}
|
|
6449
|
+
const token = this.deps.session.token;
|
|
6450
|
+
if (!token)
|
|
6451
|
+
throw new Error("client.kv.onRaw: not signed in.");
|
|
6452
|
+
const commitment = this.commitment();
|
|
6453
|
+
if (!this.feed) {
|
|
6454
|
+
const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
|
|
6455
|
+
// Ride a WSTransport rather than a bare socket: it keeps the
|
|
6456
|
+
// (otherwise idle) feed warm with heartbeat pings, auto-reconnects
|
|
6457
|
+
// if it drops, and namespaces its events by `id`.
|
|
6458
|
+
const transport = new WSTransport({ url, id: `personal:${commitment}` });
|
|
6459
|
+
const handlers = new Set();
|
|
6460
|
+
const onMessage = (e) => {
|
|
6461
|
+
for (const fn of handlers)
|
|
6462
|
+
fn(e.detail);
|
|
6463
|
+
};
|
|
6464
|
+
transport.on(EventCoreEvents.MESSAGE, onMessage);
|
|
6465
|
+
// Fire-and-forget: the feed is best-effort, and the transport queues
|
|
6466
|
+
// nothing it needs before CONNECTED (server→client only).
|
|
6467
|
+
void transport.connect();
|
|
6468
|
+
this.feed = { transport, handlers, onMessage };
|
|
6469
|
+
}
|
|
6470
|
+
const feed = this.feed;
|
|
6471
|
+
feed.handlers.add(handler);
|
|
6269
6472
|
return () => {
|
|
6270
|
-
|
|
6271
|
-
|
|
6473
|
+
feed.handlers.delete(handler);
|
|
6474
|
+
// Last subscriber out closes the socket, so an app that unsubscribes
|
|
6475
|
+
// everything is not left holding an open connection.
|
|
6476
|
+
if (feed.handlers.size === 0 && this.feed === feed) {
|
|
6477
|
+
feed.transport.off(EventCoreEvents.MESSAGE, feed.onMessage);
|
|
6478
|
+
feed.transport.disconnect();
|
|
6479
|
+
this.feed = null;
|
|
6480
|
+
}
|
|
6272
6481
|
};
|
|
6273
6482
|
}
|
|
6274
6483
|
// -------------------------------------------------------------------------
|
|
@@ -8241,12 +8450,12 @@ function canonicalMessage(fields) {
|
|
|
8241
8450
|
/** Sign the canonical bytes with the sender's ECDSA identity private key. */
|
|
8242
8451
|
async function signSpaceMessage(canonical, ecdsaPriv) {
|
|
8243
8452
|
const sig = await getSubtle().sign(ECDSA_SIGN, ecdsaPriv, utf8Encode(canonical));
|
|
8244
|
-
return toBase64$
|
|
8453
|
+
return toBase64$2(new Uint8Array(sig));
|
|
8245
8454
|
}
|
|
8246
8455
|
/** Verify a signature over the canonical bytes against the sender's ECDSA key. */
|
|
8247
8456
|
async function verifySpaceMessage(canonical, sigB64, ecdsaPub) {
|
|
8248
8457
|
try {
|
|
8249
|
-
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$
|
|
8458
|
+
return await getSubtle().verify(ECDSA_SIGN, ecdsaPub, fromBase64$2(sigB64), utf8Encode(canonical));
|
|
8250
8459
|
}
|
|
8251
8460
|
catch {
|
|
8252
8461
|
return false;
|
|
@@ -8277,13 +8486,13 @@ async function wrapSpaceKey(kSpace, epoch, recipientEcdhPub) {
|
|
|
8277
8486
|
const eph = await getSubtle().generateKey(ECDH_PARAMS, true, ["deriveBits"]);
|
|
8278
8487
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: recipientEcdhPub }, eph.privateKey, P384_BITS));
|
|
8279
8488
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${epoch}`));
|
|
8280
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
8489
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
8281
8490
|
const ciphertext = await encryptAesGcm(wrapKey, iv, kSpace);
|
|
8282
8491
|
return {
|
|
8283
8492
|
epoch,
|
|
8284
8493
|
ephemeralPub: await exportEcdhPublicKey(eph.publicKey),
|
|
8285
|
-
iv: toBase64$
|
|
8286
|
-
ciphertext: toBase64$
|
|
8494
|
+
iv: toBase64$2(iv),
|
|
8495
|
+
ciphertext: toBase64$2(ciphertext),
|
|
8287
8496
|
alg: WRAP_ALG,
|
|
8288
8497
|
};
|
|
8289
8498
|
}
|
|
@@ -8295,23 +8504,23 @@ async function unwrapSpaceKey(wrapped, ownEcdhPriv) {
|
|
|
8295
8504
|
const ephPub = await importEcdhPublicKey(wrapped.ephemeralPub);
|
|
8296
8505
|
const shared = new Uint8Array(await getSubtle().deriveBits({ name: "ECDH", public: ephPub }, ownEcdhPriv, P384_BITS));
|
|
8297
8506
|
const wrapKey = await deriveBitsHkdf(shared, WRAP_INFO, AES_GCM_KEY_BYTES, utf8Encode(`epoch:${wrapped.epoch}`));
|
|
8298
|
-
return decryptAesGcm(wrapKey, fromBase64$
|
|
8507
|
+
return decryptAesGcm(wrapKey, fromBase64$2(wrapped.iv), fromBase64$2(wrapped.ciphertext));
|
|
8299
8508
|
}
|
|
8300
8509
|
// ---------------------------------------------------------------------------
|
|
8301
8510
|
// Message sealing
|
|
8302
8511
|
// ---------------------------------------------------------------------------
|
|
8303
8512
|
/** Seal an already-serialized message string under the group key. */
|
|
8304
8513
|
async function sealSerialized(kSpace, serialized) {
|
|
8305
|
-
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
8514
|
+
const iv = randomBytes(AES_GCM_IV_BYTES$1);
|
|
8306
8515
|
const ciphertext = await encryptAesGcm(kSpace, iv, utf8Encode(serialized));
|
|
8307
|
-
return { iv: toBase64$
|
|
8516
|
+
return { iv: toBase64$2(iv), ciphertext: toBase64$2(ciphertext) };
|
|
8308
8517
|
}
|
|
8309
8518
|
/**
|
|
8310
8519
|
* Open a sealed payload back into its serialized message string. Throws (GCM
|
|
8311
8520
|
* tag failure) on the wrong group key or a tampered ciphertext.
|
|
8312
8521
|
*/
|
|
8313
8522
|
async function openSerialized(kSpace, iv, ciphertext) {
|
|
8314
|
-
const plaintext = await decryptAesGcm(kSpace, fromBase64$
|
|
8523
|
+
const plaintext = await decryptAesGcm(kSpace, fromBase64$2(iv), fromBase64$2(ciphertext));
|
|
8315
8524
|
return utf8Decode(plaintext);
|
|
8316
8525
|
}
|
|
8317
8526
|
|
|
@@ -10815,6 +11024,16 @@ class SpaceNamespace {
|
|
|
10815
11024
|
* Create a new space: generate its keypair (public key = id), register
|
|
10816
11025
|
* metadata, mint the initial group key, and connect. Returns the open Space.
|
|
10817
11026
|
*/
|
|
11027
|
+
/**
|
|
11028
|
+
* Create a space.
|
|
11029
|
+
*
|
|
11030
|
+
* `messaging: false` registers the space and stops there — no group-key
|
|
11031
|
+
* bootstrap and no WebSocket. Use it for a space that only ever holds
|
|
11032
|
+
* FILES: each chunk carries its own key inside its manifest, so the group
|
|
11033
|
+
* keyring buys nothing, and opening a socket makes the write path depend on
|
|
11034
|
+
* a live connection. That dependency is wrong for a CLI, and it is what made
|
|
11035
|
+
* the first VFS write hang forever instead of failing.
|
|
11036
|
+
*/
|
|
10818
11037
|
async createSpace(opts = {}) {
|
|
10819
11038
|
const historyPolicy = opts.historyPolicy ?? "static";
|
|
10820
11039
|
const identity = await generateSpaceIdentity();
|
|
@@ -10823,7 +11042,8 @@ class SpaceNamespace {
|
|
|
10823
11042
|
// its first member — so the metadata POST must carry the user session.
|
|
10824
11043
|
await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
|
|
10825
11044
|
const space = await this.build(identity.id, historyPolicy);
|
|
10826
|
-
|
|
11045
|
+
if (opts.messaging !== false)
|
|
11046
|
+
await space.create();
|
|
10827
11047
|
return space;
|
|
10828
11048
|
}
|
|
10829
11049
|
/** Join an existing space by id (its public key). Resolves once a key arrives. */
|
|
@@ -11020,7 +11240,7 @@ class SpaceNamespace {
|
|
|
11020
11240
|
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
11021
11241
|
if (this.deps.chatKeys && seed) {
|
|
11022
11242
|
try {
|
|
11023
|
-
await this.deps.chatKeys.ensure(memberId, toBase64$
|
|
11243
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
|
|
11024
11244
|
}
|
|
11025
11245
|
catch (e) {
|
|
11026
11246
|
if (staging)
|
|
@@ -11325,6 +11545,894 @@ class AccessTokensNamespace {
|
|
|
11325
11545
|
}
|
|
11326
11546
|
}
|
|
11327
11547
|
|
|
11548
|
+
/**
|
|
11549
|
+
* Wire types for the VFS metadata layer.
|
|
11550
|
+
*
|
|
11551
|
+
* ## What is stored where
|
|
11552
|
+
*
|
|
11553
|
+
* The filesystem is a graph of **directory records**, one per directory, each
|
|
11554
|
+
* held under a personal-space KV key and encrypted client-side. A record lists
|
|
11555
|
+
* only its immediate children — so listing a directory is one fetch, and you
|
|
11556
|
+
* only ever pay for the subtrees you actually open.
|
|
11557
|
+
*
|
|
11558
|
+
* File CONTENT is not here. Bytes are written with `client.storage.writeFile`
|
|
11559
|
+
* exactly as any other file — into a real Space, with a real gated manifest —
|
|
11560
|
+
* and the entry keeps the returned `FileManifest` as its handle.
|
|
11561
|
+
* `client.storage.readByManifest(manifest)` then reconstructs the bytes from
|
|
11562
|
+
* the content-addressed shard store with no space membership or directory
|
|
11563
|
+
* lookup needed, which is what makes a file shareable by handing over the
|
|
11564
|
+
* handle alone.
|
|
11565
|
+
*
|
|
11566
|
+
* This supersedes the flat personal mirror `writeFile` already maintains
|
|
11567
|
+
* (`{spaceId, manifest}` keyed by manifest id): the same metadata, in the same
|
|
11568
|
+
* personal space, arranged as a tree you can actually navigate.
|
|
11569
|
+
*
|
|
11570
|
+
* ## Why directories have stable random ids, not content hashes
|
|
11571
|
+
*
|
|
11572
|
+
* A git-style tree keys each directory by the hash of its contents, so writing
|
|
11573
|
+
* one file rewrites every ancestor up to the root. Here a directory's id is
|
|
11574
|
+
* random and permanent, so a file write touches exactly ONE record — its
|
|
11575
|
+
* parent — and renaming or moving a directory edits only the parent entry
|
|
11576
|
+
* while the whole subtree underneath stays byte-identical and untouched.
|
|
11577
|
+
*
|
|
11578
|
+
* ## Why a directory's key lives in its parent
|
|
11579
|
+
*
|
|
11580
|
+
* Only the root key is derived from the master seed. Every other directory has
|
|
11581
|
+
* a random key stored in its parent's entry, which chains capabilities
|
|
11582
|
+
* downward: handing someone `{id, key}` for `/apps/my-app` lets them walk that
|
|
11583
|
+
* subtree and nothing else, because no key can be derived upward. That is the
|
|
11584
|
+
* seam group-sharing hangs off later without re-encrypting the filesystem.
|
|
11585
|
+
*/
|
|
11586
|
+
/** Personal-space KV key holding a directory record. */
|
|
11587
|
+
const dirKey = (id) => `vfs/d/${id}`;
|
|
11588
|
+
/** Personal-space KV key holding one file's prior versions. */
|
|
11589
|
+
const historyKey = (id) => `vfs/h/${id}`;
|
|
11590
|
+
/** The root directory's id is fixed — it is the one node reached without a parent. */
|
|
11591
|
+
const ROOT_ID = "root";
|
|
11592
|
+
/** HKDF label for the seed-derived root key. Sits alongside `muhkoo-storage-v1`. */
|
|
11593
|
+
const VFS_ROOT_INFO = "muhkoo-vfs-v1";
|
|
11594
|
+
/** Raised when the master seed is not held, so the root key cannot be derived. */
|
|
11595
|
+
class VfsLockedError extends Error {
|
|
11596
|
+
constructor() {
|
|
11597
|
+
// Named rather than generic because the caller's remedy is specific and
|
|
11598
|
+
// not guessable from a failed decrypt: re-authenticate to recover the
|
|
11599
|
+
// seed. A page reload alone loses it — it is memory-only, never stored.
|
|
11600
|
+
super("VFS is locked: the master seed is not in memory. Sign in again to unlock.");
|
|
11601
|
+
this.name = "VfsLockedError";
|
|
11602
|
+
}
|
|
11603
|
+
}
|
|
11604
|
+
/** Raised for a path that does not exist. */
|
|
11605
|
+
class VfsNotFoundError extends Error {
|
|
11606
|
+
constructor(path) {
|
|
11607
|
+
super(`VFS: ${path} does not exist`);
|
|
11608
|
+
this.name = "VfsNotFoundError";
|
|
11609
|
+
}
|
|
11610
|
+
}
|
|
11611
|
+
/** Raised when an operation would clobber or misuse an existing entry. */
|
|
11612
|
+
class VfsConflictError extends Error {
|
|
11613
|
+
constructor(message) {
|
|
11614
|
+
super(`VFS: ${message}`);
|
|
11615
|
+
this.name = "VfsConflictError";
|
|
11616
|
+
}
|
|
11617
|
+
}
|
|
11618
|
+
|
|
11619
|
+
/**
|
|
11620
|
+
* Sealing for VFS metadata records.
|
|
11621
|
+
*
|
|
11622
|
+
* Every directory and history record is AES-256-GCM encrypted before it leaves
|
|
11623
|
+
* the process, so the server holds ciphertext and never learns a filename, a
|
|
11624
|
+
* directory shape, or a file size. The manifests inside carry their own chunk
|
|
11625
|
+
* keys, which is why the metadata must be sealed too: a readable directory
|
|
11626
|
+
* record would hand over the keys to its files.
|
|
11627
|
+
*/
|
|
11628
|
+
const AES_GCM_IV_BYTES = 12;
|
|
11629
|
+
const AES_KEY_BYTES = 32;
|
|
11630
|
+
function toBase64(bytes) {
|
|
11631
|
+
let s = "";
|
|
11632
|
+
for (const b of bytes)
|
|
11633
|
+
s += String.fromCharCode(b);
|
|
11634
|
+
return btoa(s);
|
|
11635
|
+
}
|
|
11636
|
+
function fromBase64(b64) {
|
|
11637
|
+
const s = atob(b64);
|
|
11638
|
+
const out = new Uint8Array(s.length);
|
|
11639
|
+
for (let i = 0; i < s.length; i++)
|
|
11640
|
+
out[i] = s.charCodeAt(i);
|
|
11641
|
+
return out;
|
|
11642
|
+
}
|
|
11643
|
+
/** A fresh random key for a newly created directory. */
|
|
11644
|
+
function newDirKey() {
|
|
11645
|
+
return randomBytes(AES_KEY_BYTES);
|
|
11646
|
+
}
|
|
11647
|
+
/**
|
|
11648
|
+
* A fresh random id.
|
|
11649
|
+
*
|
|
11650
|
+
* Ids are opaque and never derived from names or contents — a predictable id
|
|
11651
|
+
* would leak the directory structure through the KV key, which is the one part
|
|
11652
|
+
* of a record the server does see.
|
|
11653
|
+
*/
|
|
11654
|
+
function newId() {
|
|
11655
|
+
return toBase64(randomBytes(16)).replace(/[+/=]/g, "").slice(0, 22);
|
|
11656
|
+
}
|
|
11657
|
+
/** The root key, derived from the master seed. The only key not stored in a parent. */
|
|
11658
|
+
async function deriveRootKey(seed) {
|
|
11659
|
+
return deriveBitsHkdf(seed, VFS_ROOT_INFO, AES_KEY_BYTES);
|
|
11660
|
+
}
|
|
11661
|
+
async function seal(key, value) {
|
|
11662
|
+
const iv = randomBytes(AES_GCM_IV_BYTES);
|
|
11663
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
|
11664
|
+
const ct = await encryptAesGcm(key, iv, plaintext);
|
|
11665
|
+
return { v: 1, iv: toBase64(iv), ct: toBase64(ct) };
|
|
11666
|
+
}
|
|
11667
|
+
/**
|
|
11668
|
+
* Open a sealed record. Returns null for anything that is not a well-formed
|
|
11669
|
+
* envelope, so a missing or corrupt record reads as "absent" rather than
|
|
11670
|
+
* throwing — the caller can then rebuild it. A WRONG KEY still throws, because
|
|
11671
|
+
* silently treating an undecryptable directory as empty would present the user
|
|
11672
|
+
* with an empty filesystem and then let a write overwrite the real one.
|
|
11673
|
+
*/
|
|
11674
|
+
async function unseal(key, record) {
|
|
11675
|
+
if (!record || typeof record !== "object")
|
|
11676
|
+
return null;
|
|
11677
|
+
const r = record;
|
|
11678
|
+
if (typeof r.iv !== "string" || typeof r.ct !== "string")
|
|
11679
|
+
return null;
|
|
11680
|
+
const plaintext = await decryptAesGcm(key, fromBase64(r.iv), fromBase64(r.ct));
|
|
11681
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
11682
|
+
}
|
|
11683
|
+
|
|
11684
|
+
/**
|
|
11685
|
+
* Absolute POSIX paths for the VFS.
|
|
11686
|
+
*
|
|
11687
|
+
* A path is a lookup route, not a stored value: nothing in the metadata records
|
|
11688
|
+
* a full path, only a name inside its parent directory. That is what makes
|
|
11689
|
+
* renaming or moving a directory a single-record edit instead of a rewrite of
|
|
11690
|
+
* every descendant.
|
|
11691
|
+
*/
|
|
11692
|
+
/** Absolute, single leading slash, no `.` / `..`, no trailing slash. Root is `/`. */
|
|
11693
|
+
function normalizePath(input) {
|
|
11694
|
+
const out = [];
|
|
11695
|
+
for (const part of input.split("/")) {
|
|
11696
|
+
if (part === "" || part === ".")
|
|
11697
|
+
continue;
|
|
11698
|
+
if (part === "..")
|
|
11699
|
+
out.pop();
|
|
11700
|
+
else
|
|
11701
|
+
out.push(part);
|
|
11702
|
+
}
|
|
11703
|
+
return "/" + out.join("/");
|
|
11704
|
+
}
|
|
11705
|
+
/** Path split into its segments. `/` yields `[]`. */
|
|
11706
|
+
function segments(path) {
|
|
11707
|
+
return normalizePath(path).split("/").filter(Boolean);
|
|
11708
|
+
}
|
|
11709
|
+
/**
|
|
11710
|
+
* Resolve `path` against a working directory, POSIX-style.
|
|
11711
|
+
*
|
|
11712
|
+
* An absolute path ignores the cwd; a relative one hangs off it; `..` climbs
|
|
11713
|
+
* and stops at the root rather than escaping it. This is the single place
|
|
11714
|
+
* relativity is interpreted, so every VFS method behaves the same way — a
|
|
11715
|
+
* filesystem where `cat a.txt` and `list("a")` disagreed about what "here"
|
|
11716
|
+
* means would be worse than having no cwd at all.
|
|
11717
|
+
*/
|
|
11718
|
+
function resolveFrom(cwd, path) {
|
|
11719
|
+
if (path.startsWith("/"))
|
|
11720
|
+
return normalizePath(path);
|
|
11721
|
+
return normalizePath(`${cwd}/${path}`);
|
|
11722
|
+
}
|
|
11723
|
+
function dirname(path) {
|
|
11724
|
+
const p = normalizePath(path);
|
|
11725
|
+
const i = p.lastIndexOf("/");
|
|
11726
|
+
return i <= 0 ? "/" : p.slice(0, i);
|
|
11727
|
+
}
|
|
11728
|
+
function basename(path) {
|
|
11729
|
+
const p = normalizePath(path);
|
|
11730
|
+
return p.slice(p.lastIndexOf("/") + 1);
|
|
11731
|
+
}
|
|
11732
|
+
function join(...parts) {
|
|
11733
|
+
return normalizePath(parts.join("/"));
|
|
11734
|
+
}
|
|
11735
|
+
/**
|
|
11736
|
+
* Is `path` inside `dir` (at any depth)? A directory is never "under" itself,
|
|
11737
|
+
* which is what makes it usable as the guard against moving a directory into
|
|
11738
|
+
* its own subtree.
|
|
11739
|
+
*/
|
|
11740
|
+
function isUnder(path, dir) {
|
|
11741
|
+
const p = normalizePath(path);
|
|
11742
|
+
const d = normalizePath(dir);
|
|
11743
|
+
if (p === d)
|
|
11744
|
+
return false;
|
|
11745
|
+
return d === "/" ? true : p.startsWith(d + "/");
|
|
11746
|
+
}
|
|
11747
|
+
/**
|
|
11748
|
+
* Reject names that would make a path ambiguous or unrepresentable.
|
|
11749
|
+
*
|
|
11750
|
+
* `/` would forge a nesting level, and `.` / `..` would resolve to a different
|
|
11751
|
+
* directory on the next lookup — a file literally named `..` could never be
|
|
11752
|
+
* addressed again.
|
|
11753
|
+
*/
|
|
11754
|
+
function assertValidName(name) {
|
|
11755
|
+
if (!name)
|
|
11756
|
+
throw new Error("VFS: name cannot be empty");
|
|
11757
|
+
if (name === "." || name === "..")
|
|
11758
|
+
throw new Error(`VFS: "${name}" is not a usable name`);
|
|
11759
|
+
if (name.includes("/"))
|
|
11760
|
+
throw new Error(`VFS: name cannot contain "/" (got ${JSON.stringify(name)})`);
|
|
11761
|
+
}
|
|
11762
|
+
/**
|
|
11763
|
+
* MIME type for a filename.
|
|
11764
|
+
*
|
|
11765
|
+
* Stored with the content rather than guessed at read time, because whatever
|
|
11766
|
+
* eventually serves these bytes — a preview, a static host — sends this as the
|
|
11767
|
+
* `Content-Type`, and a browser will refuse to run a module or apply a
|
|
11768
|
+
* stylesheet labelled `application/octet-stream`.
|
|
11769
|
+
*/
|
|
11770
|
+
function contentTypeFor(name) {
|
|
11771
|
+
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
11772
|
+
switch (ext) {
|
|
11773
|
+
case "html":
|
|
11774
|
+
case "htm": return "text/html; charset=utf-8";
|
|
11775
|
+
case "css": return "text/css; charset=utf-8";
|
|
11776
|
+
case "js":
|
|
11777
|
+
case "mjs":
|
|
11778
|
+
case "jsx": return "text/javascript; charset=utf-8";
|
|
11779
|
+
case "ts":
|
|
11780
|
+
case "tsx": return "text/typescript; charset=utf-8";
|
|
11781
|
+
case "json": return "application/json; charset=utf-8";
|
|
11782
|
+
case "md": return "text/markdown; charset=utf-8";
|
|
11783
|
+
case "txt": return "text/plain; charset=utf-8";
|
|
11784
|
+
case "svg": return "image/svg+xml";
|
|
11785
|
+
case "png": return "image/png";
|
|
11786
|
+
case "jpg":
|
|
11787
|
+
case "jpeg": return "image/jpeg";
|
|
11788
|
+
case "gif": return "image/gif";
|
|
11789
|
+
case "webp": return "image/webp";
|
|
11790
|
+
case "avif": return "image/avif";
|
|
11791
|
+
case "ico": return "image/x-icon";
|
|
11792
|
+
case "woff2": return "font/woff2";
|
|
11793
|
+
case "woff": return "font/woff";
|
|
11794
|
+
case "wasm": return "application/wasm";
|
|
11795
|
+
case "map": return "application/json";
|
|
11796
|
+
default: return "application/octet-stream";
|
|
11797
|
+
}
|
|
11798
|
+
}
|
|
11799
|
+
|
|
11800
|
+
/**
|
|
11801
|
+
* Glob matching for VFS paths.
|
|
11802
|
+
*
|
|
11803
|
+
* A deliberately small dialect — `*`, `**`, `?`, and `{a,b}` — because these
|
|
11804
|
+
* cover what a project actually asks for (`**\/*.tsx`, `src/*.css`) and every
|
|
11805
|
+
* addition beyond them is a new way for a pattern to mean something the caller
|
|
11806
|
+
* did not expect.
|
|
11807
|
+
*/
|
|
11808
|
+
/** `*` stops at a separator; `**` crosses them. That distinction is the whole point. */
|
|
11809
|
+
function globToRegExp(pattern) {
|
|
11810
|
+
let out = "";
|
|
11811
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
11812
|
+
const c = pattern[i];
|
|
11813
|
+
if (c === "*") {
|
|
11814
|
+
if (pattern[i + 1] === "*") {
|
|
11815
|
+
// `**/` should also match zero directories, so `/**/x` finds `/x`.
|
|
11816
|
+
if (pattern[i + 2] === "/") {
|
|
11817
|
+
out += "(?:.*/)?";
|
|
11818
|
+
i += 2;
|
|
11819
|
+
}
|
|
11820
|
+
else {
|
|
11821
|
+
out += ".*";
|
|
11822
|
+
i += 1;
|
|
11823
|
+
}
|
|
11824
|
+
}
|
|
11825
|
+
else {
|
|
11826
|
+
out += "[^/]*";
|
|
11827
|
+
}
|
|
11828
|
+
}
|
|
11829
|
+
else if (c === "?") {
|
|
11830
|
+
out += "[^/]";
|
|
11831
|
+
}
|
|
11832
|
+
else if (c === "{") {
|
|
11833
|
+
const close = pattern.indexOf("}", i);
|
|
11834
|
+
if (close === -1) {
|
|
11835
|
+
out += "\\{";
|
|
11836
|
+
}
|
|
11837
|
+
else {
|
|
11838
|
+
const alts = pattern.slice(i + 1, close).split(",");
|
|
11839
|
+
out += `(?:${alts.map(escapeLiteral).join("|")})`;
|
|
11840
|
+
i = close;
|
|
11841
|
+
}
|
|
11842
|
+
}
|
|
11843
|
+
else {
|
|
11844
|
+
out += escapeLiteral(c);
|
|
11845
|
+
}
|
|
11846
|
+
}
|
|
11847
|
+
return new RegExp(`^${out}$`);
|
|
11848
|
+
}
|
|
11849
|
+
function escapeLiteral(s) {
|
|
11850
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11851
|
+
}
|
|
11852
|
+
|
|
11853
|
+
/**
|
|
11854
|
+
* `client.vfs` — a real filesystem over Muhkoo primitives.
|
|
11855
|
+
*
|
|
11856
|
+
* Metadata is a graph of encrypted directory records in the user's personal
|
|
11857
|
+
* space; file content is chunked, encrypted and stored in the global
|
|
11858
|
+
* content-addressed shard store, referenced by manifest. See `types.ts` for
|
|
11859
|
+
* why directories carry stable random ids and why each key lives in its parent.
|
|
11860
|
+
*
|
|
11861
|
+
* ## Consistency
|
|
11862
|
+
*
|
|
11863
|
+
* Every mutation is a read-modify-write of exactly ONE directory record, so a
|
|
11864
|
+
* write cannot leave the tree half-updated: either the parent names the new
|
|
11865
|
+
* child or it does not. Two tabs writing different directories never conflict;
|
|
11866
|
+
* two tabs writing the SAME directory resolve last-writer-wins at the personal
|
|
11867
|
+
* space, which carries an HLC per key. Orphaned records (a directory whose
|
|
11868
|
+
* parent entry lost the race) are unreachable rather than corrupting — they
|
|
11869
|
+
* cost storage until a future sweep, which is the trade this shape makes in
|
|
11870
|
+
* exchange for single-record writes.
|
|
11871
|
+
*/
|
|
11872
|
+
const DEFAULT_HISTORY_LIMIT = 20;
|
|
11873
|
+
class VfsNamespace {
|
|
11874
|
+
deps;
|
|
11875
|
+
rootKey = null;
|
|
11876
|
+
/**
|
|
11877
|
+
* Decrypted directory records, by id.
|
|
11878
|
+
*
|
|
11879
|
+
* The file tree re-lists constantly while typing, and every miss is a
|
|
11880
|
+
* network round trip plus a decrypt. Invalidated precisely on write rather
|
|
11881
|
+
* than by TTL — a stale directory listing shows files that are not there.
|
|
11882
|
+
*/
|
|
11883
|
+
cache = new Map();
|
|
11884
|
+
historyLimit;
|
|
11885
|
+
constructor(deps) {
|
|
11886
|
+
this.deps = deps;
|
|
11887
|
+
this.historyLimit = deps.historyLimit ?? DEFAULT_HISTORY_LIMIT;
|
|
11888
|
+
}
|
|
11889
|
+
/**
|
|
11890
|
+
* The working directory relative paths resolve against.
|
|
11891
|
+
*
|
|
11892
|
+
* Kept here rather than left to each caller so that a CLI, a script and the
|
|
11893
|
+
* SDK all agree on what "here" means — the alternative is every consumer
|
|
11894
|
+
* re-implementing `cd`, and disagreeing about `..`.
|
|
11895
|
+
*/
|
|
11896
|
+
_cwd = "/";
|
|
11897
|
+
/** The current working directory. Always absolute. */
|
|
11898
|
+
get cwd() {
|
|
11899
|
+
return this._cwd;
|
|
11900
|
+
}
|
|
11901
|
+
/**
|
|
11902
|
+
* Change the working directory.
|
|
11903
|
+
*
|
|
11904
|
+
* Verifies the target is a real directory, so a typo fails here — where the
|
|
11905
|
+
* message can name the path — rather than turning every later relative path
|
|
11906
|
+
* into a confusing "does not exist".
|
|
11907
|
+
*/
|
|
11908
|
+
async cd(path) {
|
|
11909
|
+
const target = this.resolve(path);
|
|
11910
|
+
if (target !== "/") {
|
|
11911
|
+
const found = await this.find(target);
|
|
11912
|
+
if (!found)
|
|
11913
|
+
throw new VfsNotFoundError(target);
|
|
11914
|
+
if (found.entry.kind !== "dir")
|
|
11915
|
+
throw new VfsConflictError(`${target} is not a directory`);
|
|
11916
|
+
}
|
|
11917
|
+
this._cwd = target;
|
|
11918
|
+
return target;
|
|
11919
|
+
}
|
|
11920
|
+
/** Resolve a path against the working directory. Absolute paths pass through. */
|
|
11921
|
+
resolve(path) {
|
|
11922
|
+
return resolveFrom(this._cwd, path);
|
|
11923
|
+
}
|
|
11924
|
+
/** True when the master seed is held, so the root key can be derived. */
|
|
11925
|
+
get unlocked() {
|
|
11926
|
+
return this.deps.seed() !== null;
|
|
11927
|
+
}
|
|
11928
|
+
/**
|
|
11929
|
+
* Drop every cached record.
|
|
11930
|
+
*
|
|
11931
|
+
* Call after signing in as someone else — the cache is keyed by directory
|
|
11932
|
+
* id with no notion of whose filesystem it came from, and serving one
|
|
11933
|
+
* identity's directories to another would be the worst possible bug here.
|
|
11934
|
+
*/
|
|
11935
|
+
clearCache() {
|
|
11936
|
+
this.cache.clear();
|
|
11937
|
+
this.rootKey = null;
|
|
11938
|
+
this._cwd = "/"; // a different identity's tree has different directories
|
|
11939
|
+
}
|
|
11940
|
+
/**
|
|
11941
|
+
* Call `handler` when this filesystem changes somewhere else — another tab,
|
|
11942
|
+
* another machine, the CLI.
|
|
11943
|
+
*
|
|
11944
|
+
* Deliberately a NOTIFICATION, not a diff: the frame names a record id, and
|
|
11945
|
+
* a record id is not a path (paths are resolved by walking, and the walk is
|
|
11946
|
+
* what the change may have invalidated). So the cache is dropped and the
|
|
11947
|
+
* caller re-reads whatever it is actually showing. Trying to translate a
|
|
11948
|
+
* record id back into a path would mean maintaining a reverse index that
|
|
11949
|
+
* could itself go stale.
|
|
11950
|
+
*
|
|
11951
|
+
* Returns an unsubscribe function. A runtime with no socket (a CLI) gets a
|
|
11952
|
+
* no-op rather than an error — watching is an optimisation, never required
|
|
11953
|
+
* for correctness.
|
|
11954
|
+
*/
|
|
11955
|
+
watch(handler) {
|
|
11956
|
+
if (!this.deps.subscribe)
|
|
11957
|
+
return () => { };
|
|
11958
|
+
return this.deps.subscribe((raw) => {
|
|
11959
|
+
let frame;
|
|
11960
|
+
try {
|
|
11961
|
+
frame = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
11962
|
+
}
|
|
11963
|
+
catch {
|
|
11964
|
+
return;
|
|
11965
|
+
}
|
|
11966
|
+
if (frame?._t !== "change" || typeof frame.key !== "string")
|
|
11967
|
+
return;
|
|
11968
|
+
if (!frame.key.startsWith("vfs/"))
|
|
11969
|
+
return; // someone else's data on the shared feed
|
|
11970
|
+
// Our own writes echo back. Dropping the cache is cheap and keeping a
|
|
11971
|
+
// stale entry is not, so this does not try to filter them out.
|
|
11972
|
+
this.cache.clear();
|
|
11973
|
+
handler();
|
|
11974
|
+
});
|
|
11975
|
+
}
|
|
11976
|
+
// ---- reads --------------------------------------------------------------
|
|
11977
|
+
async exists(path) {
|
|
11978
|
+
return (await this.find(path)) !== null;
|
|
11979
|
+
}
|
|
11980
|
+
async stat(path) {
|
|
11981
|
+
const found = await this.find(path);
|
|
11982
|
+
if (!found)
|
|
11983
|
+
throw new VfsNotFoundError(this.resolve(path));
|
|
11984
|
+
return toStat(this.resolve(path), found.name, found.entry);
|
|
11985
|
+
}
|
|
11986
|
+
/** Immediate children of a directory. One record fetch. */
|
|
11987
|
+
async list(path = ".") {
|
|
11988
|
+
const dir = await this.openDir(path);
|
|
11989
|
+
const base = this.resolve(path);
|
|
11990
|
+
return Object.entries(dir.node.entries)
|
|
11991
|
+
.map(([name, entry]) => toStat(join(base, name), name, entry))
|
|
11992
|
+
.sort(byDirsThenName);
|
|
11993
|
+
}
|
|
11994
|
+
/**
|
|
11995
|
+
* Every file path under a prefix, depth-first.
|
|
11996
|
+
*
|
|
11997
|
+
* Deliberately separate from `list`: this walks the whole subtree, which is
|
|
11998
|
+
* one fetch per directory. Fine for a project, wrong as a default.
|
|
11999
|
+
*/
|
|
12000
|
+
async walk(path = ".") {
|
|
12001
|
+
const out = [];
|
|
12002
|
+
const visit = async (dirPath) => {
|
|
12003
|
+
for (const entry of await this.list(dirPath)) {
|
|
12004
|
+
if (entry.kind === "dir")
|
|
12005
|
+
await visit(entry.path);
|
|
12006
|
+
else
|
|
12007
|
+
out.push(entry.path);
|
|
12008
|
+
}
|
|
12009
|
+
};
|
|
12010
|
+
await visit(this.resolve(path));
|
|
12011
|
+
return out;
|
|
12012
|
+
}
|
|
12013
|
+
async readFile(path) {
|
|
12014
|
+
const entry = await this.expectFile(path);
|
|
12015
|
+
return this.deps.content.read(entry.manifest);
|
|
12016
|
+
}
|
|
12017
|
+
async readText(path) {
|
|
12018
|
+
return new TextDecoder().decode(await this.readFile(path));
|
|
12019
|
+
}
|
|
12020
|
+
// ---- writes -------------------------------------------------------------
|
|
12021
|
+
/**
|
|
12022
|
+
* Create a directory. `recursive` creates missing parents and tolerates an
|
|
12023
|
+
* existing target, matching `mkdir -p`.
|
|
12024
|
+
*/
|
|
12025
|
+
async mkdir(path, opts = {}) {
|
|
12026
|
+
const target = this.resolve(path);
|
|
12027
|
+
if (target === "/")
|
|
12028
|
+
return;
|
|
12029
|
+
const parts = segments(target);
|
|
12030
|
+
let current = "/";
|
|
12031
|
+
for (let i = 0; i < parts.length; i++) {
|
|
12032
|
+
const name = parts[i];
|
|
12033
|
+
const isLast = i === parts.length - 1;
|
|
12034
|
+
const next = join(current, name);
|
|
12035
|
+
const existing = (await this.openDir(current)).node.entries[name];
|
|
12036
|
+
if (existing) {
|
|
12037
|
+
if (existing.kind === "file")
|
|
12038
|
+
throw new VfsConflictError(`${next} is a file`);
|
|
12039
|
+
if (isLast && !opts.recursive)
|
|
12040
|
+
throw new VfsConflictError(`${next} already exists`);
|
|
12041
|
+
}
|
|
12042
|
+
else {
|
|
12043
|
+
if (!isLast && !opts.recursive)
|
|
12044
|
+
throw new VfsNotFoundError(current);
|
|
12045
|
+
await this.createDir(current, name);
|
|
12046
|
+
}
|
|
12047
|
+
current = next;
|
|
12048
|
+
}
|
|
12049
|
+
}
|
|
12050
|
+
/**
|
|
12051
|
+
* Write a file, creating parent directories as needed.
|
|
12052
|
+
*
|
|
12053
|
+
* The previous version is pushed onto the file's history record before the
|
|
12054
|
+
* directory entry is replaced, so an interrupted write can lose the new
|
|
12055
|
+
* version but never the old one.
|
|
12056
|
+
*/
|
|
12057
|
+
async writeFile(path, data, opts = {}) {
|
|
12058
|
+
const target = this.resolve(path);
|
|
12059
|
+
const name = basename(target);
|
|
12060
|
+
assertValidName(name);
|
|
12061
|
+
const parentPath = dirname(target);
|
|
12062
|
+
await this.mkdir(parentPath, { recursive: true });
|
|
12063
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
12064
|
+
const { manifest, size } = await this.deps.content.write(bytes, {
|
|
12065
|
+
name,
|
|
12066
|
+
type: opts.type ?? contentTypeFor(name),
|
|
12067
|
+
});
|
|
12068
|
+
const parent = await this.openDir(parentPath);
|
|
12069
|
+
const prior = parent.node.entries[name];
|
|
12070
|
+
if (prior?.kind === "dir")
|
|
12071
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
12072
|
+
const id = prior?.id ?? newId();
|
|
12073
|
+
let versions = prior?.versions ?? 0;
|
|
12074
|
+
if (prior)
|
|
12075
|
+
versions = await this.pushHistory(parent, id, prior);
|
|
12076
|
+
const entry = { kind: "file", id, manifest, size, mtime: Date.now(), versions };
|
|
12077
|
+
await this.mutate(parent, (node) => {
|
|
12078
|
+
node.entries[name] = entry;
|
|
12079
|
+
});
|
|
12080
|
+
return toStat(target, name, entry);
|
|
12081
|
+
}
|
|
12082
|
+
/** Delete a file, or a directory when `recursive`. */
|
|
12083
|
+
async delete(path, opts = {}) {
|
|
12084
|
+
const target = this.resolve(path);
|
|
12085
|
+
if (target === "/")
|
|
12086
|
+
throw new VfsConflictError("the root directory cannot be deleted");
|
|
12087
|
+
const name = basename(target);
|
|
12088
|
+
const parent = await this.openDir(dirname(target));
|
|
12089
|
+
const entry = parent.node.entries[name];
|
|
12090
|
+
if (!entry)
|
|
12091
|
+
throw new VfsNotFoundError(target);
|
|
12092
|
+
if (entry.kind === "dir") {
|
|
12093
|
+
const child = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12094
|
+
const empty = Object.keys(child.node.entries).length === 0;
|
|
12095
|
+
if (!empty && !opts.recursive)
|
|
12096
|
+
throw new VfsConflictError(`${target} is not empty`);
|
|
12097
|
+
await this.purge(child);
|
|
12098
|
+
}
|
|
12099
|
+
else {
|
|
12100
|
+
await this.releaseFile(parent, entry);
|
|
12101
|
+
}
|
|
12102
|
+
await this.mutate(parent, (node) => {
|
|
12103
|
+
delete node.entries[name];
|
|
12104
|
+
});
|
|
12105
|
+
}
|
|
12106
|
+
/**
|
|
12107
|
+
* Move or rename. Works across directories, which is the same operation:
|
|
12108
|
+
* detach the entry from one parent and attach it to another.
|
|
12109
|
+
*
|
|
12110
|
+
* A directory move is O(1) regardless of how much is inside it — the
|
|
12111
|
+
* subtree records are not touched at all, because nothing in them records
|
|
12112
|
+
* its own path.
|
|
12113
|
+
*/
|
|
12114
|
+
async rename(from, to) {
|
|
12115
|
+
const src = this.resolve(from);
|
|
12116
|
+
const dst = this.resolve(to);
|
|
12117
|
+
if (src === dst)
|
|
12118
|
+
return;
|
|
12119
|
+
if (src === "/")
|
|
12120
|
+
throw new VfsConflictError("the root directory cannot be moved");
|
|
12121
|
+
if (isUnder(dst, src))
|
|
12122
|
+
throw new VfsConflictError(`cannot move ${src} into itself`);
|
|
12123
|
+
const dstName = basename(dst);
|
|
12124
|
+
assertValidName(dstName);
|
|
12125
|
+
const srcParent = await this.openDir(dirname(src));
|
|
12126
|
+
const entry = srcParent.node.entries[basename(src)];
|
|
12127
|
+
if (!entry)
|
|
12128
|
+
throw new VfsNotFoundError(src);
|
|
12129
|
+
await this.mkdir(dirname(dst), { recursive: true });
|
|
12130
|
+
const dstParent = await this.openDir(dirname(dst));
|
|
12131
|
+
if (dstParent.node.entries[dstName])
|
|
12132
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
12133
|
+
// Attach before detaching. If the second write fails the entry is
|
|
12134
|
+
// reachable from both parents — visible and fixable. The other order
|
|
12135
|
+
// would lose the subtree entirely.
|
|
12136
|
+
const moved = { ...entry, mtime: Date.now() };
|
|
12137
|
+
await this.mutate(dstParent, (node) => {
|
|
12138
|
+
node.entries[dstName] = moved;
|
|
12139
|
+
});
|
|
12140
|
+
// Re-open: same-parent moves mutated the record we are about to edit.
|
|
12141
|
+
const detachFrom = await this.openDir(dirname(src));
|
|
12142
|
+
await this.mutate(detachFrom, (node) => {
|
|
12143
|
+
delete node.entries[basename(src)];
|
|
12144
|
+
});
|
|
12145
|
+
}
|
|
12146
|
+
/**
|
|
12147
|
+
* Copy a file or a directory tree.
|
|
12148
|
+
*
|
|
12149
|
+
* Copying a FILE moves no bytes: content is immutable and content-addressed,
|
|
12150
|
+
* so the new entry simply points at the same manifest. A 200 MB asset
|
|
12151
|
+
* duplicates in one small metadata write, and the shard store already holds
|
|
12152
|
+
* exactly one copy of it.
|
|
12153
|
+
*
|
|
12154
|
+
* The copy gets a NEW file id, so it does not inherit or share history —
|
|
12155
|
+
* two paths sharing an id would mean saving one silently pushed a version
|
|
12156
|
+
* onto the other.
|
|
12157
|
+
*/
|
|
12158
|
+
async copy(from, to) {
|
|
12159
|
+
const src = this.resolve(from);
|
|
12160
|
+
const dst = this.resolve(to);
|
|
12161
|
+
if (src === "/")
|
|
12162
|
+
throw new VfsConflictError("the root directory cannot be copied");
|
|
12163
|
+
if (src === dst)
|
|
12164
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
12165
|
+
if (isUnder(dst, src))
|
|
12166
|
+
throw new VfsConflictError(`cannot copy ${src} into itself`);
|
|
12167
|
+
const found = await this.find(src);
|
|
12168
|
+
if (!found)
|
|
12169
|
+
throw new VfsNotFoundError(src);
|
|
12170
|
+
const dstName = basename(dst);
|
|
12171
|
+
assertValidName(dstName);
|
|
12172
|
+
await this.mkdir(dirname(dst), { recursive: true });
|
|
12173
|
+
const dstParent = await this.openDir(dirname(dst));
|
|
12174
|
+
if (dstParent.node.entries[dstName])
|
|
12175
|
+
throw new VfsConflictError(`${dst} already exists`);
|
|
12176
|
+
const copied = await this.cloneEntry(found.entry);
|
|
12177
|
+
await this.mutate(dstParent, (node) => {
|
|
12178
|
+
node.entries[dstName] = copied;
|
|
12179
|
+
});
|
|
12180
|
+
return toStat(dst, dstName, copied);
|
|
12181
|
+
}
|
|
12182
|
+
/**
|
|
12183
|
+
* Paths matching a glob, relative to the whole filesystem.
|
|
12184
|
+
*
|
|
12185
|
+
* Matching happens on the client over a walk of the tree, so the cost is one
|
|
12186
|
+
* record fetch per directory — cheap for a project, and worth knowing before
|
|
12187
|
+
* pointing it at the root of a large filesystem. Use `walk` when the prefix
|
|
12188
|
+
* already narrows it.
|
|
12189
|
+
*/
|
|
12190
|
+
async glob(pattern, opts = {}) {
|
|
12191
|
+
const re = globToRegExp(this.resolve(pattern));
|
|
12192
|
+
return (await this.walk(opts.from ?? ".")).filter((path) => re.test(path));
|
|
12193
|
+
}
|
|
12194
|
+
/**
|
|
12195
|
+
* Delete metadata records no longer reachable from the root.
|
|
12196
|
+
*
|
|
12197
|
+
* These accumulate because every write touches exactly one record: if two
|
|
12198
|
+
* tabs edit the same directory, last-writer-wins at the personal space can
|
|
12199
|
+
* leave a child whose parent entry lost the race. Unreachable is harmless —
|
|
12200
|
+
* it is invisible and undecryptable-by-nobody — but it costs storage, so
|
|
12201
|
+
* this reclaims it.
|
|
12202
|
+
*
|
|
12203
|
+
* Deliberately explicit, never automatic: a sweep racing another tab's
|
|
12204
|
+
* in-flight write could delete a record that is about to be referenced.
|
|
12205
|
+
* Run it when the filesystem is idle.
|
|
12206
|
+
*/
|
|
12207
|
+
async sweep(opts = {}) {
|
|
12208
|
+
const root = await this.root();
|
|
12209
|
+
const reachable = new Set();
|
|
12210
|
+
const visit = async (dir) => {
|
|
12211
|
+
reachable.add(dirKey(dir.id));
|
|
12212
|
+
for (const entry of Object.values(dir.node.entries)) {
|
|
12213
|
+
if (entry.kind === "dir") {
|
|
12214
|
+
await visit(await this.openDirById(entry.id, fromBase64(entry.key)));
|
|
12215
|
+
}
|
|
12216
|
+
else {
|
|
12217
|
+
reachable.add(historyKey(entry.id));
|
|
12218
|
+
}
|
|
12219
|
+
}
|
|
12220
|
+
};
|
|
12221
|
+
await visit(root);
|
|
12222
|
+
// Only ever consider OUR records: the personal space is shared with
|
|
12223
|
+
// chat keys, space keys and the legacy file mirror, and a sweep that
|
|
12224
|
+
// reached beyond its own namespace would be a data-loss bug.
|
|
12225
|
+
const mine = (await this.deps.store.list()).filter((k) => /^vfs\/[dh]\//.test(k));
|
|
12226
|
+
const orphans = mine.filter((k) => !reachable.has(k));
|
|
12227
|
+
// A root that read as EMPTY while orphans exist is the shape of a failed
|
|
12228
|
+
// load, not of a fresh filesystem — deleting here would wipe a tree we
|
|
12229
|
+
// merely failed to reach. Refuse unless the caller insists.
|
|
12230
|
+
const rootEmpty = Object.keys(root.node.entries).length === 0;
|
|
12231
|
+
if (rootEmpty && orphans.length && !opts.force) {
|
|
12232
|
+
throw new VfsConflictError(`refusing to sweep ${orphans.length} record(s) with an empty root — ` +
|
|
12233
|
+
"this looks like a failed load rather than an empty filesystem; pass { force: true } if it really is empty");
|
|
12234
|
+
}
|
|
12235
|
+
for (const key of orphans)
|
|
12236
|
+
await this.deps.store.delete(key).catch(() => { });
|
|
12237
|
+
return { removed: orphans };
|
|
12238
|
+
}
|
|
12239
|
+
// ---- history ------------------------------------------------------------
|
|
12240
|
+
/** Prior versions of a file, newest first. One record fetch. */
|
|
12241
|
+
async history(path) {
|
|
12242
|
+
const entry = await this.expectFile(path);
|
|
12243
|
+
const record = await this.readHistory(await this.openDir(dirname(path)), entry.id);
|
|
12244
|
+
return record.versions.map(({ size, mtime }) => ({ size, mtime }));
|
|
12245
|
+
}
|
|
12246
|
+
/**
|
|
12247
|
+
* Restore a prior version, where 0 is the most recent one.
|
|
12248
|
+
*
|
|
12249
|
+
* The current version is pushed onto history first, so restoring is itself
|
|
12250
|
+
* undoable rather than a one-way door.
|
|
12251
|
+
*/
|
|
12252
|
+
async restore(path, index = 0) {
|
|
12253
|
+
const target = this.resolve(path);
|
|
12254
|
+
const name = basename(target);
|
|
12255
|
+
const parent = await this.openDir(dirname(target));
|
|
12256
|
+
const entry = parent.node.entries[name];
|
|
12257
|
+
if (!entry || entry.kind !== "file")
|
|
12258
|
+
throw new VfsNotFoundError(target);
|
|
12259
|
+
const record = await this.readHistory(parent, entry.id);
|
|
12260
|
+
const version = record.versions[index];
|
|
12261
|
+
if (!version)
|
|
12262
|
+
throw new VfsNotFoundError(`${target} version ${index}`);
|
|
12263
|
+
const versions = await this.pushHistory(parent, entry.id, entry);
|
|
12264
|
+
const restored = {
|
|
12265
|
+
kind: "file",
|
|
12266
|
+
id: entry.id,
|
|
12267
|
+
manifest: version.manifest,
|
|
12268
|
+
size: version.size,
|
|
12269
|
+
mtime: Date.now(),
|
|
12270
|
+
versions,
|
|
12271
|
+
};
|
|
12272
|
+
await this.mutate(parent, (node) => {
|
|
12273
|
+
node.entries[name] = restored;
|
|
12274
|
+
});
|
|
12275
|
+
return toStat(target, name, restored);
|
|
12276
|
+
}
|
|
12277
|
+
// ---- internals ----------------------------------------------------------
|
|
12278
|
+
async expectFile(path) {
|
|
12279
|
+
const found = await this.find(path);
|
|
12280
|
+
if (!found)
|
|
12281
|
+
throw new VfsNotFoundError(this.resolve(path));
|
|
12282
|
+
if (found.entry.kind !== "file")
|
|
12283
|
+
throw new VfsConflictError(`${this.resolve(path)} is a directory`);
|
|
12284
|
+
return found.entry;
|
|
12285
|
+
}
|
|
12286
|
+
/** Resolve a path to its entry, or null. Root has no entry of its own. */
|
|
12287
|
+
async find(path) {
|
|
12288
|
+
const target = this.resolve(path);
|
|
12289
|
+
if (target === "/")
|
|
12290
|
+
return null;
|
|
12291
|
+
const parent = await this.openDirOrNull(dirname(target));
|
|
12292
|
+
if (!parent)
|
|
12293
|
+
return null;
|
|
12294
|
+
const name = basename(target);
|
|
12295
|
+
const entry = parent.node.entries[name];
|
|
12296
|
+
return entry ? { name, entry } : null;
|
|
12297
|
+
}
|
|
12298
|
+
async root() {
|
|
12299
|
+
const seed = this.deps.seed();
|
|
12300
|
+
if (!seed)
|
|
12301
|
+
throw new VfsLockedError();
|
|
12302
|
+
if (!this.rootKey)
|
|
12303
|
+
this.rootKey = await deriveRootKey(seed);
|
|
12304
|
+
return this.openDirById(ROOT_ID, this.rootKey);
|
|
12305
|
+
}
|
|
12306
|
+
/** Open a directory by path, creating nothing. Throws if it is missing. */
|
|
12307
|
+
async openDir(path) {
|
|
12308
|
+
const dir = await this.openDirOrNull(path);
|
|
12309
|
+
if (!dir)
|
|
12310
|
+
throw new VfsNotFoundError(this.resolve(path));
|
|
12311
|
+
return dir;
|
|
12312
|
+
}
|
|
12313
|
+
async openDirOrNull(path) {
|
|
12314
|
+
let dir = await this.root();
|
|
12315
|
+
for (const name of segments(this.resolve(path))) {
|
|
12316
|
+
const entry = dir.node.entries[name];
|
|
12317
|
+
if (!entry || entry.kind !== "dir")
|
|
12318
|
+
return null;
|
|
12319
|
+
dir = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12320
|
+
}
|
|
12321
|
+
return dir;
|
|
12322
|
+
}
|
|
12323
|
+
async openDirById(id, key) {
|
|
12324
|
+
const hit = this.cache.get(id);
|
|
12325
|
+
if (hit)
|
|
12326
|
+
return hit;
|
|
12327
|
+
const raw = await this.deps.store.get(dirKey(id));
|
|
12328
|
+
// An absent record is an EMPTY directory, not an error: the root exists
|
|
12329
|
+
// before anything has been written to it, and a directory is created by
|
|
12330
|
+
// its parent naming it rather than by its own record appearing.
|
|
12331
|
+
const node = (await unseal(key, raw)) ?? { v: 1, entries: {}, mtime: Date.now() };
|
|
12332
|
+
const open = { id, key, node };
|
|
12333
|
+
this.cache.set(id, open);
|
|
12334
|
+
return open;
|
|
12335
|
+
}
|
|
12336
|
+
/** Apply an edit to a directory record and persist it. */
|
|
12337
|
+
async mutate(dir, edit) {
|
|
12338
|
+
edit(dir.node);
|
|
12339
|
+
dir.node.mtime = Date.now();
|
|
12340
|
+
await this.deps.store.put(dirKey(dir.id), await seal(dir.key, dir.node));
|
|
12341
|
+
this.cache.set(dir.id, dir);
|
|
12342
|
+
}
|
|
12343
|
+
async createDir(parentPath, name) {
|
|
12344
|
+
assertValidName(name);
|
|
12345
|
+
const parent = await this.openDir(parentPath);
|
|
12346
|
+
const entry = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
12347
|
+
await this.mutate(parent, (node) => {
|
|
12348
|
+
node.entries[name] = entry;
|
|
12349
|
+
});
|
|
12350
|
+
}
|
|
12351
|
+
/** Recursively remove a subtree's records. Best-effort: unreachable ≠ fatal. */
|
|
12352
|
+
async purge(dir) {
|
|
12353
|
+
for (const entry of Object.values(dir.node.entries)) {
|
|
12354
|
+
if (entry.kind === "dir") {
|
|
12355
|
+
await this.purge(await this.openDirById(entry.id, fromBase64(entry.key)));
|
|
12356
|
+
}
|
|
12357
|
+
else {
|
|
12358
|
+
await this.releaseFile(dir, entry);
|
|
12359
|
+
}
|
|
12360
|
+
}
|
|
12361
|
+
this.cache.delete(dir.id);
|
|
12362
|
+
await this.deps.store.delete(dirKey(dir.id)).catch(() => { });
|
|
12363
|
+
}
|
|
12364
|
+
/**
|
|
12365
|
+
* Deep-copy an entry. Files reuse their manifest (no bytes move); each
|
|
12366
|
+
* directory gets a fresh id and key, so the copy is independent of the
|
|
12367
|
+
* original — writing into one must never appear in the other.
|
|
12368
|
+
*/
|
|
12369
|
+
async cloneEntry(entry) {
|
|
12370
|
+
if (entry.kind === "file") {
|
|
12371
|
+
// Same bytes, second owner: take a reference so deleting either one
|
|
12372
|
+
// leaves the other readable.
|
|
12373
|
+
await this.deps.content.retain(entry.manifest);
|
|
12374
|
+
return { kind: "file", id: newId(), manifest: entry.manifest, size: entry.size, mtime: Date.now(), versions: 0 };
|
|
12375
|
+
}
|
|
12376
|
+
const source = await this.openDirById(entry.id, fromBase64(entry.key));
|
|
12377
|
+
const clone = { kind: "dir", id: newId(), key: toBase64(newDirKey()), mtime: Date.now() };
|
|
12378
|
+
const node = { v: 1, entries: {}, mtime: Date.now() };
|
|
12379
|
+
for (const [name, child] of Object.entries(source.node.entries)) {
|
|
12380
|
+
node.entries[name] = await this.cloneEntry(child);
|
|
12381
|
+
}
|
|
12382
|
+
const open = { id: clone.id, key: fromBase64(clone.key), node };
|
|
12383
|
+
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
12384
|
+
this.cache.set(open.id, open);
|
|
12385
|
+
return clone;
|
|
12386
|
+
}
|
|
12387
|
+
/**
|
|
12388
|
+
* Let go of a file's bytes: the current manifest and every retained
|
|
12389
|
+
* version, then the history record itself.
|
|
12390
|
+
*/
|
|
12391
|
+
async releaseFile(parent, entry) {
|
|
12392
|
+
const record = await this.readHistory(parent, entry.id).catch(() => ({ v: 1, versions: [] }));
|
|
12393
|
+
const manifests = [entry.manifest, ...record.versions.map((v) => v.manifest)];
|
|
12394
|
+
for (const manifest of manifests)
|
|
12395
|
+
await this.deps.content.release(manifest).catch(() => { });
|
|
12396
|
+
await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
|
|
12397
|
+
}
|
|
12398
|
+
async readHistory(parent, fileId) {
|
|
12399
|
+
const raw = await this.deps.store.get(historyKey(fileId));
|
|
12400
|
+
return (await unseal(parent.key, raw)) ?? { v: 1, versions: [] };
|
|
12401
|
+
}
|
|
12402
|
+
/** Push `entry` onto its history record. Returns the new version count. */
|
|
12403
|
+
async pushHistory(parent, fileId, entry) {
|
|
12404
|
+
const record = await this.readHistory(parent, fileId);
|
|
12405
|
+
record.versions.unshift({ manifest: entry.manifest, size: entry.size, mtime: entry.mtime });
|
|
12406
|
+
// Bounded, because this is a single stored value. Unchanged chunks are
|
|
12407
|
+
// deduped by the content-addressed shard store, so the cost of keeping
|
|
12408
|
+
// versions is this record's own growth — which is what the cap bounds.
|
|
12409
|
+
const evicted = record.versions.slice(this.historyLimit);
|
|
12410
|
+
record.versions = record.versions.slice(0, this.historyLimit);
|
|
12411
|
+
// A version that falls off the end is unreachable — release its shards
|
|
12412
|
+
// or the cap bounds the record's size while the storage grows forever.
|
|
12413
|
+
for (const v of evicted)
|
|
12414
|
+
await this.deps.content.release(v.manifest).catch(() => { });
|
|
12415
|
+
await this.deps.store.put(historyKey(fileId), await seal(parent.key, record));
|
|
12416
|
+
return record.versions.length;
|
|
12417
|
+
}
|
|
12418
|
+
}
|
|
12419
|
+
function toStat(path, name, entry) {
|
|
12420
|
+
return {
|
|
12421
|
+
path,
|
|
12422
|
+
name,
|
|
12423
|
+
kind: entry.kind,
|
|
12424
|
+
size: entry.kind === "file" ? entry.size : 0,
|
|
12425
|
+
mtime: entry.mtime,
|
|
12426
|
+
versions: entry.kind === "file" ? (entry.versions ?? 0) : 0,
|
|
12427
|
+
};
|
|
12428
|
+
}
|
|
12429
|
+
/** Directories first, then alphabetical — what every file explorer does. */
|
|
12430
|
+
function byDirsThenName(a, b) {
|
|
12431
|
+
if (a.kind !== b.kind)
|
|
12432
|
+
return a.kind === "dir" ? -1 : 1;
|
|
12433
|
+
return a.name.localeCompare(b.name);
|
|
12434
|
+
}
|
|
12435
|
+
|
|
11328
12436
|
/**
|
|
11329
12437
|
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
11330
12438
|
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
@@ -12543,6 +13651,8 @@ class DbCache {
|
|
|
12543
13651
|
* `FileStorage`, `EncryptedSession`, `Network`, …) remain exported and usable
|
|
12544
13652
|
* directly, but the client is the supported, ergonomic surface.
|
|
12545
13653
|
*/
|
|
13654
|
+
/** Personal-space key holding the id of the space VFS content is written to. */
|
|
13655
|
+
const VFS_SPACE_KEY = "vfs/space";
|
|
12546
13656
|
/** The hosted Muhkoo Accelerator — the default {@link ClientOptions.baseUrl}. */
|
|
12547
13657
|
const DEFAULT_BASE_URL = "https://api.muhkoo.dev";
|
|
12548
13658
|
/** The hosted auth SPA — the default {@link ClientOptions.authBaseUrl}. */
|
|
@@ -12564,6 +13674,8 @@ class Client {
|
|
|
12564
13674
|
db;
|
|
12565
13675
|
/** File storage — `client.storage.writeFile(...)`, etc. */
|
|
12566
13676
|
storage;
|
|
13677
|
+
/** Filesystem over the personal space: directories, versions, real paths. */
|
|
13678
|
+
vfs;
|
|
12567
13679
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
12568
13680
|
message;
|
|
12569
13681
|
/** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
|
|
@@ -12663,6 +13775,94 @@ class Client {
|
|
|
12663
13775
|
deferShardUpload,
|
|
12664
13776
|
});
|
|
12665
13777
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
13778
|
+
// The VFS stores its metadata in the personal space — the same place
|
|
13779
|
+
// `storage.writeFile` already mirrors `{spaceId, manifest}` to, only
|
|
13780
|
+
// arranged as a navigable tree instead of a flat index keyed by file id.
|
|
13781
|
+
//
|
|
13782
|
+
// Content still goes through `storage.writeFile` untouched, into a real
|
|
13783
|
+
// Space with a real gated manifest. The space is created once, lazily,
|
|
13784
|
+
// and its id recorded in the personal space: a filesystem that has never
|
|
13785
|
+
// been written to should not cost a space.
|
|
13786
|
+
const personalStore = {
|
|
13787
|
+
get: async (key) => {
|
|
13788
|
+
const c = this.session.commitment;
|
|
13789
|
+
if (!c)
|
|
13790
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
13791
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
|
|
13792
|
+
return res?.value ?? null;
|
|
13793
|
+
},
|
|
13794
|
+
put: async (key, value) => {
|
|
13795
|
+
const c = this.session.commitment;
|
|
13796
|
+
if (!c)
|
|
13797
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
13798
|
+
await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
|
|
13799
|
+
},
|
|
13800
|
+
delete: async (key) => {
|
|
13801
|
+
const c = this.session.commitment;
|
|
13802
|
+
if (!c)
|
|
13803
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
13804
|
+
await this.http.del(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`);
|
|
13805
|
+
},
|
|
13806
|
+
list: async () => {
|
|
13807
|
+
const c = this.session.commitment;
|
|
13808
|
+
if (!c)
|
|
13809
|
+
throw new Error("client.vfs: no session — sign in first.");
|
|
13810
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/list`, {});
|
|
13811
|
+
return res?.keys ?? [];
|
|
13812
|
+
},
|
|
13813
|
+
};
|
|
13814
|
+
let contentSpace = null;
|
|
13815
|
+
const resolveContentSpace = () => {
|
|
13816
|
+
// Cached as the PROMISE, not the value: two concurrent first writes
|
|
13817
|
+
// would otherwise each create a space and one would be orphaned.
|
|
13818
|
+
if (!contentSpace) {
|
|
13819
|
+
contentSpace = (async () => {
|
|
13820
|
+
const existing = await personalStore.get(VFS_SPACE_KEY);
|
|
13821
|
+
if (typeof existing === "string" && existing)
|
|
13822
|
+
return this.space.get(existing);
|
|
13823
|
+
// `messaging: false` — this space only ever holds files, and
|
|
13824
|
+
// file chunks carry their own keys, so it needs no group
|
|
13825
|
+
// keyring and no socket. Creating it the normal way opens a
|
|
13826
|
+
// WebSocket, which made the first write hang indefinitely.
|
|
13827
|
+
const space = await this.space.createSpace({ private: true, messaging: false });
|
|
13828
|
+
await personalStore.put(VFS_SPACE_KEY, space.id);
|
|
13829
|
+
return space;
|
|
13830
|
+
})().catch((err) => {
|
|
13831
|
+
contentSpace = null; // let the next write retry
|
|
13832
|
+
throw err;
|
|
13833
|
+
});
|
|
13834
|
+
}
|
|
13835
|
+
return contentSpace;
|
|
13836
|
+
};
|
|
13837
|
+
this.vfs = new VfsNamespace({
|
|
13838
|
+
store: personalStore,
|
|
13839
|
+
seed: () => this.session.seed,
|
|
13840
|
+
// Share the personal space's socket with `client.kv` rather than
|
|
13841
|
+
// opening a second one to the same Durable Object.
|
|
13842
|
+
subscribe: (handler) => this.kv.onRaw(handler),
|
|
13843
|
+
content: {
|
|
13844
|
+
// `putFile` needs no WebSocket and commits the shards, so the
|
|
13845
|
+
// bytes are metered to the owning app and reference-counted for
|
|
13846
|
+
// GC. `storage.writeFile` would additionally index the manifest
|
|
13847
|
+
// in the space — a second, diverging source of truth for a file
|
|
13848
|
+
// whose handle the VFS already holds.
|
|
13849
|
+
write: async (data, meta) => {
|
|
13850
|
+
const space = await resolveContentSpace();
|
|
13851
|
+
const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
|
|
13852
|
+
return { manifest, size: stat.size };
|
|
13853
|
+
},
|
|
13854
|
+
// Reads go through the manifest alone — no space needed. That is
|
|
13855
|
+
// the capability path, so it keeps working for a file whose
|
|
13856
|
+
// handle was shared from someone else's filesystem.
|
|
13857
|
+
read: async (manifest) => (await this.storage.readByManifest(manifest)).data,
|
|
13858
|
+
release: async (manifest) => {
|
|
13859
|
+
await (await resolveContentSpace()).releaseFile(manifest);
|
|
13860
|
+
},
|
|
13861
|
+
retain: async (manifest) => {
|
|
13862
|
+
await (await resolveContentSpace()).commitFile(manifest);
|
|
13863
|
+
},
|
|
13864
|
+
},
|
|
13865
|
+
});
|
|
12666
13866
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
12667
13867
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
12668
13868
|
// round-trip. The server only ever sees ciphertext.
|
|
@@ -13601,5 +14801,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
13601
14801
|
default: loadRsWasm
|
|
13602
14802
|
});
|
|
13603
14803
|
|
|
13604
|
-
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReauthRequiredError, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$
|
|
14804
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReauthRequiredError, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
13605
14805
|
//# sourceMappingURL=index.js.map
|