@muhkoo/connect 0.9.0-alpha.0 → 0.10.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/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/server/index.js
CHANGED
|
@@ -2976,7 +2976,7 @@ function base64UrlDecode(s) {
|
|
|
2976
2976
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
2977
2977
|
* `package.json` `version`.
|
|
2978
2978
|
*/
|
|
2979
|
-
const VERSION = "0.
|
|
2979
|
+
const VERSION = "0.10.0-alpha.0";
|
|
2980
2980
|
|
|
2981
2981
|
/**
|
|
2982
2982
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -2999,6 +2999,28 @@ const VERSION = "0.9.0-alpha.0";
|
|
|
2999
2999
|
* convenience JSON helpers (`get`/`post`/`del`) for code that talks to the
|
|
3000
3000
|
* accelerator directly.
|
|
3001
3001
|
*/
|
|
3002
|
+
/** Per-request timing logs, enabled on staging only (temporary diagnostic). */
|
|
3003
|
+
const HTTP_DEBUG = (() => {
|
|
3004
|
+
try {
|
|
3005
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
3006
|
+
}
|
|
3007
|
+
catch {
|
|
3008
|
+
return false;
|
|
3009
|
+
}
|
|
3010
|
+
})();
|
|
3011
|
+
/** Short, readable request path (pathname + search) for the timing log. */
|
|
3012
|
+
function reqPath(input) {
|
|
3013
|
+
try {
|
|
3014
|
+
const u = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
3015
|
+
if (!/^https?:\/\//i.test(u))
|
|
3016
|
+
return u;
|
|
3017
|
+
const parsed = new URL(u);
|
|
3018
|
+
return parsed.pathname + parsed.search;
|
|
3019
|
+
}
|
|
3020
|
+
catch {
|
|
3021
|
+
return "?";
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3002
3024
|
/** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
|
|
3003
3025
|
class HttpError extends Error {
|
|
3004
3026
|
status;
|
|
@@ -3055,7 +3077,14 @@ class HttpClient {
|
|
|
3055
3077
|
if (token)
|
|
3056
3078
|
headers.set("X-Muhkoo-Session", token);
|
|
3057
3079
|
}
|
|
3058
|
-
|
|
3080
|
+
const finalInit = { ...init, headers };
|
|
3081
|
+
if (!HTTP_DEBUG)
|
|
3082
|
+
return this.fetchFn(input, finalInit);
|
|
3083
|
+
// Staging diagnostic: time every accelerator request (this `fetch` is the
|
|
3084
|
+
// single chokepoint — JSON helpers + the roster/shard fetches ride it).
|
|
3085
|
+
const t = performance.now();
|
|
3086
|
+
const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
|
|
3087
|
+
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; });
|
|
3059
3088
|
};
|
|
3060
3089
|
/** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
|
|
3061
3090
|
isSameOrigin(input) {
|
|
@@ -3869,6 +3898,34 @@ class ZkAuth {
|
|
|
3869
3898
|
return null;
|
|
3870
3899
|
}
|
|
3871
3900
|
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Boot helper — call this once on app start. Restores the session AND, when a
|
|
3903
|
+
* passkey is enrolled, silently unlocks the identity via WebAuthn (recovering
|
|
3904
|
+
* the master seed so the ratchet keypair rehydrates and `client.kv` works).
|
|
3905
|
+
* The seed is never persisted, so a passkey (or password) is the only way to
|
|
3906
|
+
* unlock after a reload — this keeps that off the app's plate.
|
|
3907
|
+
*
|
|
3908
|
+
* Returns `{ user, unlocked }`. When `unlocked` is false (no passkey, the
|
|
3909
|
+
* user cancelled, or the browser required a gesture), prompt for a password
|
|
3910
|
+
* and call {@link unlock}. Apps need no key-management scaffolding beyond this.
|
|
3911
|
+
*/
|
|
3912
|
+
async resume() {
|
|
3913
|
+
const user = await this.restore();
|
|
3914
|
+
if (!user)
|
|
3915
|
+
return { user: null, unlocked: false };
|
|
3916
|
+
if (this.deps.session.isUnlocked)
|
|
3917
|
+
return { user, unlocked: true };
|
|
3918
|
+
try {
|
|
3919
|
+
const factors = await this.listFactors();
|
|
3920
|
+
if (factors.some((f) => f.type === "passkey")) {
|
|
3921
|
+
await this.loginWithPasskey(user.username);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
catch {
|
|
3925
|
+
/* no passkey / cancelled / no user gesture — leave locked for a prompt */
|
|
3926
|
+
}
|
|
3927
|
+
return { user, unlocked: this.deps.session.isUnlocked };
|
|
3928
|
+
}
|
|
3872
3929
|
/**
|
|
3873
3930
|
* Re-derive identity material for an already-authenticated (restored)
|
|
3874
3931
|
* session, so the client can decrypt storage and run the ratchet. Verifies
|
|
@@ -5445,6 +5502,8 @@ class ReedSolomonCodec {
|
|
|
5445
5502
|
* skip a PUT they'd otherwise dedup-rewrite. The
|
|
5446
5503
|
* server can ignore the optimization if it wants.
|
|
5447
5504
|
*/
|
|
5505
|
+
/** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */
|
|
5506
|
+
const HEDGE_MS = 500;
|
|
5448
5507
|
/**
|
|
5449
5508
|
* Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
|
|
5450
5509
|
* every runtime this SDK targets.
|
|
@@ -5546,31 +5605,63 @@ class ShardClient {
|
|
|
5546
5605
|
if (cached)
|
|
5547
5606
|
return cached;
|
|
5548
5607
|
}
|
|
5549
|
-
//
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5608
|
+
// No peers → straight to origin.
|
|
5609
|
+
if (!this.peers)
|
|
5610
|
+
return this.fetchFromOrigin(hash);
|
|
5611
|
+
// HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
|
|
5612
|
+
// or missing peer stall delivery — give the peer a short head start, then
|
|
5613
|
+
// start origin in parallel and take whichever returns the block first.
|
|
5614
|
+
const peers = this.peers;
|
|
5615
|
+
return new Promise((resolve, reject) => {
|
|
5616
|
+
let done = false;
|
|
5617
|
+
let originStarted = false;
|
|
5618
|
+
let timer = null;
|
|
5619
|
+
const win = (b) => {
|
|
5620
|
+
if (done)
|
|
5621
|
+
return;
|
|
5622
|
+
done = true;
|
|
5623
|
+
if (timer)
|
|
5624
|
+
clearTimeout(timer);
|
|
5625
|
+
resolve(b);
|
|
5626
|
+
};
|
|
5627
|
+
const fail = (e) => {
|
|
5628
|
+
if (!done) {
|
|
5629
|
+
done = true;
|
|
5630
|
+
reject(e);
|
|
5631
|
+
}
|
|
5632
|
+
};
|
|
5633
|
+
const startOrigin = () => {
|
|
5634
|
+
if (originStarted || done)
|
|
5635
|
+
return;
|
|
5636
|
+
originStarted = true;
|
|
5637
|
+
if (timer) {
|
|
5638
|
+
clearTimeout(timer);
|
|
5639
|
+
timer = null;
|
|
5640
|
+
}
|
|
5641
|
+
this.fetchFromOrigin(hash).then(win, fail);
|
|
5642
|
+
};
|
|
5643
|
+
timer = setTimeout(startOrigin, HEDGE_MS);
|
|
5644
|
+
peers.getBlock(hash).then((b) => {
|
|
5645
|
+
if (b) {
|
|
5556
5646
|
if (this.cache)
|
|
5557
|
-
void this.cache.put(hash,
|
|
5558
|
-
|
|
5647
|
+
void this.cache.put(hash, b);
|
|
5648
|
+
win(b); // peer delivered first
|
|
5559
5649
|
}
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
}
|
|
5564
|
-
}
|
|
5650
|
+
else {
|
|
5651
|
+
startOrigin(); // peer miss → don't wait out the hedge
|
|
5652
|
+
}
|
|
5653
|
+
}, () => startOrigin());
|
|
5654
|
+
});
|
|
5655
|
+
}
|
|
5656
|
+
/** Fetch a shard from origin. Returns `null` for 404 (or offline when a
|
|
5657
|
+
* cache is present, so RS recovery can proceed); throws otherwise. */
|
|
5658
|
+
async fetchFromOrigin(hash) {
|
|
5565
5659
|
const url = this.urlForHash(hash);
|
|
5566
5660
|
let response;
|
|
5567
5661
|
try {
|
|
5568
5662
|
response = await this.fetchFn(url, { method: "GET" });
|
|
5569
5663
|
}
|
|
5570
5664
|
catch (err) {
|
|
5571
|
-
// Offline with no cache hit: report the shard as missing so
|
|
5572
|
-
// FileStorage's Reed–Solomon recovery can still reconstruct from the
|
|
5573
|
-
// shards that ARE cached. Rethrow when there's no cache to fall back on.
|
|
5574
5665
|
if (this.cache)
|
|
5575
5666
|
return null;
|
|
5576
5667
|
throw err;
|
|
@@ -5581,8 +5672,7 @@ class ShardClient {
|
|
|
5581
5672
|
const text = await safeText(response);
|
|
5582
5673
|
throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
|
|
5583
5674
|
}
|
|
5584
|
-
const
|
|
5585
|
-
const bytes = new Uint8Array(buf);
|
|
5675
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
5586
5676
|
if (this.cache)
|
|
5587
5677
|
void this.cache.put(hash, bytes);
|
|
5588
5678
|
return bytes;
|
|
@@ -6936,18 +7026,23 @@ class Space {
|
|
|
6936
7026
|
const hlc = await offline.nextHlc();
|
|
6937
7027
|
const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
|
|
6938
7028
|
const frame = { spaceMessage: packet };
|
|
6939
|
-
|
|
7029
|
+
const entry = {
|
|
6940
7030
|
handle: provisionalSpaceKey(clientId),
|
|
6941
7031
|
packet,
|
|
6942
7032
|
op: "msg",
|
|
6943
7033
|
clientId,
|
|
6944
7034
|
hlc,
|
|
6945
7035
|
pending: true,
|
|
6946
|
-
}
|
|
6947
|
-
if (this.isConnected())
|
|
6948
|
-
this.sendRaw(frame);
|
|
6949
|
-
|
|
7036
|
+
};
|
|
7037
|
+
if (this.isConnected()) {
|
|
7038
|
+
this.sendRaw(frame); // send first — don't block the wire on IndexedDB
|
|
7039
|
+
void offline.putMessage(this.id, entry); // optimistic cache off the hot path
|
|
7040
|
+
}
|
|
7041
|
+
else {
|
|
7042
|
+
// Offline: persist the optimistic entry + durable replay queue.
|
|
7043
|
+
void offline.putMessage(this.id, entry);
|
|
6950
7044
|
await offline.enqueueFrame(this.id, frame, clientId, hlc);
|
|
7045
|
+
}
|
|
6951
7046
|
}
|
|
6952
7047
|
/** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
|
|
6953
7048
|
onMessage(handler) {
|
|
@@ -7011,18 +7106,14 @@ class Space {
|
|
|
7011
7106
|
const offline = this.deps.offline;
|
|
7012
7107
|
if (!offline?.enabled || !this.cipher)
|
|
7013
7108
|
return [];
|
|
7014
|
-
const entries = await offline.loadMessages(this.id);
|
|
7109
|
+
const entries = (await offline.loadMessages(this.id)).filter((e) => !e.deleted && e.packet);
|
|
7110
|
+
// Decode in parallel (already verified when cached → skipVerify).
|
|
7111
|
+
const decodedAll = await Promise.all(entries.map((e) => this.decodeFrame(e.packet, this.cipher, /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle), /*skipVerify*/ true)));
|
|
7015
7112
|
const out = [];
|
|
7016
|
-
for (
|
|
7017
|
-
|
|
7018
|
-
continue;
|
|
7019
|
-
// Already verified when first cached → skip re-verification (which
|
|
7020
|
-
// would also fail offline without a warm member directory).
|
|
7021
|
-
const decoded = await this.decodeFrame(e.packet, this.cipher,
|
|
7022
|
-
/*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
|
|
7023
|
-
/*skipVerify*/ true);
|
|
7113
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7114
|
+
const decoded = decodedAll[i];
|
|
7024
7115
|
if (decoded) {
|
|
7025
|
-
decoded.pending =
|
|
7116
|
+
decoded.pending = entries[i].pending;
|
|
7026
7117
|
out.push(decoded);
|
|
7027
7118
|
}
|
|
7028
7119
|
}
|
|
@@ -7079,25 +7170,45 @@ class Space {
|
|
|
7079
7170
|
params.set("limit", String(opts.limit));
|
|
7080
7171
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7081
7172
|
(params.toString() ? `?${params}` : "");
|
|
7173
|
+
// Temporary staging diagnostic: split fetch / directory / decode time.
|
|
7174
|
+
const dbg = (() => { try {
|
|
7175
|
+
return globalThis.location?.hostname?.includes("staging");
|
|
7176
|
+
}
|
|
7177
|
+
catch {
|
|
7178
|
+
return false;
|
|
7179
|
+
} })();
|
|
7180
|
+
const mark = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
|
|
7181
|
+
const t0 = mark();
|
|
7082
7182
|
const res = await this.deps.fetch(url);
|
|
7083
7183
|
if (!res.ok)
|
|
7084
7184
|
throw new Error(`Space.history: ${res.status} ${res.statusText}`);
|
|
7085
7185
|
const body = (await res.json());
|
|
7186
|
+
const tFetch = mark();
|
|
7086
7187
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7087
|
-
//
|
|
7088
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7188
|
+
// Force past the cooldown — a history load is an explicit "get fresh".
|
|
7189
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7190
|
+
const tDir = mark();
|
|
7191
|
+
// Decode the whole page in PARALLEL — each frame is an AES-GCM decrypt +
|
|
7192
|
+
// ECDSA verify on the WebCrypto thread; doing them sequentially (await
|
|
7193
|
+
// per message) serializes ~100 main↔crypto round-trips and dominates
|
|
7194
|
+
// load time. Promise.all preserves input order.
|
|
7195
|
+
const raws = body.messages ?? [];
|
|
7196
|
+
const decodedAll = await Promise.all(raws.map((raw) => this.decodeFrame(raw, cipher)));
|
|
7197
|
+
if (dbg) {
|
|
7198
|
+
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`);
|
|
7199
|
+
}
|
|
7089
7200
|
const messages = [];
|
|
7090
|
-
for (
|
|
7091
|
-
const decoded =
|
|
7201
|
+
for (let i = 0; i < decodedAll.length; i++) {
|
|
7202
|
+
const decoded = decodedAll[i];
|
|
7092
7203
|
if (!decoded)
|
|
7093
7204
|
continue;
|
|
7094
7205
|
messages.push(decoded);
|
|
7095
|
-
// Warm the offline cache
|
|
7206
|
+
// Warm the offline cache off the hot path (best-effort, non-blocking).
|
|
7096
7207
|
if (this.deps.offline?.enabled) {
|
|
7097
7208
|
try {
|
|
7098
|
-
const outer = JSON.parse(
|
|
7209
|
+
const outer = JSON.parse(raws[i]);
|
|
7099
7210
|
if (typeof outer.spaceMessage === "string") {
|
|
7100
|
-
|
|
7211
|
+
void this.cacheFrame(outer.spaceMessage, decoded, "msg");
|
|
7101
7212
|
}
|
|
7102
7213
|
}
|
|
7103
7214
|
catch {
|
|
@@ -7194,8 +7305,8 @@ class Space {
|
|
|
7194
7305
|
const handle = Number(f.timestamp ?? 0);
|
|
7195
7306
|
const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
|
|
7196
7307
|
if (decoded) {
|
|
7197
|
-
this.emitEvent("message", decoded);
|
|
7198
|
-
|
|
7308
|
+
this.emitEvent("message", decoded); // UI first
|
|
7309
|
+
void this.cacheFrame(f.spaceMessage, decoded, "msg"); // cache off the hot path
|
|
7199
7310
|
}
|
|
7200
7311
|
return;
|
|
7201
7312
|
}
|
|
@@ -7207,7 +7318,7 @@ class Space {
|
|
|
7207
7318
|
const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
|
|
7208
7319
|
if (decoded) {
|
|
7209
7320
|
this.emitEvent("message-edited", decoded);
|
|
7210
|
-
|
|
7321
|
+
void this.cacheFrame(e.spaceMessage, decoded, "edit");
|
|
7211
7322
|
}
|
|
7212
7323
|
}
|
|
7213
7324
|
return;
|
|
@@ -7217,8 +7328,8 @@ class Space {
|
|
|
7217
7328
|
const handle = Number(d.ts ?? 0);
|
|
7218
7329
|
this.emitEvent("message-deleted", { handle });
|
|
7219
7330
|
if (this.deps.offline?.enabled) {
|
|
7220
|
-
|
|
7221
|
-
|
|
7331
|
+
// Cache the tombstone off the hot path.
|
|
7332
|
+
void this.deps.offline.putDeleted(this.id, handle).then(() => this.deps.offline?.observeHandle(this.id, handle));
|
|
7222
7333
|
}
|
|
7223
7334
|
return;
|
|
7224
7335
|
}
|
|
@@ -7239,7 +7350,7 @@ class Space {
|
|
|
7239
7350
|
this.emitEvent("join-request", req);
|
|
7240
7351
|
// A new member appeared — refresh the directory so their future
|
|
7241
7352
|
// messages verify, and (if we hold the key) auto-admit them.
|
|
7242
|
-
void this.deps.keyring?.refreshDirectory();
|
|
7353
|
+
void this.deps.keyring?.refreshDirectory({ force: true });
|
|
7243
7354
|
if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
|
|
7244
7355
|
void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
|
|
7245
7356
|
}
|
|
@@ -7389,7 +7500,7 @@ class Space {
|
|
|
7389
7500
|
const cursor = await offline.getCursor(this.id);
|
|
7390
7501
|
let since = String(cursor?.lastSeenHandle ?? 0);
|
|
7391
7502
|
// Warm the member directory once so historical senders' signatures verify.
|
|
7392
|
-
await this.deps.keyring?.refreshDirectory();
|
|
7503
|
+
await this.deps.keyring?.refreshDirectory({ force: true });
|
|
7393
7504
|
for (let page = 0; page < 50; page++) {
|
|
7394
7505
|
const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
|
|
7395
7506
|
`?since=${encodeURIComponent(since)}&limit=100`;
|
|
@@ -7652,6 +7763,19 @@ function parseMaybeJson(text) {
|
|
|
7652
7763
|
* `KEEPER_MEMBER_ID`.
|
|
7653
7764
|
*/
|
|
7654
7765
|
const KEEPER_MEMBER_ID = "__keeper__";
|
|
7766
|
+
/** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
|
|
7767
|
+
const KEYRING_DEBUG = (() => {
|
|
7768
|
+
try {
|
|
7769
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
7770
|
+
}
|
|
7771
|
+
catch {
|
|
7772
|
+
return false;
|
|
7773
|
+
}
|
|
7774
|
+
})();
|
|
7775
|
+
function klog(msg) {
|
|
7776
|
+
if (KEYRING_DEBUG)
|
|
7777
|
+
console.info(`[muhkoo:keyring] ${msg}`);
|
|
7778
|
+
}
|
|
7655
7779
|
class SpaceKeyring {
|
|
7656
7780
|
deps;
|
|
7657
7781
|
keys = new Map();
|
|
@@ -7659,6 +7783,14 @@ class SpaceKeyring {
|
|
|
7659
7783
|
policy;
|
|
7660
7784
|
/** Cached member directory: memberId → imported ECDSA verify key. */
|
|
7661
7785
|
memberEcdsa = new Map();
|
|
7786
|
+
/** Coalesce concurrent roster refreshes (the parallel history/backlog decode
|
|
7787
|
+
* would otherwise fire one `roster` fetch PER message — a thundering herd). */
|
|
7788
|
+
dirInFlight = null;
|
|
7789
|
+
dirRefreshedAt = 0;
|
|
7790
|
+
/** Min gap between non-forced roster refreshes. A miss that persists within
|
|
7791
|
+
* this window won't re-fetch (the roster hasn't changed that fast); genuine
|
|
7792
|
+
* membership changes use `{ force: true }` (join requests / history loads). */
|
|
7793
|
+
static DIR_COOLDOWN_MS = 5000;
|
|
7662
7794
|
constructor(deps) {
|
|
7663
7795
|
this.deps = deps;
|
|
7664
7796
|
this.policy = deps.historyPolicy ?? "static";
|
|
@@ -7668,8 +7800,27 @@ class SpaceKeyring {
|
|
|
7668
7800
|
ecdsaKeyFor(memberId) {
|
|
7669
7801
|
return this.memberEcdsa.get(memberId);
|
|
7670
7802
|
}
|
|
7671
|
-
/**
|
|
7672
|
-
|
|
7803
|
+
/**
|
|
7804
|
+
* Refresh the member directory (memberId → ECDSA key) from the roster.
|
|
7805
|
+
* Coalesces concurrent callers into ONE fetch, and skips a fetch when one
|
|
7806
|
+
* completed within {@link DIR_COOLDOWN_MS} (unless `force`). Without this, a
|
|
7807
|
+
* page of N messages decoded in parallel — each missing the cold directory —
|
|
7808
|
+
* fires N simultaneous `roster` requests that queue behind the browser's
|
|
7809
|
+
* connection limit and take many seconds.
|
|
7810
|
+
*/
|
|
7811
|
+
async refreshDirectory(opts) {
|
|
7812
|
+
if (this.dirInFlight)
|
|
7813
|
+
return this.dirInFlight; // a refresh is already running
|
|
7814
|
+
if (!opts?.force && Date.now() - this.dirRefreshedAt < SpaceKeyring.DIR_COOLDOWN_MS) {
|
|
7815
|
+
return; // refreshed very recently — the roster won't have changed
|
|
7816
|
+
}
|
|
7817
|
+
this.dirInFlight = this.doRefreshDirectory().finally(() => {
|
|
7818
|
+
this.dirRefreshedAt = Date.now();
|
|
7819
|
+
this.dirInFlight = null;
|
|
7820
|
+
});
|
|
7821
|
+
return this.dirInFlight;
|
|
7822
|
+
}
|
|
7823
|
+
async doRefreshDirectory() {
|
|
7673
7824
|
let roster;
|
|
7674
7825
|
try {
|
|
7675
7826
|
roster = await this.deps.transport.fetchRoster();
|
|
@@ -7704,34 +7855,63 @@ class SpaceKeyring {
|
|
|
7704
7855
|
async loadFromCache() {
|
|
7705
7856
|
if (!this.deps.cache)
|
|
7706
7857
|
return false;
|
|
7858
|
+
// The cache entries are ECIES-wrapped to our identity key, so we unwrap
|
|
7859
|
+
// with the keyring's OWN private key — available whenever chat works,
|
|
7860
|
+
// unlike client.kv's StorageCipher which needs the full unlocked identity.
|
|
7861
|
+
const priv = this.deps.ownPrivateKey();
|
|
7862
|
+
if (!priv) {
|
|
7863
|
+
klog("loadFromCache: no private key available yet");
|
|
7864
|
+
return false;
|
|
7865
|
+
}
|
|
7707
7866
|
let cached;
|
|
7708
7867
|
try {
|
|
7709
7868
|
cached = await this.deps.cache.loadKeys(this.deps.spaceId);
|
|
7710
7869
|
}
|
|
7711
|
-
catch {
|
|
7870
|
+
catch (e) {
|
|
7871
|
+
klog(`loadFromCache: ERROR reading cache — ${e instanceof Error ? e.message : e}`);
|
|
7712
7872
|
return false;
|
|
7713
7873
|
}
|
|
7714
|
-
if (!cached)
|
|
7874
|
+
if (!cached) {
|
|
7875
|
+
klog("loadFromCache: cache empty");
|
|
7876
|
+
return false;
|
|
7877
|
+
}
|
|
7878
|
+
for (const [epochStr, serialized] of Object.entries(cached)) {
|
|
7879
|
+
try {
|
|
7880
|
+
const blob = JSON.parse(serialized);
|
|
7881
|
+
this.keys.set(Number(epochStr), await unwrapSpaceKey(blob, priv));
|
|
7882
|
+
}
|
|
7883
|
+
catch {
|
|
7884
|
+
/* stale / old-format entry — skip; we'll re-admit and re-persist */
|
|
7885
|
+
}
|
|
7886
|
+
}
|
|
7887
|
+
if (this.keys.size === 0) {
|
|
7888
|
+
klog("loadFromCache: no usable entries (stale format → will re-admit)");
|
|
7715
7889
|
return false;
|
|
7716
|
-
for (const [epochStr, b64] of Object.entries(cached)) {
|
|
7717
|
-
this.keys.set(Number(epochStr), fromBase64$1(b64));
|
|
7718
7890
|
}
|
|
7719
7891
|
this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
|
|
7720
|
-
|
|
7892
|
+
klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
|
|
7893
|
+
return true;
|
|
7721
7894
|
}
|
|
7722
7895
|
async persist() {
|
|
7723
|
-
if (!this.deps.cache)
|
|
7896
|
+
if (!this.deps.cache || this.keys.size === 0)
|
|
7724
7897
|
return;
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
out[String(epoch)] = toBase64$1(key);
|
|
7728
|
-
// Best-effort: caching is an optimization; a write failure (e.g. locked
|
|
7729
|
-
// storage) must not break key acquisition.
|
|
7898
|
+
// Best-effort: caching is an optimization; a write failure must not break
|
|
7899
|
+
// key acquisition.
|
|
7730
7900
|
try {
|
|
7901
|
+
// Wrap each epoch key to our OWN identity key (ECIES-to-self). The
|
|
7902
|
+
// cache stays server-blind (the kv layer stores it with encrypt:false
|
|
7903
|
+
// — it's already encrypted) AND readable with the keyring private key
|
|
7904
|
+
// alone, so it works while the full identity is locked (post-reload).
|
|
7905
|
+
const selfPub = await importEcdhPublicKey(this.deps.identityEcdhPub);
|
|
7906
|
+
const out = {};
|
|
7907
|
+
for (const [epoch, key] of this.keys) {
|
|
7908
|
+
out[String(epoch)] = JSON.stringify(await wrapSpaceKey(key, epoch, selfPub));
|
|
7909
|
+
}
|
|
7731
7910
|
await this.deps.cache.saveKeys(this.deps.spaceId, out);
|
|
7911
|
+
klog(`persist: saved ${Object.keys(out).length} epoch(s)`);
|
|
7732
7912
|
}
|
|
7733
|
-
catch {
|
|
7734
|
-
|
|
7913
|
+
catch (e) {
|
|
7914
|
+
klog(`persist: FAILED — ${e instanceof Error ? e.message : e}`);
|
|
7735
7915
|
}
|
|
7736
7916
|
}
|
|
7737
7917
|
/** Creator path: mint epoch-0 group key for a brand-new space. */
|
|
@@ -7780,8 +7960,24 @@ class SpaceKeyring {
|
|
|
7780
7960
|
this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
|
|
7781
7961
|
await this.persist();
|
|
7782
7962
|
}
|
|
7963
|
+
klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
|
|
7783
7964
|
return added;
|
|
7784
7965
|
}
|
|
7966
|
+
/**
|
|
7967
|
+
* Background freshness check for the cache-hit fast path: confirm we hold the
|
|
7968
|
+
* current epoch (picks up a rotation / our removal) WITHOUT the full newcomer
|
|
7969
|
+
* admit handshake. Cheap + idempotent; never blocks first paint. Returns the
|
|
7970
|
+
* number of newly-obtained epoch keys (0 = already current).
|
|
7971
|
+
*/
|
|
7972
|
+
async reconcile() {
|
|
7973
|
+
await this.refreshDirectory({ force: true });
|
|
7974
|
+
try {
|
|
7975
|
+
return await this.pullKeys();
|
|
7976
|
+
}
|
|
7977
|
+
catch {
|
|
7978
|
+
return 0; // best-effort — the live keyringReady frame is the backstop
|
|
7979
|
+
}
|
|
7980
|
+
}
|
|
7785
7981
|
// -- key-holder: admit + rotate -------------------------------------------
|
|
7786
7982
|
/**
|
|
7787
7983
|
* Wrap our group key(s) for a newcomer and post the blobs. In a `static`
|
|
@@ -8181,14 +8377,22 @@ class ShardCache {
|
|
|
8181
8377
|
class PeerExchange {
|
|
8182
8378
|
engine;
|
|
8183
8379
|
timeoutMs;
|
|
8380
|
+
debug;
|
|
8184
8381
|
constructor(engine, opts = {}) {
|
|
8185
8382
|
this.engine = engine;
|
|
8186
8383
|
this.timeoutMs = opts.timeoutMs ?? 2500;
|
|
8384
|
+
this.debug = opts.debug ?? false;
|
|
8187
8385
|
}
|
|
8188
|
-
getBlock(hash, opts) {
|
|
8189
|
-
|
|
8386
|
+
async getBlock(hash, opts) {
|
|
8387
|
+
const bytes = await this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
|
|
8388
|
+
if (this.debug) {
|
|
8389
|
+
console.info(`%c[muhkoo:p2p] ${bytes ? "✓ peer-served" : "✗ miss → origin"} shard ${hash.slice(0, 10)}…`, `color:${bytes ? "#58da7d" : "#888"}`);
|
|
8390
|
+
}
|
|
8391
|
+
return bytes;
|
|
8190
8392
|
}
|
|
8191
8393
|
announce(hash) {
|
|
8394
|
+
if (this.debug)
|
|
8395
|
+
console.info(`[muhkoo:p2p] announce shard ${hash.slice(0, 10)}…`);
|
|
8192
8396
|
this.engine.announce(hash);
|
|
8193
8397
|
}
|
|
8194
8398
|
}
|
|
@@ -8876,7 +9080,11 @@ class PeerNetwork {
|
|
|
8876
9080
|
this.signaler.send(from, "hello", { broadcast: false });
|
|
8877
9081
|
}
|
|
8878
9082
|
}));
|
|
8879
|
-
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs });
|
|
9083
|
+
this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs, debug: opts.debug });
|
|
9084
|
+
if (opts.debug) {
|
|
9085
|
+
console.info("[muhkoo:p2p] mesh attached for space; awaiting peers…");
|
|
9086
|
+
this.offs.push(this.transport.onPeerChange((peers) => console.info(`%c[muhkoo:p2p] connected peers: ${peers.length}`, "color:#58da7d", peers)));
|
|
9087
|
+
}
|
|
8880
9088
|
}
|
|
8881
9089
|
/**
|
|
8882
9090
|
* Begin peer discovery — broadcast a hello so existing members dial us and
|
|
@@ -9174,6 +9382,7 @@ class SpaceNamespace {
|
|
|
9174
9382
|
workerFactory: this.deps.p2p.workerFactory,
|
|
9175
9383
|
iceServers: this.deps.p2p.iceServers,
|
|
9176
9384
|
maxPeers: this.deps.p2p.maxPeers,
|
|
9385
|
+
debug: this.deps.p2p.debug,
|
|
9177
9386
|
}));
|
|
9178
9387
|
}
|
|
9179
9388
|
return space;
|
|
@@ -9182,7 +9391,34 @@ class SpaceNamespace {
|
|
|
9182
9391
|
async ensureIdentity(memberId) {
|
|
9183
9392
|
const store = KeyStore.getInstance();
|
|
9184
9393
|
if (!store.getKeyPair(memberId)) {
|
|
9185
|
-
|
|
9394
|
+
// Unlocked (seed in hand): rehydrate the member's STABLE keypair from
|
|
9395
|
+
// the vault, or provision + persist one on first use. This keeps the
|
|
9396
|
+
// member the same identity across reloads — no re-admit each session,
|
|
9397
|
+
// and the group-key cache round-trips. Best-effort: a personal-space
|
|
9398
|
+
// hiccup falls through to an ephemeral keypair (today's behavior).
|
|
9399
|
+
const seed = this.deps.session.seed;
|
|
9400
|
+
const staging = (() => { try {
|
|
9401
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9402
|
+
}
|
|
9403
|
+
catch {
|
|
9404
|
+
return false;
|
|
9405
|
+
} })();
|
|
9406
|
+
if (staging)
|
|
9407
|
+
console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
|
|
9408
|
+
if (this.deps.chatKeys && seed) {
|
|
9409
|
+
try {
|
|
9410
|
+
await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
|
|
9411
|
+
}
|
|
9412
|
+
catch (e) {
|
|
9413
|
+
if (staging)
|
|
9414
|
+
console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
|
|
9415
|
+
}
|
|
9416
|
+
}
|
|
9417
|
+
if (!store.getKeyPair(memberId)) {
|
|
9418
|
+
if (staging)
|
|
9419
|
+
console.info("[muhkoo:vault] ensureIdentity: EPHEMERAL keypair (locked / no vault) → will re-admit");
|
|
9420
|
+
await store.generateOwnKeyPair(memberId);
|
|
9421
|
+
}
|
|
9186
9422
|
}
|
|
9187
9423
|
const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
|
|
9188
9424
|
const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
|
|
@@ -9423,6 +9659,112 @@ class FunctionsNamespace {
|
|
|
9423
9659
|
}
|
|
9424
9660
|
}
|
|
9425
9661
|
|
|
9662
|
+
/**
|
|
9663
|
+
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
9664
|
+
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
9665
|
+
*
|
|
9666
|
+
* Historically each app hand-rolled this (see the web app's old
|
|
9667
|
+
* `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
|
|
9668
|
+
* PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
|
|
9669
|
+
* That scaffolding now lives here so ANY app gets a **stable** member keypair
|
|
9670
|
+
* across reloads for free — without which the member re-admits to every space on
|
|
9671
|
+
* every load and the group-key cache can never round-trip.
|
|
9672
|
+
*
|
|
9673
|
+
* Security invariant: the wrapping secret is the **master seed** (not the
|
|
9674
|
+
* password), so the blob survives password changes and unlocks under a passkey.
|
|
9675
|
+
* The seed is only ever held in memory (never persisted), so this vault can only
|
|
9676
|
+
* provision/rehydrate while the client is unlocked.
|
|
9677
|
+
*/
|
|
9678
|
+
/** Staging-only diagnostics: is the member keypair stable across reloads? */
|
|
9679
|
+
const VAULT_DEBUG = (() => {
|
|
9680
|
+
try {
|
|
9681
|
+
return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
|
|
9682
|
+
}
|
|
9683
|
+
catch {
|
|
9684
|
+
return false;
|
|
9685
|
+
}
|
|
9686
|
+
})();
|
|
9687
|
+
function vlog(msg) {
|
|
9688
|
+
if (VAULT_DEBUG)
|
|
9689
|
+
console.info(`[muhkoo:vault] ${msg}`);
|
|
9690
|
+
}
|
|
9691
|
+
/** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
|
|
9692
|
+
async function fingerprint(memberId) {
|
|
9693
|
+
try {
|
|
9694
|
+
const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
|
|
9695
|
+
if (!raw)
|
|
9696
|
+
return "none";
|
|
9697
|
+
let s = 0;
|
|
9698
|
+
for (const b of raw)
|
|
9699
|
+
s = (s * 31 + b) >>> 0;
|
|
9700
|
+
return s.toString(16).padStart(8, "0");
|
|
9701
|
+
}
|
|
9702
|
+
catch {
|
|
9703
|
+
return "err";
|
|
9704
|
+
}
|
|
9705
|
+
}
|
|
9706
|
+
/** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
|
|
9707
|
+
const CHAT_KEYS_KEY = "chat-keys";
|
|
9708
|
+
class ChatKeyVault {
|
|
9709
|
+
store;
|
|
9710
|
+
constructor(store) {
|
|
9711
|
+
this.store = store;
|
|
9712
|
+
}
|
|
9713
|
+
/**
|
|
9714
|
+
* Register-time: ensure a keypair exists for `memberId`, seed-wrap the
|
|
9715
|
+
* dehydrated form, and persist it to the personal space. Idempotent — reuses
|
|
9716
|
+
* an existing in-tab keypair rather than minting a second one.
|
|
9717
|
+
*/
|
|
9718
|
+
async provision(memberId, seedSecret) {
|
|
9719
|
+
const ks = KeyStore.getInstance();
|
|
9720
|
+
if (!ks.getKeyPair(memberId)) {
|
|
9721
|
+
try {
|
|
9722
|
+
await ks.generateOwnKeyPair(memberId);
|
|
9723
|
+
}
|
|
9724
|
+
catch (e) {
|
|
9725
|
+
if (!String(e?.message ?? e).includes("already exists"))
|
|
9726
|
+
throw e;
|
|
9727
|
+
}
|
|
9728
|
+
}
|
|
9729
|
+
const dehydrated = await ks.dehydrateKeyPair(memberId);
|
|
9730
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
|
|
9731
|
+
await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
|
|
9732
|
+
vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
|
|
9733
|
+
}
|
|
9734
|
+
/**
|
|
9735
|
+
* Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
|
|
9736
|
+
* hydrate the `KeyStore` — restoring the SAME keypair the member had before.
|
|
9737
|
+
* Returns `true` when a keypair is now in hand (already present or hydrated),
|
|
9738
|
+
* `false` when there's no blob yet (a brand-new member → caller provisions).
|
|
9739
|
+
* Throws only on a genuine unwrap failure (wrong secret / tampered blob).
|
|
9740
|
+
*/
|
|
9741
|
+
async rehydrate(memberId, seedSecret) {
|
|
9742
|
+
// Already unlocked in this tab — nothing to do.
|
|
9743
|
+
if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
|
|
9744
|
+
return true;
|
|
9745
|
+
const wrapped = await this.store.get(CHAT_KEYS_KEY);
|
|
9746
|
+
if (!wrapped) {
|
|
9747
|
+
vlog("rehydrate: no chat-keys blob (new member → will provision)");
|
|
9748
|
+
return false;
|
|
9749
|
+
}
|
|
9750
|
+
const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
|
|
9751
|
+
const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
|
|
9752
|
+
await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
|
|
9753
|
+
vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
|
|
9754
|
+
return true;
|
|
9755
|
+
}
|
|
9756
|
+
/**
|
|
9757
|
+
* Ensure a stable keypair is in hand: rehydrate from the vault if a blob
|
|
9758
|
+
* exists, otherwise mint one and persist it (so it's stable next session).
|
|
9759
|
+
* Used at register and lazily when a space is first opened.
|
|
9760
|
+
*/
|
|
9761
|
+
async ensure(memberId, seedSecret) {
|
|
9762
|
+
if (await this.rehydrate(memberId, seedSecret))
|
|
9763
|
+
return;
|
|
9764
|
+
await this.provision(memberId, seedSecret);
|
|
9765
|
+
}
|
|
9766
|
+
}
|
|
9767
|
+
|
|
9426
9768
|
/**
|
|
9427
9769
|
* `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
|
|
9428
9770
|
* lexicographically-sortable string so the whole offline layer (and the
|
|
@@ -10650,11 +10992,38 @@ class Client {
|
|
|
10650
10992
|
this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
|
|
10651
10993
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
10652
10994
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
10653
|
-
// round-trip. The server only ever sees
|
|
10995
|
+
// round-trip. The server only ever sees ciphertext.
|
|
10996
|
+
//
|
|
10997
|
+
// `encrypt: false` is deliberate: the keyring already ECIES-wraps each
|
|
10998
|
+
// key to the member's identity (server-blind), and StorageCipher would
|
|
10999
|
+
// additionally require the FULL unlocked identity (`requireIdentity()`),
|
|
11000
|
+
// which is locked after a page reload — that coupling is exactly why the
|
|
11001
|
+
// cache previously never read or persisted. The wrapped blobs are opaque.
|
|
10654
11002
|
const spaceKeyCache = {
|
|
10655
11003
|
loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
|
|
10656
|
-
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
|
|
11004
|
+
saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys, { encrypt: false }),
|
|
11005
|
+
};
|
|
11006
|
+
// SDK-owned vault for the member's long-lived ratchet/space keypair.
|
|
11007
|
+
// Stored at the personal-space `chat-keys` key (same key the app used to
|
|
11008
|
+
// manage by hand), seed-wrapped, session-token authed (no snarkjs). This
|
|
11009
|
+
// gives every app a STABLE keypair across reloads — no per-app scaffolding
|
|
11010
|
+
// — so members don't re-admit each load and the group-key cache works.
|
|
11011
|
+
const chatKeyStore = {
|
|
11012
|
+
get: async (key) => {
|
|
11013
|
+
const c = this.session.commitment;
|
|
11014
|
+
if (!c)
|
|
11015
|
+
return null;
|
|
11016
|
+
const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
|
|
11017
|
+
return (res?.value ?? null);
|
|
11018
|
+
},
|
|
11019
|
+
put: async (key, value) => {
|
|
11020
|
+
const c = this.session.commitment;
|
|
11021
|
+
if (!c)
|
|
11022
|
+
throw new Error("ChatKeyVault: no session — sign in first.");
|
|
11023
|
+
await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
|
|
11024
|
+
},
|
|
10657
11025
|
};
|
|
11026
|
+
const chatKeys = new ChatKeyVault(chatKeyStore);
|
|
10658
11027
|
this.space = new SpaceNamespace({
|
|
10659
11028
|
http: this.http,
|
|
10660
11029
|
session: this.session,
|
|
@@ -10662,6 +11031,7 @@ class Client {
|
|
|
10662
11031
|
cache: spaceKeyCache,
|
|
10663
11032
|
offline: this.offline,
|
|
10664
11033
|
p2p: options.p2p,
|
|
11034
|
+
chatKeys,
|
|
10665
11035
|
});
|
|
10666
11036
|
this.agents = new AgentsNamespace({ http: this.http });
|
|
10667
11037
|
this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
|