@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.
@@ -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
- // Peers next: ask the Space swarm before spending origin egress. Peer
3901
- // blocks are hash-verified by the block engine; on miss/timeout we fall
3902
- // through to origin. Best-effort — never let a peer error block a read.
3903
- if (this.peers) {
3904
- try {
3905
- const fromPeer = await this.peers.getBlock(hash);
3906
- if (fromPeer) {
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, fromPeer);
3909
- return fromPeer;
3941
+ void this.cache.put(hash, b);
3942
+ win(b); // peer delivered first
3910
3943
  }
3911
- }
3912
- catch {
3913
- /* peer layer hiccup — fall through to origin */
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 buf = await response.arrayBuffer();
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.9.0-alpha.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
- return this.fetchFn(input, { ...init, headers });
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
- await offline.putMessage(this.id, {
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
- else
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 (const e of entries) {
7286
- if (e.deleted || !e.packet)
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 = e.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
- // (refreshDirectory swallows its own transport errors.)
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 (const raw of body.messages ?? []) {
7360
- const decoded = await this.decodeFrame(raw, cipher);
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 with the inner sealed packet at its handle.
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(raw);
7478
+ const outer = JSON.parse(raws[i]);
7368
7479
  if (typeof outer.spaceMessage === "string") {
7369
- await this.cacheFrame(outer.spaceMessage, decoded, "msg");
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
- await this.cacheFrame(f.spaceMessage, decoded, "msg");
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
- await this.cacheFrame(e.spaceMessage, decoded, "edit");
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
- await this.deps.offline.putDeleted(this.id, handle);
7490
- await this.deps.offline.observeHandle(this.id, handle);
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
- /** Refresh the member directory (memberId → ECDSA key) from the roster. */
7941
- async refreshDirectory() {
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
- return this.keys.size > 0;
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
- const out = {};
7995
- for (const [epoch, key] of this.keys)
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
- /* ignore */
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
- return this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
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
- await store.generateOwnKeyPair(memberId);
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 the ciphertext.
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 });