@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.
@@ -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.9.0-alpha.0";
2979
+ const VERSION = "0.10.1-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
- return this.fetchFn(input, { ...init, headers });
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
- // Peers next: ask the Space swarm before spending origin egress. Peer
5550
- // blocks are hash-verified by the block engine; on miss/timeout we fall
5551
- // through to origin. Best-effort — never let a peer error block a read.
5552
- if (this.peers) {
5553
- try {
5554
- const fromPeer = await this.peers.getBlock(hash);
5555
- if (fromPeer) {
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, fromPeer);
5558
- return fromPeer;
5647
+ void this.cache.put(hash, b);
5648
+ win(b); // peer delivered first
5559
5649
  }
5560
- }
5561
- catch {
5562
- /* peer layer hiccup — fall through to origin */
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 buf = await response.arrayBuffer();
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
- await offline.putMessage(this.id, {
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
- else
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 (const e of entries) {
7017
- if (e.deleted || !e.packet)
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 = e.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
- // (refreshDirectory swallows its own transport errors.)
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 (const raw of body.messages ?? []) {
7091
- const decoded = await this.decodeFrame(raw, cipher);
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 with the inner sealed packet at its handle.
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(raw);
7209
+ const outer = JSON.parse(raws[i]);
7099
7210
  if (typeof outer.spaceMessage === "string") {
7100
- await this.cacheFrame(outer.spaceMessage, decoded, "msg");
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
- await this.cacheFrame(f.spaceMessage, decoded, "msg");
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
- await this.cacheFrame(e.spaceMessage, decoded, "edit");
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
- await this.deps.offline.putDeleted(this.id, handle);
7221
- await this.deps.offline.observeHandle(this.id, handle);
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
- /** Refresh the member directory (memberId → ECDSA key) from the roster. */
7672
- async refreshDirectory() {
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
- return this.keys.size > 0;
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
- const out = {};
7726
- for (const [epoch, key] of this.keys)
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
- /* ignore */
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
- return this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
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
- await store.generateOwnKeyPair(memberId);
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 the ciphertext.
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 });