@muhkoo/connect 0.9.0-alpha.0 → 0.10.1-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/blockEngine.worker.js.map +1 -1
- package/dist/browser/index.js +441 -71
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +103 -2
- package/dist/server/index.js +441 -71
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -3796,6 +3796,8 @@ class ReedSolomonCodec {
|
|
|
3796
3796
|
* skip a PUT they'd otherwise dedup-rewrite. The
|
|
3797
3797
|
* server can ignore the optimization if it wants.
|
|
3798
3798
|
*/
|
|
3799
|
+
/** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */
|
|
3800
|
+
const HEDGE_MS = 500;
|
|
3799
3801
|
/**
|
|
3800
3802
|
* Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
|
|
3801
3803
|
* every runtime this SDK targets.
|
|
@@ -3897,31 +3899,63 @@ class ShardClient {
|
|
|
3897
3899
|
if (cached)
|
|
3898
3900
|
return cached;
|
|
3899
3901
|
}
|
|
3900
|
-
//
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3902
|
+
// No peers → straight to origin.
|
|
3903
|
+
if (!this.peers)
|
|
3904
|
+
return this.fetchFromOrigin(hash);
|
|
3905
|
+
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
3906
|
+
// or missing peer stall delivery — give the peer a short head start, then
|
|
3907
|
+
// start origin in parallel and take whichever returns the block first.
|
|
3908
|
+
const peers = this.peers;
|
|
3909
|
+
return new Promise((resolve, reject) => {
|
|
3910
|
+
let done = false;
|
|
3911
|
+
let originStarted = false;
|
|
3912
|
+
let timer = null;
|
|
3913
|
+
const win = (b) => {
|
|
3914
|
+
if (done)
|
|
3915
|
+
return;
|
|
3916
|
+
done = true;
|
|
3917
|
+
if (timer)
|
|
3918
|
+
clearTimeout(timer);
|
|
3919
|
+
resolve(b);
|
|
3920
|
+
};
|
|
3921
|
+
const fail = (e) => {
|
|
3922
|
+
if (!done) {
|
|
3923
|
+
done = true;
|
|
3924
|
+
reject(e);
|
|
3925
|
+
}
|
|
3926
|
+
};
|
|
3927
|
+
const startOrigin = () => {
|
|
3928
|
+
if (originStarted || done)
|
|
3929
|
+
return;
|
|
3930
|
+
originStarted = true;
|
|
3931
|
+
if (timer) {
|
|
3932
|
+
clearTimeout(timer);
|
|
3933
|
+
timer = null;
|
|
3934
|
+
}
|
|
3935
|
+
this.fetchFromOrigin(hash).then(win, fail);
|
|
3936
|
+
};
|
|
3937
|
+
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
3938
|
+
peers.getBlock(hash).then((b) => {
|
|
3939
|
+
if (b) {
|
|
3907
3940
|
if (this.cache)
|
|
3908
|
-
void this.cache.put(hash,
|
|
3909
|
-
|
|
3941
|
+
void this.cache.put(hash, b);
|
|
3942
|
+
win(b); // peer delivered first
|
|
3910
3943
|
}
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
}
|
|
3915
|
-
}
|
|
3944
|
+
else {
|
|
3945
|
+
startOrigin(); // peer miss → don't wait out the hedge
|
|
3946
|
+
}
|
|
3947
|
+
}, () => startOrigin());
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
/** Fetch a shard from origin. Returns `null` for 404 (or offline when a
|
|
3951
|
+
* cache is present, so RS recovery can proceed); throws otherwise. */
|
|
3952
|
+
async fetchFromOrigin(hash) {
|
|
3916
3953
|
const url = this.urlForHash(hash);
|
|
3917
3954
|
let response;
|
|
3918
3955
|
try {
|
|
3919
3956
|
response = await this.fetchFn(url, { method: "GET" });
|
|
3920
3957
|
}
|
|
3921
3958
|
catch (err) {
|
|
3922
|
-
// Offline with no cache hit: report the shard as missing so
|
|
3923
|
-
// FileStorage's Reed–Solomon recovery can still reconstruct from the
|
|
3924
|
-
// shards that ARE cached. Rethrow when there's no cache to fall back on.
|
|
3925
3959
|
if (this.cache)
|
|
3926
3960
|
return null;
|
|
3927
3961
|
throw err;
|
|
@@ -3932,8 +3966,7 @@ class ShardClient {
|
|
|
3932
3966
|
const text = await safeText(response);
|
|
3933
3967
|
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
3934
3968
|
}
|
|
3935
|
-
const
|
|
3936
|
-
const bytes = new Uint8Array(buf);
|
|
3969
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
3937
3970
|
if (this.cache)
|
|
3938
3971
|
void this.cache.put(hash, bytes);
|
|
3939
3972
|
return bytes;
|
|
@@ -4961,7 +4994,7 @@ function base64UrlDecode(s) {
|
|
|
4961
4994
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
4962
4995
|
* `package.json` `version`.
|
|
4963
4996
|
*/
|
|
4964
|
-
const VERSION = "0.
|
|
4997
|
+
const VERSION = "0.10.1-alpha.0";
|
|
4965
4998
|
|
|
4966
4999
|
/**
|
|
4967
5000
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -4984,6 +5017,28 @@ const VERSION = "0.9.0-alpha.0";
|
|
|
4984
5017
|
* convenience JSON helpers (`get`/`post`/`del`) for code that talks to the
|
|
4985
5018
|
* accelerator directly.
|
|
4986
5019
|
*/
|
|
5020
|
+
/** Per-request timing logs, enabled on staging only (temporary diagnostic). */
|
|
5021
|
+
const HTTP_DEBUG = (() => {
|
|
5022
|
+
try {
|
|
5023
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
5024
|
+
}
|
|
5025
|
+
catch {
|
|
5026
|
+
return false;
|
|
5027
|
+
}
|
|
5028
|
+
})();
|
|
5029
|
+
/** Short, readable request path (pathname + search) for the timing log. */
|
|
5030
|
+
function reqPath(input) {
|
|
5031
|
+
try {
|
|
5032
|
+
const u = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
5033
|
+
if (!/^https?:\/\//i.test(u))
|
|
5034
|
+
return u;
|
|
5035
|
+
const parsed = new URL(u);
|
|
5036
|
+
return parsed.pathname + parsed.search;
|
|
5037
|
+
}
|
|
5038
|
+
catch {
|
|
5039
|
+
return "?";
|
|
5040
|
+
}
|
|
5041
|
+
}
|
|
4987
5042
|
/** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
|
|
4988
5043
|
class HttpError extends Error {
|
|
4989
5044
|
status;
|
|
@@ -5040,7 +5095,14 @@ class HttpClient {
|
|
|
5040
5095
|
if (token)
|
|
5041
5096
|
headers.set("X-Muhkoo-Session", token);
|
|
5042
5097
|
}
|
|
5043
|
-
|
|
5098
|
+
const finalInit = { ...init, headers };
|
|
5099
|
+
if (!HTTP_DEBUG)
|
|
5100
|
+
return this.fetchFn(input, finalInit);
|
|
5101
|
+
// Staging diagnostic: time every accelerator request (this `fetch` is the
|
|
5102
|
+
// single chokepoint — JSON helpers + the roster/shard fetches ride it).
|
|
5103
|
+
const t = performance.now();
|
|
5104
|
+
const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
|
|
5105
|
+
return this.fetchFn(input, finalInit).then((res) => { console.info(`[muhkoo:http] ${tag} → ${res.status} ${Math.round(performance.now() - t)}ms`); return res; }, (err) => { console.info(`[muhkoo:http] ${tag} → ERROR ${Math.round(performance.now() - t)}ms`); throw err; });
|
|
5044
5106
|
};
|
|
5045
5107
|
/** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
|
|
5046
5108
|
isSameOrigin(input) {
|
|
@@ -5854,6 +5916,34 @@ class ZkAuth {
|
|
|
5854
5916
|
return null;
|
|
5855
5917
|
}
|
|
5856
5918
|
}
|
|
5919
|
+
/**
|
|
5920
|
+
* Boot helper — call this once on app start. Restores the session AND, when a
|
|
5921
|
+
* passkey is enrolled, silently unlocks the identity via WebAuthn (recovering
|
|
5922
|
+
* the master seed so the ratchet keypair rehydrates and `client.kv` works).
|
|
5923
|
+
* The seed is never persisted, so a passkey (or password) is the only way to
|
|
5924
|
+
* unlock after a reload — this keeps that off the app's plate.
|
|
5925
|
+
*
|
|
5926
|
+
* Returns `{ user, unlocked }`. When `unlocked` is false (no passkey, the
|
|
5927
|
+
* user cancelled, or the browser required a gesture), prompt for a password
|
|
5928
|
+
* and call {@link unlock}. Apps need no key-management scaffolding beyond this.
|
|
5929
|
+
*/
|
|
5930
|
+
async resume() {
|
|
5931
|
+
const user = await this.restore();
|
|
5932
|
+
if (!user)
|
|
5933
|
+
return { user: null, unlocked: false };
|
|
5934
|
+
if (this.deps.session.isUnlocked)
|
|
5935
|
+
return { user, unlocked: true };
|
|
5936
|
+
try {
|
|
5937
|
+
const factors = await this.listFactors();
|
|
5938
|
+
if (factors.some((f) => f.type === "passkey")) {
|
|
5939
|
+
await this.loginWithPasskey(user.username);
|
|
5940
|
+
}
|
|
5941
|
+
}
|
|
5942
|
+
catch {
|
|
5943
|
+
/* no passkey / cancelled / no user gesture — leave locked for a prompt */
|
|
5944
|
+
}
|
|
5945
|
+
return { user, unlocked: this.deps.session.isUnlocked };
|
|
5946
|
+
}
|
|
5857
5947
|
/**
|
|
5858
5948
|
* Re-derive identity material for an already-authenticated (restored)
|
|
5859
5949
|
* session, so the client can decrypt storage and run the ratchet. Verifies
|
|
@@ -7205,18 +7295,23 @@ class Space {
|
|
|
7205
7295
|
const hlc = await offline.nextHlc();
|
|
7206
7296
|
const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
|
|
7207
7297
|
const frame = { spaceMessage: packet };
|
|
7208
|
-
|
|
7298
|
+
const entry = {
|
|
7209
7299
|
handle: provisionalSpaceKey(clientId),
|
|
7210
7300
|
packet,
|
|
7211
7301
|
op: "msg",
|
|
7212
7302
|
clientId,
|
|
7213
7303
|
hlc,
|
|
7214
7304
|
pending: true,
|
|
7215
|
-
}
|
|
7216
|
-
if (this.isConnected())
|
|
7217
|
-
this.sendRaw(frame);
|
|
7218
|
-
|
|
7305
|
+
};
|
|
7306
|
+
if (this.isConnected()) {
|
|
7307
|
+
this.sendRaw(frame); // send first — don't block the wire on IndexedDB
|
|
7308
|
+
void offline.putMessage(this.id, entry); // optimistic cache off the hot path
|
|
7309
|
+
}
|
|
7310
|
+
else {
|
|
7311
|
+
// Offline: persist the optimistic entry + durable replay queue.
|
|
7312
|
+
void offline.putMessage(this.id, entry);
|
|
7219
7313
|
await offline.enqueueFrame(this.id, frame, clientId, hlc);
|
|
7314
|
+
}
|
|
7220
7315
|
}
|
|
7221
7316
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
7222
7317
|
onMessage(handler) {
|
|
@@ -7280,18 +7375,14 @@ class Space {
|
|
|
7280
7375
|
const offline = this.deps.offline;
|
|
7281
7376
|
if (!offline?.enabled || !this.cipher)
|
|
7282
7377
|
return [];
|
|
7283
|
-
const entries = await offline.loadMessages(this.id);
|
|
7378
|
+
const entries = (await offline.loadMessages(this.id)).filter((e) => !e.deleted && e.packet);
|
|
7379
|
+
// Decode in parallel (already verified when cached → skipVerify).
|
|
7380
|
+
const decodedAll = await Promise.all(entries.map((e) => this.decodeFrame(e.packet, this.cipher, /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle), /*skipVerify*/ true)));
|
|
7284
7381
|
const out = [];
|
|
7285
|
-
for (
|
|
7286
|
-
|
|
7287
|
-
continue;
|
|
7288
|
-
// Already verified when first cached → skip re-verification (which
|
|
7289
|
-
// would also fail offline without a warm member directory).
|
|
7290
|
-
const decoded = await this.decodeFrame(e.packet, this.cipher,
|
|
7291
|
-
/*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
|
|
7292
|
-
/*skipVerify*/ true);
|
|
7382
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7383
|
+
const decoded = decodedAll[i];
|
|
7293
7384
|
if (decoded) {
|
|
7294
|
-
decoded.pending =
|
|
7385
|
+
decoded.pending = entries[i].pending;
|
|
7295
7386
|
out.push(decoded);
|
|
7296
7387
|
}
|
|
7297
7388
|
}
|
|
@@ -7348,25 +7439,45 @@ class Space {
|
|
|
7348
7439
|
params.set("limit", String(opts.limit));
|
|
7349
7440
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7350
7441
|
(params.toString() ? `?${params}` : "");
|
|
7442
|
+
// Temporary staging diagnostic: split fetch / directory / decode time.
|
|
7443
|
+
const dbg = (() => { try {
|
|
7444
|
+
return globalThis.location?.hostname?.includes("staging");
|
|
7445
|
+
}
|
|
7446
|
+
catch {
|
|
7447
|
+
return false;
|
|
7448
|
+
} })();
|
|
7449
|
+
const mark = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
7450
|
+
const t0 = mark();
|
|
7351
7451
|
const res = await this.deps.fetch(url);
|
|
7352
7452
|
if (!res.ok)
|
|
7353
7453
|
throw new Error(`Space.history: ${res.status} ${res.statusText}`);
|
|
7354
7454
|
const body = (await res.json());
|
|
7455
|
+
const tFetch = mark();
|
|
7355
7456
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7356
|
-
//
|
|
7357
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7457
|
+
// Force past the cooldown — a history load is an explicit "get fresh".
|
|
7458
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7459
|
+
const tDir = mark();
|
|
7460
|
+
// Decode the whole page in PARALLEL — each frame is an AES-GCM decrypt +
|
|
7461
|
+
// ECDSA verify on the WebCrypto thread; doing them sequentially (await
|
|
7462
|
+
// per message) serializes ~100 main↔crypto round-trips and dominates
|
|
7463
|
+
// load time. Promise.all preserves input order.
|
|
7464
|
+
const raws = body.messages ?? [];
|
|
7465
|
+
const decodedAll = await Promise.all(raws.map((raw) => this.decodeFrame(raw, cipher)));
|
|
7466
|
+
if (dbg) {
|
|
7467
|
+
console.info(`[muhkoo:space] history n=${raws.length} | fetch ${Math.round(tFetch - t0)}ms | dir ${Math.round(tDir - tFetch)}ms | decode ${Math.round(mark() - tDir)}ms`);
|
|
7468
|
+
}
|
|
7358
7469
|
const messages = [];
|
|
7359
|
-
for (
|
|
7360
|
-
const decoded =
|
|
7470
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7471
|
+
const decoded = decodedAll[i];
|
|
7361
7472
|
if (!decoded)
|
|
7362
7473
|
continue;
|
|
7363
7474
|
messages.push(decoded);
|
|
7364
|
-
// Warm the offline cache
|
|
7475
|
+
// Warm the offline cache off the hot path (best-effort, non-blocking).
|
|
7365
7476
|
if (this.deps.offline?.enabled) {
|
|
7366
7477
|
try {
|
|
7367
|
-
const outer = JSON.parse(
|
|
7478
|
+
const outer = JSON.parse(raws[i]);
|
|
7368
7479
|
if (typeof outer.spaceMessage === "string") {
|
|
7369
|
-
|
|
7480
|
+
void this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7370
7481
|
}
|
|
7371
7482
|
}
|
|
7372
7483
|
catch {
|
|
@@ -7463,8 +7574,8 @@ class Space {
|
|
|
7463
7574
|
const handle = Number(f.timestamp ?? 0);
|
|
7464
7575
|
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
7465
7576
|
if (decoded) {
|
|
7466
|
-
this.emitEvent("message", decoded);
|
|
7467
|
-
|
|
7577
|
+
this.emitEvent("message", decoded); // UI first
|
|
7578
|
+
void this.cacheFrame(f.spaceMessage, decoded, "msg"); // cache off the hot path
|
|
7468
7579
|
}
|
|
7469
7580
|
return;
|
|
7470
7581
|
}
|
|
@@ -7476,7 +7587,7 @@ class Space {
|
|
|
7476
7587
|
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
7477
7588
|
if (decoded) {
|
|
7478
7589
|
this.emitEvent("message-edited", decoded);
|
|
7479
|
-
|
|
7590
|
+
void this.cacheFrame(e.spaceMessage, decoded, "edit");
|
|
7480
7591
|
}
|
|
7481
7592
|
}
|
|
7482
7593
|
return;
|
|
@@ -7486,8 +7597,8 @@ class Space {
|
|
|
7486
7597
|
const handle = Number(d.ts ?? 0);
|
|
7487
7598
|
this.emitEvent("message-deleted", { handle });
|
|
7488
7599
|
if (this.deps.offline?.enabled) {
|
|
7489
|
-
|
|
7490
|
-
|
|
7600
|
+
// Cache the tombstone off the hot path.
|
|
7601
|
+
void this.deps.offline.putDeleted(this.id, handle).then(() => this.deps.offline?.observeHandle(this.id, handle));
|
|
7491
7602
|
}
|
|
7492
7603
|
return;
|
|
7493
7604
|
}
|
|
@@ -7508,7 +7619,7 @@ class Space {
|
|
|
7508
7619
|
this.emitEvent("join-request", req);
|
|
7509
7620
|
// A new member appeared — refresh the directory so their future
|
|
7510
7621
|
// messages verify, and (if we hold the key) auto-admit them.
|
|
7511
|
-
void this.deps.keyring?.refreshDirectory();
|
|
7622
|
+
void this.deps.keyring?.refreshDirectory({ force: true });
|
|
7512
7623
|
if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
|
|
7513
7624
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
7514
7625
|
}
|
|
@@ -7658,7 +7769,7 @@ class Space {
|
|
|
7658
7769
|
const cursor = await offline.getCursor(this.id);
|
|
7659
7770
|
let since = String(cursor?.lastSeenHandle ?? 0);
|
|
7660
7771
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7661
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7772
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7662
7773
|
for (let page = 0; page < 50; page++) {
|
|
7663
7774
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7664
7775
|
`?since=${encodeURIComponent(since)}&limit=100`;
|
|
@@ -7921,6 +8032,19 @@ function parseMaybeJson(text) {
|
|
|
7921
8032
|
* `KEEPER_MEMBER_ID`.
|
|
7922
8033
|
*/
|
|
7923
8034
|
const KEEPER_MEMBER_ID = "__keeper__";
|
|
8035
|
+
/** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
|
|
8036
|
+
const KEYRING_DEBUG = (() => {
|
|
8037
|
+
try {
|
|
8038
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
8039
|
+
}
|
|
8040
|
+
catch {
|
|
8041
|
+
return false;
|
|
8042
|
+
}
|
|
8043
|
+
})();
|
|
8044
|
+
function klog(msg) {
|
|
8045
|
+
if (KEYRING_DEBUG)
|
|
8046
|
+
console.info(`[muhkoo:keyring] ${msg}`);
|
|
8047
|
+
}
|
|
7924
8048
|
class SpaceKeyring {
|
|
7925
8049
|
deps;
|
|
7926
8050
|
keys = new Map();
|
|
@@ -7928,6 +8052,14 @@ class SpaceKeyring {
|
|
|
7928
8052
|
policy;
|
|
7929
8053
|
/** Cached member directory: memberId → imported ECDSA verify key. */
|
|
7930
8054
|
memberEcdsa = new Map();
|
|
8055
|
+
/** Coalesce concurrent roster refreshes (the parallel history/backlog decode
|
|
8056
|
+
* would otherwise fire one `roster` fetch PER message — a thundering herd). */
|
|
8057
|
+
dirInFlight = null;
|
|
8058
|
+
dirRefreshedAt = 0;
|
|
8059
|
+
/** Min gap between non-forced roster refreshes. A miss that persists within
|
|
8060
|
+
* this window won't re-fetch (the roster hasn't changed that fast); genuine
|
|
8061
|
+
* membership changes use `{ force: true }` (join requests / history loads). */
|
|
8062
|
+
static DIR_COOLDOWN_MS = 5000;
|
|
7931
8063
|
constructor(deps) {
|
|
7932
8064
|
this.deps = deps;
|
|
7933
8065
|
this.policy = deps.historyPolicy ?? "static";
|
|
@@ -7937,8 +8069,27 @@ class SpaceKeyring {
|
|
|
7937
8069
|
ecdsaKeyFor(memberId) {
|
|
7938
8070
|
return this.memberEcdsa.get(memberId);
|
|
7939
8071
|
}
|
|
7940
|
-
/**
|
|
7941
|
-
|
|
8072
|
+
/**
|
|
8073
|
+
* Refresh the member directory (memberId → ECDSA key) from the roster.
|
|
8074
|
+
* Coalesces concurrent callers into ONE fetch, and skips a fetch when one
|
|
8075
|
+
* completed within {@link DIR_COOLDOWN_MS} (unless `force`). Without this, a
|
|
8076
|
+
* page of N messages decoded in parallel — each missing the cold directory —
|
|
8077
|
+
* fires N simultaneous `roster` requests that queue behind the browser's
|
|
8078
|
+
* connection limit and take many seconds.
|
|
8079
|
+
*/
|
|
8080
|
+
async refreshDirectory(opts) {
|
|
8081
|
+
if (this.dirInFlight)
|
|
8082
|
+
return this.dirInFlight; // a refresh is already running
|
|
8083
|
+
if (!opts?.force && Date.now() - this.dirRefreshedAt < SpaceKeyring.DIR_COOLDOWN_MS) {
|
|
8084
|
+
return; // refreshed very recently — the roster won't have changed
|
|
8085
|
+
}
|
|
8086
|
+
this.dirInFlight = this.doRefreshDirectory().finally(() => {
|
|
8087
|
+
this.dirRefreshedAt = Date.now();
|
|
8088
|
+
this.dirInFlight = null;
|
|
8089
|
+
});
|
|
8090
|
+
return this.dirInFlight;
|
|
8091
|
+
}
|
|
8092
|
+
async doRefreshDirectory() {
|
|
7942
8093
|
let roster;
|
|
7943
8094
|
try {
|
|
7944
8095
|
roster = await this.deps.transport.fetchRoster();
|
|
@@ -7973,34 +8124,63 @@ class SpaceKeyring {
|
|
|
7973
8124
|
async loadFromCache() {
|
|
7974
8125
|
if (!this.deps.cache)
|
|
7975
8126
|
return false;
|
|
8127
|
+
// The cache entries are ECIES-wrapped to our identity key, so we unwrap
|
|
8128
|
+
// with the keyring's OWN private key — available whenever chat works,
|
|
8129
|
+
// unlike client.kv's StorageCipher which needs the full unlocked identity.
|
|
8130
|
+
const priv = this.deps.ownPrivateKey();
|
|
8131
|
+
if (!priv) {
|
|
8132
|
+
klog("loadFromCache: no private key available yet");
|
|
8133
|
+
return false;
|
|
8134
|
+
}
|
|
7976
8135
|
let cached;
|
|
7977
8136
|
try {
|
|
7978
8137
|
cached = await this.deps.cache.loadKeys(this.deps.spaceId);
|
|
7979
8138
|
}
|
|
7980
|
-
catch {
|
|
8139
|
+
catch (e) {
|
|
8140
|
+
klog(`loadFromCache: ERROR reading cache — ${e instanceof Error ? e.message : e}`);
|
|
7981
8141
|
return false;
|
|
7982
8142
|
}
|
|
7983
|
-
if (!cached)
|
|
8143
|
+
if (!cached) {
|
|
8144
|
+
klog("loadFromCache: cache empty");
|
|
8145
|
+
return false;
|
|
8146
|
+
}
|
|
8147
|
+
for (const [epochStr, serialized] of Object.entries(cached)) {
|
|
8148
|
+
try {
|
|
8149
|
+
const blob = JSON.parse(serialized);
|
|
8150
|
+
this.keys.set(Number(epochStr), await unwrapSpaceKey(blob, priv));
|
|
8151
|
+
}
|
|
8152
|
+
catch {
|
|
8153
|
+
/* stale / old-format entry — skip; we'll re-admit and re-persist */
|
|
8154
|
+
}
|
|
8155
|
+
}
|
|
8156
|
+
if (this.keys.size === 0) {
|
|
8157
|
+
klog("loadFromCache: no usable entries (stale format → will re-admit)");
|
|
7984
8158
|
return false;
|
|
7985
|
-
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
7986
|
-
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
7987
8159
|
}
|
|
7988
8160
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
7989
|
-
|
|
8161
|
+
klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
|
|
8162
|
+
return true;
|
|
7990
8163
|
}
|
|
7991
8164
|
async persist() {
|
|
7992
|
-
if (!this.deps.cache)
|
|
8165
|
+
if (!this.deps.cache || this.keys.size === 0)
|
|
7993
8166
|
return;
|
|
7994
|
-
|
|
7995
|
-
|
|
7996
|
-
out[String(epoch)] = toBase64$1(key);
|
|
7997
|
-
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
7998
|
-
// storage) must not break key acquisition.
|
|
8167
|
+
// Best-effort: caching is an optimization; a write failure must not break
|
|
8168
|
+
// key acquisition.
|
|
7999
8169
|
try {
|
|
8170
|
+
// Wrap each epoch key to our OWN identity key (ECIES-to-self). The
|
|
8171
|
+
// cache stays server-blind (the kv layer stores it with encrypt:false
|
|
8172
|
+
// — it's already encrypted) AND readable with the keyring private key
|
|
8173
|
+
// alone, so it works while the full identity is locked (post-reload).
|
|
8174
|
+
const selfPub = await importEcdhPublicKey(this.deps.identityEcdhPub);
|
|
8175
|
+
const out = {};
|
|
8176
|
+
for (const [epoch, key] of this.keys) {
|
|
8177
|
+
out[String(epoch)] = JSON.stringify(await wrapSpaceKey(key, epoch, selfPub));
|
|
8178
|
+
}
|
|
8000
8179
|
await this.deps.cache.saveKeys(this.deps.spaceId, out);
|
|
8180
|
+
klog(`persist: saved ${Object.keys(out).length} epoch(s)`);
|
|
8001
8181
|
}
|
|
8002
|
-
catch {
|
|
8003
|
-
|
|
8182
|
+
catch (e) {
|
|
8183
|
+
klog(`persist: FAILED — ${e instanceof Error ? e.message : e}`);
|
|
8004
8184
|
}
|
|
8005
8185
|
}
|
|
8006
8186
|
/** Creator path: mint epoch-0 group key for a brand-new space. */
|
|
@@ -8049,8 +8229,24 @@ class SpaceKeyring {
|
|
|
8049
8229
|
this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
|
|
8050
8230
|
await this.persist();
|
|
8051
8231
|
}
|
|
8232
|
+
klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
|
|
8052
8233
|
return added;
|
|
8053
8234
|
}
|
|
8235
|
+
/**
|
|
8236
|
+
* Background freshness check for the cache-hit fast path: confirm we hold the
|
|
8237
|
+
* current epoch (picks up a rotation / our removal) WITHOUT the full newcomer
|
|
8238
|
+
* admit handshake. Cheap + idempotent; never blocks first paint. Returns the
|
|
8239
|
+
* number of newly-obtained epoch keys (0 = already current).
|
|
8240
|
+
*/
|
|
8241
|
+
async reconcile() {
|
|
8242
|
+
await this.refreshDirectory({ force: true });
|
|
8243
|
+
try {
|
|
8244
|
+
return await this.pullKeys();
|
|
8245
|
+
}
|
|
8246
|
+
catch {
|
|
8247
|
+
return 0; // best-effort — the live keyringReady frame is the backstop
|
|
8248
|
+
}
|
|
8249
|
+
}
|
|
8054
8250
|
// -- key-holder: admit + rotate -------------------------------------------
|
|
8055
8251
|
/**
|
|
8056
8252
|
* Wrap our group key(s) for a newcomer and post the blobs. In a `static`
|
|
@@ -8450,14 +8646,22 @@ class ShardCache {
|
|
|
8450
8646
|
class PeerExchange {
|
|
8451
8647
|
engine;
|
|
8452
8648
|
timeoutMs;
|
|
8649
|
+
debug;
|
|
8453
8650
|
constructor(engine, opts = {}) {
|
|
8454
8651
|
this.engine = engine;
|
|
8455
8652
|
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8653
|
+
this.debug = opts.debug ?? false;
|
|
8456
8654
|
}
|
|
8457
|
-
getBlock(hash, opts) {
|
|
8458
|
-
|
|
8655
|
+
async getBlock(hash, opts) {
|
|
8656
|
+
const bytes = await this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8657
|
+
if (this.debug) {
|
|
8658
|
+
console.info(`%c[muhkoo:p2p] ${bytes ? "✓ peer-served" : "✗ miss → origin"} shard ${hash.slice(0, 10)}…`, `color:${bytes ? "#58da7d" : "#888"}`);
|
|
8659
|
+
}
|
|
8660
|
+
return bytes;
|
|
8459
8661
|
}
|
|
8460
8662
|
announce(hash) {
|
|
8663
|
+
if (this.debug)
|
|
8664
|
+
console.info(`[muhkoo:p2p] announce shard ${hash.slice(0, 10)}…`);
|
|
8461
8665
|
this.engine.announce(hash);
|
|
8462
8666
|
}
|
|
8463
8667
|
}
|
|
@@ -9145,7 +9349,11 @@ class PeerNetwork {
|
|
|
9145
9349
|
this.signaler.send(from, "hello", { broadcast: false });
|
|
9146
9350
|
}
|
|
9147
9351
|
}));
|
|
9148
|
-
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs });
|
|
9352
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs, debug: opts.debug });
|
|
9353
|
+
if (opts.debug) {
|
|
9354
|
+
console.info("[muhkoo:p2p] mesh attached for space; awaiting peers…");
|
|
9355
|
+
this.offs.push(this.transport.onPeerChange((peers) => console.info(`%c[muhkoo:p2p] connected peers: ${peers.length}`, "color:#58da7d", peers)));
|
|
9356
|
+
}
|
|
9149
9357
|
}
|
|
9150
9358
|
/**
|
|
9151
9359
|
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
@@ -9443,6 +9651,7 @@ class SpaceNamespace {
|
|
|
9443
9651
|
workerFactory: this.deps.p2p.workerFactory,
|
|
9444
9652
|
iceServers: this.deps.p2p.iceServers,
|
|
9445
9653
|
maxPeers: this.deps.p2p.maxPeers,
|
|
9654
|
+
debug: this.deps.p2p.debug,
|
|
9446
9655
|
}));
|
|
9447
9656
|
}
|
|
9448
9657
|
return space;
|
|
@@ -9451,7 +9660,34 @@ class SpaceNamespace {
|
|
|
9451
9660
|
async ensureIdentity(memberId) {
|
|
9452
9661
|
const store = KeyStore.getInstance();
|
|
9453
9662
|
if (!store.getKeyPair(memberId)) {
|
|
9454
|
-
|
|
9663
|
+
// Unlocked (seed in hand): rehydrate the member's STABLE keypair from
|
|
9664
|
+
// the vault, or provision + persist one on first use. This keeps the
|
|
9665
|
+
// member the same identity across reloads — no re-admit each session,
|
|
9666
|
+
// and the group-key cache round-trips. Best-effort: a personal-space
|
|
9667
|
+
// hiccup falls through to an ephemeral keypair (today's behavior).
|
|
9668
|
+
const seed = this.deps.session.seed;
|
|
9669
|
+
const staging = (() => { try {
|
|
9670
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9671
|
+
}
|
|
9672
|
+
catch {
|
|
9673
|
+
return false;
|
|
9674
|
+
} })();
|
|
9675
|
+
if (staging)
|
|
9676
|
+
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
9677
|
+
if (this.deps.chatKeys && seed) {
|
|
9678
|
+
try {
|
|
9679
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
|
|
9680
|
+
}
|
|
9681
|
+
catch (e) {
|
|
9682
|
+
if (staging)
|
|
9683
|
+
console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
|
|
9684
|
+
}
|
|
9685
|
+
}
|
|
9686
|
+
if (!store.getKeyPair(memberId)) {
|
|
9687
|
+
if (staging)
|
|
9688
|
+
console.info("[muhkoo:vault] ensureIdentity: EPHEMERAL keypair (locked / no vault) → will re-admit");
|
|
9689
|
+
await store.generateOwnKeyPair(memberId);
|
|
9690
|
+
}
|
|
9455
9691
|
}
|
|
9456
9692
|
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
9457
9693
|
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
@@ -9692,6 +9928,112 @@ class FunctionsNamespace {
|
|
|
9692
9928
|
}
|
|
9693
9929
|
}
|
|
9694
9930
|
|
|
9931
|
+
/**
|
|
9932
|
+
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
9933
|
+
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
9934
|
+
*
|
|
9935
|
+
* Historically each app hand-rolled this (see the web app's old
|
|
9936
|
+
* `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
|
|
9937
|
+
* PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
|
|
9938
|
+
* That scaffolding now lives here so ANY app gets a **stable** member keypair
|
|
9939
|
+
* across reloads for free — without which the member re-admits to every space on
|
|
9940
|
+
* every load and the group-key cache can never round-trip.
|
|
9941
|
+
*
|
|
9942
|
+
* Security invariant: the wrapping secret is the **master seed** (not the
|
|
9943
|
+
* password), so the blob survives password changes and unlocks under a passkey.
|
|
9944
|
+
* The seed is only ever held in memory (never persisted), so this vault can only
|
|
9945
|
+
* provision/rehydrate while the client is unlocked.
|
|
9946
|
+
*/
|
|
9947
|
+
/** Staging-only diagnostics: is the member keypair stable across reloads? */
|
|
9948
|
+
const VAULT_DEBUG = (() => {
|
|
9949
|
+
try {
|
|
9950
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9951
|
+
}
|
|
9952
|
+
catch {
|
|
9953
|
+
return false;
|
|
9954
|
+
}
|
|
9955
|
+
})();
|
|
9956
|
+
function vlog(msg) {
|
|
9957
|
+
if (VAULT_DEBUG)
|
|
9958
|
+
console.info(`[muhkoo:vault] ${msg}`);
|
|
9959
|
+
}
|
|
9960
|
+
/** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
|
|
9961
|
+
async function fingerprint(memberId) {
|
|
9962
|
+
try {
|
|
9963
|
+
const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
|
|
9964
|
+
if (!raw)
|
|
9965
|
+
return "none";
|
|
9966
|
+
let s = 0;
|
|
9967
|
+
for (const b of raw)
|
|
9968
|
+
s = (s * 31 + b) >>> 0;
|
|
9969
|
+
return s.toString(16).padStart(8, "0");
|
|
9970
|
+
}
|
|
9971
|
+
catch {
|
|
9972
|
+
return "err";
|
|
9973
|
+
}
|
|
9974
|
+
}
|
|
9975
|
+
/** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
|
|
9976
|
+
const CHAT_KEYS_KEY = "chat-keys";
|
|
9977
|
+
class ChatKeyVault {
|
|
9978
|
+
store;
|
|
9979
|
+
constructor(store) {
|
|
9980
|
+
this.store = store;
|
|
9981
|
+
}
|
|
9982
|
+
/**
|
|
9983
|
+
* Register-time: ensure a keypair exists for `memberId`, seed-wrap the
|
|
9984
|
+
* dehydrated form, and persist it to the personal space. Idempotent — reuses
|
|
9985
|
+
* an existing in-tab keypair rather than minting a second one.
|
|
9986
|
+
*/
|
|
9987
|
+
async provision(memberId, seedSecret) {
|
|
9988
|
+
const ks = KeyStore.getInstance();
|
|
9989
|
+
if (!ks.getKeyPair(memberId)) {
|
|
9990
|
+
try {
|
|
9991
|
+
await ks.generateOwnKeyPair(memberId);
|
|
9992
|
+
}
|
|
9993
|
+
catch (e) {
|
|
9994
|
+
if (!String(e?.message ?? e).includes("already exists"))
|
|
9995
|
+
throw e;
|
|
9996
|
+
}
|
|
9997
|
+
}
|
|
9998
|
+
const dehydrated = await ks.dehydrateKeyPair(memberId);
|
|
9999
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
|
|
10000
|
+
await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
|
|
10001
|
+
vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
|
|
10002
|
+
}
|
|
10003
|
+
/**
|
|
10004
|
+
* Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
|
|
10005
|
+
* hydrate the `KeyStore` — restoring the SAME keypair the member had before.
|
|
10006
|
+
* Returns `true` when a keypair is now in hand (already present or hydrated),
|
|
10007
|
+
* `false` when there's no blob yet (a brand-new member → caller provisions).
|
|
10008
|
+
* Throws only on a genuine unwrap failure (wrong secret / tampered blob).
|
|
10009
|
+
*/
|
|
10010
|
+
async rehydrate(memberId, seedSecret) {
|
|
10011
|
+
// Already unlocked in this tab — nothing to do.
|
|
10012
|
+
if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
|
|
10013
|
+
return true;
|
|
10014
|
+
const wrapped = await this.store.get(CHAT_KEYS_KEY);
|
|
10015
|
+
if (!wrapped) {
|
|
10016
|
+
vlog("rehydrate: no chat-keys blob (new member → will provision)");
|
|
10017
|
+
return false;
|
|
10018
|
+
}
|
|
10019
|
+
const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
|
|
10020
|
+
const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
|
|
10021
|
+
await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
|
|
10022
|
+
vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
|
|
10023
|
+
return true;
|
|
10024
|
+
}
|
|
10025
|
+
/**
|
|
10026
|
+
* Ensure a stable keypair is in hand: rehydrate from the vault if a blob
|
|
10027
|
+
* exists, otherwise mint one and persist it (so it's stable next session).
|
|
10028
|
+
* Used at register and lazily when a space is first opened.
|
|
10029
|
+
*/
|
|
10030
|
+
async ensure(memberId, seedSecret) {
|
|
10031
|
+
if (await this.rehydrate(memberId, seedSecret))
|
|
10032
|
+
return;
|
|
10033
|
+
await this.provision(memberId, seedSecret);
|
|
10034
|
+
}
|
|
10035
|
+
}
|
|
10036
|
+
|
|
9695
10037
|
/**
|
|
9696
10038
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
9697
10039
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -10919,11 +11261,38 @@ class Client {
|
|
|
10919
11261
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
10920
11262
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
10921
11263
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
10922
|
-
// round-trip. The server only ever sees
|
|
11264
|
+
// round-trip. The server only ever sees ciphertext.
|
|
11265
|
+
//
|
|
11266
|
+
// `encrypt: false` is deliberate: the keyring already ECIES-wraps each
|
|
11267
|
+
// key to the member's identity (server-blind), and StorageCipher would
|
|
11268
|
+
// additionally require the FULL unlocked identity (`requireIdentity()`),
|
|
11269
|
+
// which is locked after a page reload — that coupling is exactly why the
|
|
11270
|
+
// cache previously never read or persisted. The wrapped blobs are opaque.
|
|
10923
11271
|
const spaceKeyCache = {
|
|
10924
11272
|
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
10925
|
-
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
11273
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys, { encrypt: false }),
|
|
11274
|
+
};
|
|
11275
|
+
// SDK-owned vault for the member's long-lived ratchet/space keypair.
|
|
11276
|
+
// Stored at the personal-space `chat-keys` key (same key the app used to
|
|
11277
|
+
// manage by hand), seed-wrapped, session-token authed (no snarkjs). This
|
|
11278
|
+
// gives every app a STABLE keypair across reloads — no per-app scaffolding
|
|
11279
|
+
// — so members don't re-admit each load and the group-key cache works.
|
|
11280
|
+
const chatKeyStore = {
|
|
11281
|
+
get: async (key) => {
|
|
11282
|
+
const c = this.session.commitment;
|
|
11283
|
+
if (!c)
|
|
11284
|
+
return null;
|
|
11285
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
|
|
11286
|
+
return (res?.value ?? null);
|
|
11287
|
+
},
|
|
11288
|
+
put: async (key, value) => {
|
|
11289
|
+
const c = this.session.commitment;
|
|
11290
|
+
if (!c)
|
|
11291
|
+
throw new Error("ChatKeyVault: no session — sign in first.");
|
|
11292
|
+
await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
|
|
11293
|
+
},
|
|
10926
11294
|
};
|
|
11295
|
+
const chatKeys = new ChatKeyVault(chatKeyStore);
|
|
10927
11296
|
this.space = new SpaceNamespace({
|
|
10928
11297
|
http: this.http,
|
|
10929
11298
|
session: this.session,
|
|
@@ -10931,6 +11300,7 @@ class Client {
|
|
|
10931
11300
|
cache: spaceKeyCache,
|
|
10932
11301
|
offline: this.offline,
|
|
10933
11302
|
p2p: options.p2p,
|
|
11303
|
+
chatKeys,
|
|
10934
11304
|
});
|
|
10935
11305
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
10936
11306
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|