@muhkoo/connect 0.8.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.
@@ -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.8.0-alpha.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.8.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.
@@ -5463,6 +5522,7 @@ class ShardClient {
5463
5522
  fetchFn;
5464
5523
  cache;
5465
5524
  deferUpload;
5525
+ peers;
5466
5526
  constructor(opts) {
5467
5527
  if (!opts?.baseUrl)
5468
5528
  throw new Error("ShardClient: `baseUrl` is required");
@@ -5480,6 +5540,7 @@ class ShardClient {
5480
5540
  this.fetchFn = f.bind(globalThis);
5481
5541
  this.cache = opts.cache;
5482
5542
  this.deferUpload = opts.deferUpload;
5543
+ this.peers = opts.peers;
5483
5544
  }
5484
5545
  /** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
5485
5546
  urlForHash(hash) {
@@ -5498,6 +5559,9 @@ class ShardClient {
5498
5559
  // this is safe to keep regardless of upload outcome.
5499
5560
  if (this.cache)
5500
5561
  await this.cache.put(hash, bytes);
5562
+ // Tell the swarm we now hold this block, so peers can pull it from us.
5563
+ if (this.peers)
5564
+ this.peers.announce(hash);
5501
5565
  let response;
5502
5566
  try {
5503
5567
  response = await this.fetchFn(url, {
@@ -5541,15 +5605,63 @@ class ShardClient {
5541
5605
  if (cached)
5542
5606
  return cached;
5543
5607
  }
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) {
5646
+ if (this.cache)
5647
+ void this.cache.put(hash, b);
5648
+ win(b); // peer delivered first
5649
+ }
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) {
5544
5659
  const url = this.urlForHash(hash);
5545
5660
  let response;
5546
5661
  try {
5547
5662
  response = await this.fetchFn(url, { method: "GET" });
5548
5663
  }
5549
5664
  catch (err) {
5550
- // Offline with no cache hit: report the shard as missing so
5551
- // FileStorage's Reed–Solomon recovery can still reconstruct from the
5552
- // shards that ARE cached. Rethrow when there's no cache to fall back on.
5553
5665
  if (this.cache)
5554
5666
  return null;
5555
5667
  throw err;
@@ -5560,8 +5672,7 @@ class ShardClient {
5560
5672
  const text = await safeText(response);
5561
5673
  throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
5562
5674
  }
5563
- const buf = await response.arrayBuffer();
5564
- const bytes = new Uint8Array(buf);
5675
+ const bytes = new Uint8Array(await response.arrayBuffer());
5565
5676
  if (this.cache)
5566
5677
  void this.cache.put(hash, bytes);
5567
5678
  return bytes;
@@ -6768,6 +6879,8 @@ class Space {
6768
6879
  events = new EventTarget();
6769
6880
  rawWired = false;
6770
6881
  catchUpUnsub = null;
6882
+ /** Optional P2P swarm for this Space (block exchange among members). */
6883
+ peerNet = null;
6771
6884
  constructor(deps) {
6772
6885
  this.deps = deps;
6773
6886
  this.name = deps.name;
@@ -6816,6 +6929,9 @@ class Space {
6816
6929
  if (this.deps.keyring) {
6817
6930
  this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
6818
6931
  this.channel?.sendRaw({ name: this.deps.myId() });
6932
+ // Kick P2P peer discovery now the signaling channel is live
6933
+ // (re-runs on each reconnect to re-form the mesh).
6934
+ this.peerNet?.start();
6819
6935
  });
6820
6936
  this.wireFanout();
6821
6937
  // Register offline catch-up once: on each reconnect the sync
@@ -6831,6 +6947,27 @@ class Space {
6831
6947
  this.channel?.disconnect();
6832
6948
  this.catchUpUnsub?.();
6833
6949
  this.catchUpUnsub = null;
6950
+ this.peerNet?.close();
6951
+ this.peerNet = null;
6952
+ }
6953
+ /** Attach a P2P swarm so this Space's file reads/writes can use peers. */
6954
+ attachPeerNetwork(net) {
6955
+ this.peerNet = net;
6956
+ }
6957
+ /**
6958
+ * Broadcast an app payload directly to connected Space peers over P2P,
6959
+ * **bypassing the server** — for CRDT ops / high-frequency state (live
6960
+ * cursors, presence, collaborative edits) you don't want to push through the
6961
+ * relay. No-op when no P2P mesh is active (requires `client` `p2p`).
6962
+ * Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
6963
+ * is peer-direct and fragments large payloads.
6964
+ */
6965
+ gossipToPeers(data) {
6966
+ this.peerNet?.gossip(data);
6967
+ }
6968
+ /** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
6969
+ onPeerGossip(handler) {
6970
+ return this.peerNet?.onGossip(handler) ?? (() => { });
6834
6971
  }
6835
6972
  /** Subscribe to a raw channel event. Buffered until `connect()` if early. */
6836
6973
  on(event, handler) {
@@ -6889,18 +7026,23 @@ class Space {
6889
7026
  const hlc = await offline.nextHlc();
6890
7027
  const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
6891
7028
  const frame = { spaceMessage: packet };
6892
- await offline.putMessage(this.id, {
7029
+ const entry = {
6893
7030
  handle: provisionalSpaceKey(clientId),
6894
7031
  packet,
6895
7032
  op: "msg",
6896
7033
  clientId,
6897
7034
  hlc,
6898
7035
  pending: true,
6899
- });
6900
- if (this.isConnected())
6901
- this.sendRaw(frame);
6902
- 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);
6903
7044
  await offline.enqueueFrame(this.id, frame, clientId, hlc);
7045
+ }
6904
7046
  }
6905
7047
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
6906
7048
  onMessage(handler) {
@@ -6964,18 +7106,14 @@ class Space {
6964
7106
  const offline = this.deps.offline;
6965
7107
  if (!offline?.enabled || !this.cipher)
6966
7108
  return [];
6967
- 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)));
6968
7112
  const out = [];
6969
- for (const e of entries) {
6970
- if (e.deleted || !e.packet)
6971
- continue;
6972
- // Already verified when first cached → skip re-verification (which
6973
- // would also fail offline without a warm member directory).
6974
- const decoded = await this.decodeFrame(e.packet, this.cipher,
6975
- /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
6976
- /*skipVerify*/ true);
7113
+ for (let i = 0; i < decodedAll.length; i++) {
7114
+ const decoded = decodedAll[i];
6977
7115
  if (decoded) {
6978
- decoded.pending = e.pending;
7116
+ decoded.pending = entries[i].pending;
6979
7117
  out.push(decoded);
6980
7118
  }
6981
7119
  }
@@ -7000,10 +7138,12 @@ class Space {
7000
7138
  * indicators, presence, or live cursors are built on top of this. No-op if
7001
7139
  * the space isn't connected (ephemeral signals aren't worth queuing).
7002
7140
  */
7003
- sendEphemeral(subject, data) {
7141
+ sendEphemeral(subject, data, to) {
7004
7142
  if (!this.isConnected())
7005
7143
  return;
7006
- this.sendRaw({ pub: { subject, data } });
7144
+ // `to` (a member id) lets the server unicast a *directed* ephemeral
7145
+ // instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
7146
+ this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
7007
7147
  }
7008
7148
  /** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
7009
7149
  onEphemeral(handler) {
@@ -7030,25 +7170,45 @@ class Space {
7030
7170
  params.set("limit", String(opts.limit));
7031
7171
  const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
7032
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();
7033
7182
  const res = await this.deps.fetch(url);
7034
7183
  if (!res.ok)
7035
7184
  throw new Error(`Space.history: ${res.status} ${res.statusText}`);
7036
7185
  const body = (await res.json());
7186
+ const tFetch = mark();
7037
7187
  // Warm the member directory once so historical senders' signatures verify.
7038
- // (refreshDirectory swallows its own transport errors.)
7039
- 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
+ }
7040
7200
  const messages = [];
7041
- for (const raw of body.messages ?? []) {
7042
- const decoded = await this.decodeFrame(raw, cipher);
7201
+ for (let i = 0; i < decodedAll.length; i++) {
7202
+ const decoded = decodedAll[i];
7043
7203
  if (!decoded)
7044
7204
  continue;
7045
7205
  messages.push(decoded);
7046
- // 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).
7047
7207
  if (this.deps.offline?.enabled) {
7048
7208
  try {
7049
- const outer = JSON.parse(raw);
7209
+ const outer = JSON.parse(raws[i]);
7050
7210
  if (typeof outer.spaceMessage === "string") {
7051
- await this.cacheFrame(outer.spaceMessage, decoded, "msg");
7211
+ void this.cacheFrame(outer.spaceMessage, decoded, "msg");
7052
7212
  }
7053
7213
  }
7054
7214
  catch {
@@ -7123,6 +7283,7 @@ class Space {
7123
7283
  fetch: this.deps.fetch,
7124
7284
  cache: this.deps.shardCache,
7125
7285
  deferUpload: this.deps.deferShardUpload,
7286
+ peers: this.peerNet?.exchange,
7126
7287
  });
7127
7288
  return new FileStorage({ shards });
7128
7289
  }
@@ -7144,8 +7305,8 @@ class Space {
7144
7305
  const handle = Number(f.timestamp ?? 0);
7145
7306
  const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
7146
7307
  if (decoded) {
7147
- this.emitEvent("message", decoded);
7148
- 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
7149
7310
  }
7150
7311
  return;
7151
7312
  }
@@ -7157,7 +7318,7 @@ class Space {
7157
7318
  const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
7158
7319
  if (decoded) {
7159
7320
  this.emitEvent("message-edited", decoded);
7160
- await this.cacheFrame(e.spaceMessage, decoded, "edit");
7321
+ void this.cacheFrame(e.spaceMessage, decoded, "edit");
7161
7322
  }
7162
7323
  }
7163
7324
  return;
@@ -7167,8 +7328,8 @@ class Space {
7167
7328
  const handle = Number(d.ts ?? 0);
7168
7329
  this.emitEvent("message-deleted", { handle });
7169
7330
  if (this.deps.offline?.enabled) {
7170
- await this.deps.offline.putDeleted(this.id, handle);
7171
- 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));
7172
7333
  }
7173
7334
  return;
7174
7335
  }
@@ -7189,7 +7350,7 @@ class Space {
7189
7350
  this.emitEvent("join-request", req);
7190
7351
  // A new member appeared — refresh the directory so their future
7191
7352
  // messages verify, and (if we hold the key) auto-admit them.
7192
- void this.deps.keyring?.refreshDirectory();
7353
+ void this.deps.keyring?.refreshDirectory({ force: true });
7193
7354
  if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
7194
7355
  void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
7195
7356
  }
@@ -7339,7 +7500,7 @@ class Space {
7339
7500
  const cursor = await offline.getCursor(this.id);
7340
7501
  let since = String(cursor?.lastSeenHandle ?? 0);
7341
7502
  // Warm the member directory once so historical senders' signatures verify.
7342
- await this.deps.keyring?.refreshDirectory();
7503
+ await this.deps.keyring?.refreshDirectory({ force: true });
7343
7504
  for (let page = 0; page < 50; page++) {
7344
7505
  const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
7345
7506
  `?since=${encodeURIComponent(since)}&limit=100`;
@@ -7602,6 +7763,19 @@ function parseMaybeJson(text) {
7602
7763
  * `KEEPER_MEMBER_ID`.
7603
7764
  */
7604
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
+ }
7605
7779
  class SpaceKeyring {
7606
7780
  deps;
7607
7781
  keys = new Map();
@@ -7609,6 +7783,14 @@ class SpaceKeyring {
7609
7783
  policy;
7610
7784
  /** Cached member directory: memberId → imported ECDSA verify key. */
7611
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;
7612
7794
  constructor(deps) {
7613
7795
  this.deps = deps;
7614
7796
  this.policy = deps.historyPolicy ?? "static";
@@ -7618,8 +7800,27 @@ class SpaceKeyring {
7618
7800
  ecdsaKeyFor(memberId) {
7619
7801
  return this.memberEcdsa.get(memberId);
7620
7802
  }
7621
- /** Refresh the member directory (memberId → ECDSA key) from the roster. */
7622
- 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() {
7623
7824
  let roster;
7624
7825
  try {
7625
7826
  roster = await this.deps.transport.fetchRoster();
@@ -7654,34 +7855,63 @@ class SpaceKeyring {
7654
7855
  async loadFromCache() {
7655
7856
  if (!this.deps.cache)
7656
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
+ }
7657
7866
  let cached;
7658
7867
  try {
7659
7868
  cached = await this.deps.cache.loadKeys(this.deps.spaceId);
7660
7869
  }
7661
- catch {
7870
+ catch (e) {
7871
+ klog(`loadFromCache: ERROR reading cache — ${e instanceof Error ? e.message : e}`);
7662
7872
  return false;
7663
7873
  }
7664
- 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)");
7665
7889
  return false;
7666
- for (const [epochStr, b64] of Object.entries(cached)) {
7667
- this.keys.set(Number(epochStr), fromBase64$1(b64));
7668
7890
  }
7669
7891
  this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
7670
- return this.keys.size > 0;
7892
+ klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
7893
+ return true;
7671
7894
  }
7672
7895
  async persist() {
7673
- if (!this.deps.cache)
7896
+ if (!this.deps.cache || this.keys.size === 0)
7674
7897
  return;
7675
- const out = {};
7676
- for (const [epoch, key] of this.keys)
7677
- out[String(epoch)] = toBase64$1(key);
7678
- // Best-effort: caching is an optimization; a write failure (e.g. locked
7679
- // storage) must not break key acquisition.
7898
+ // Best-effort: caching is an optimization; a write failure must not break
7899
+ // key acquisition.
7680
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
+ }
7681
7910
  await this.deps.cache.saveKeys(this.deps.spaceId, out);
7911
+ klog(`persist: saved ${Object.keys(out).length} epoch(s)`);
7682
7912
  }
7683
- catch {
7684
- /* ignore */
7913
+ catch (e) {
7914
+ klog(`persist: FAILED — ${e instanceof Error ? e.message : e}`);
7685
7915
  }
7686
7916
  }
7687
7917
  /** Creator path: mint epoch-0 group key for a brand-new space. */
@@ -7730,8 +7960,24 @@ class SpaceKeyring {
7730
7960
  this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
7731
7961
  await this.persist();
7732
7962
  }
7963
+ klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
7733
7964
  return added;
7734
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
+ }
7735
7981
  // -- key-holder: admit + rotate -------------------------------------------
7736
7982
  /**
7737
7983
  * Wrap our group key(s) for a newcomer and post the blobs. In a `static`
@@ -8041,6 +8287,861 @@ class SpaceCache {
8041
8287
  }
8042
8288
  }
8043
8289
 
8290
+ /**
8291
+ * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
8292
+ * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
8293
+ * is uniquely safe: a cache hit is self-validating, immutable, and shareable
8294
+ * across files. The Cache API (rather than IndexedDB) is the right home for
8295
+ * large binary blobs — it streams, it's purpose-built for response bodies, and
8296
+ * it has its own eviction.
8297
+ *
8298
+ * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
8299
+ * than the real shard endpoint, so the cache is addressable by hash alone — the
8300
+ * sync engine can re-PUT a deferred shard knowing only its hash, without
8301
+ * reconstructing the accelerator URL. We never fetch these synthetic URLs; they
8302
+ * exist only as cache keys.
8303
+ *
8304
+ * Every method degrades to a no-op / null where the Cache API is unavailable
8305
+ * (Node, Workers, insecure contexts), so callers don't need to branch.
8306
+ */
8307
+ const CACHE_NAME = "muhkoo-shards-v1";
8308
+ class ShardCache {
8309
+ static available() {
8310
+ try {
8311
+ return typeof caches !== "undefined";
8312
+ }
8313
+ catch {
8314
+ return false;
8315
+ }
8316
+ }
8317
+ key(hash) {
8318
+ return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
8319
+ }
8320
+ open() {
8321
+ return caches.open(CACHE_NAME);
8322
+ }
8323
+ /** Cached shard bytes, or `null` on miss / when unavailable. */
8324
+ async get(hash) {
8325
+ if (!ShardCache.available())
8326
+ return null;
8327
+ try {
8328
+ const cache = await this.open();
8329
+ const res = await cache.match(this.key(hash));
8330
+ if (!res)
8331
+ return null;
8332
+ return new Uint8Array(await res.arrayBuffer());
8333
+ }
8334
+ catch {
8335
+ return null;
8336
+ }
8337
+ }
8338
+ /** Store shard bytes under their hash. Best-effort. */
8339
+ async put(hash, bytes) {
8340
+ if (!ShardCache.available())
8341
+ return;
8342
+ try {
8343
+ const cache = await this.open();
8344
+ await cache.put(this.key(hash), new Response(bytes, {
8345
+ headers: { "content-type": "application/octet-stream" },
8346
+ }));
8347
+ }
8348
+ catch {
8349
+ /* quota / unavailable — degrade to online-only for this shard */
8350
+ }
8351
+ }
8352
+ /** Whether a shard is present in the cache. */
8353
+ async has(hash) {
8354
+ if (!ShardCache.available())
8355
+ return false;
8356
+ try {
8357
+ const cache = await this.open();
8358
+ return Boolean(await cache.match(this.key(hash)));
8359
+ }
8360
+ catch {
8361
+ return false;
8362
+ }
8363
+ }
8364
+ }
8365
+
8366
+ /**
8367
+ * `PeerExchange` — the thin façade the storage layer consults to try fetching a
8368
+ * block from peers before falling back to origin. It satisfies the structural
8369
+ * `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
8370
+ * declares (so storage never imports the p2p module — the dependency arrow stays
8371
+ * one-way, p2p → storage).
8372
+ *
8373
+ * It delegates to an {@link EngineHandle} — either an in-process {@link
8374
+ * ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
8375
+ * works whether the block engine runs on the main thread or off it.
8376
+ */
8377
+ class PeerExchange {
8378
+ engine;
8379
+ timeoutMs;
8380
+ debug;
8381
+ constructor(engine, opts = {}) {
8382
+ this.engine = engine;
8383
+ this.timeoutMs = opts.timeoutMs ?? 2500;
8384
+ this.debug = opts.debug ?? false;
8385
+ }
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;
8392
+ }
8393
+ announce(hash) {
8394
+ if (this.debug)
8395
+ console.info(`[muhkoo:p2p] announce shard ${hash.slice(0, 10)}…`);
8396
+ this.engine.announce(hash);
8397
+ }
8398
+ }
8399
+
8400
+ /**
8401
+ * `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
8402
+ * relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
8403
+ * blind-relay `pub` frames stamped with the authenticated sender, so SDP
8404
+ * offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
8405
+ * to Space membership. The sender (`from`) is server-stamped and can't be
8406
+ * spoofed; the payload is opaque to the server.
8407
+ *
8408
+ * Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
8409
+ * accelerator can optionally unicast peer-pair signaling instead of broadcasting
8410
+ * (a small, backward-compatible `pub` enhancement); the client filters by `to`
8411
+ * regardless.
8412
+ */
8413
+ const P2P_SUBJECT = "__p2p__";
8414
+ class SpaceSignaler {
8415
+ space;
8416
+ myId;
8417
+ constructor(space, myId) {
8418
+ this.space = space;
8419
+ this.myId = myId;
8420
+ }
8421
+ /** Send a signal to one peer (or `"*"`). */
8422
+ send(to, kind, data) {
8423
+ const env = { to, kind, data };
8424
+ // Ask the server to unicast for a directed target; broadcast for "*".
8425
+ // `to` also stays in the envelope so a broadcast-only server still works
8426
+ // (the client filters by it).
8427
+ this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
8428
+ }
8429
+ /** Announce/withdraw P2P availability to all Space members. */
8430
+ broadcast(kind, data) {
8431
+ this.send("*", kind, data);
8432
+ }
8433
+ /**
8434
+ * Subscribe to inbound signals addressed to us (or broadcast). Skips our own
8435
+ * echoes. Returns an unsubscribe function.
8436
+ */
8437
+ onSignal(cb) {
8438
+ return this.space.onEphemeral((e) => {
8439
+ if (e.subject !== P2P_SUBJECT || e.from === this.myId)
8440
+ return;
8441
+ const env = e.data;
8442
+ if (!env || typeof env.kind !== "string")
8443
+ return;
8444
+ if (env.to !== this.myId && env.to !== "*")
8445
+ return;
8446
+ cb(e.from, env.kind, env.data);
8447
+ });
8448
+ }
8449
+ }
8450
+
8451
+ /**
8452
+ * `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
8453
+ * signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
8454
+ * (`RTCPeerConnection` is Window-only — not available in Workers), so the block
8455
+ * engine lives in a Worker and this only shuttles bytes.
8456
+ *
8457
+ * Connection setup uses a deterministic initiator (the peer with the smaller id
8458
+ * dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
8459
+ * TURN — an unreachable peer simply never connects and the caller falls back to
8460
+ * origin (P2P is best-effort).
8461
+ *
8462
+ * Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
8463
+ * each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
8464
+ * `[msgId u32][index u32][total u32]` and reassembled per peer.
8465
+ */
8466
+ const CHUNK = 16 * 1024;
8467
+ // [msgId u32][index u32][total u32][channel u32]
8468
+ const HEADER$1 = 16;
8469
+ /** Pause sends when a channel has this many bytes buffered (backpressure). */
8470
+ const MAX_BUFFERED = 4 * 1024 * 1024;
8471
+ const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
8472
+ class WebRtcTransport {
8473
+ signaler;
8474
+ myId;
8475
+ conns = new Map();
8476
+ msgCbs = new Set();
8477
+ peerCbs = new Set();
8478
+ offSignal;
8479
+ iceServers;
8480
+ maxPeers;
8481
+ nextMsgId = 1;
8482
+ constructor(signaler, myId, opts = {}) {
8483
+ this.signaler = signaler;
8484
+ this.myId = myId;
8485
+ this.iceServers = opts.iceServers ?? DEFAULT_ICE;
8486
+ this.maxPeers = opts.maxPeers ?? 8;
8487
+ this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
8488
+ }
8489
+ peers() {
8490
+ return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
8491
+ }
8492
+ /** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
8493
+ connectTo(peerId) {
8494
+ if (peerId === this.myId || this.conns.has(peerId))
8495
+ return;
8496
+ if (this.conns.size >= this.maxPeers)
8497
+ return; // mesh cap
8498
+ if (this.myId < peerId)
8499
+ void this.initiate(peerId); // smaller id dials
8500
+ }
8501
+ send(peer, data, channel = 0) {
8502
+ const dc = this.conns.get(peer)?.dc;
8503
+ if (dc?.readyState === "open")
8504
+ void this.sendFragmented(dc, data, channel);
8505
+ }
8506
+ broadcast(data, channel = 0) {
8507
+ for (const [, c] of this.conns) {
8508
+ if (c.dc?.readyState === "open")
8509
+ void this.sendFragmented(c.dc, data, channel);
8510
+ }
8511
+ }
8512
+ onMessage(cb) {
8513
+ this.msgCbs.add(cb);
8514
+ return () => this.msgCbs.delete(cb);
8515
+ }
8516
+ onPeerChange(cb) {
8517
+ this.peerCbs.add(cb);
8518
+ return () => this.peerCbs.delete(cb);
8519
+ }
8520
+ close() {
8521
+ this.offSignal();
8522
+ for (const [, c] of this.conns) {
8523
+ try {
8524
+ c.dc?.close();
8525
+ }
8526
+ catch { /* already closing */ }
8527
+ try {
8528
+ c.pc.close();
8529
+ }
8530
+ catch { /* already closing */ }
8531
+ }
8532
+ this.conns.clear();
8533
+ this.msgCbs.clear();
8534
+ this.peerCbs.clear();
8535
+ }
8536
+ // -- internals -----------------------------------------------------------
8537
+ newPc(peerId) {
8538
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
8539
+ const conn = { pc, reasm: new Map() };
8540
+ this.conns.set(peerId, conn);
8541
+ pc.onicecandidate = (e) => {
8542
+ if (e.candidate)
8543
+ this.signaler.send(peerId, "ice", e.candidate.toJSON());
8544
+ };
8545
+ pc.onconnectionstatechange = () => {
8546
+ if (["failed", "closed", "disconnected"].includes(pc.connectionState))
8547
+ this.drop(peerId);
8548
+ };
8549
+ pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
8550
+ return conn;
8551
+ }
8552
+ async initiate(peerId) {
8553
+ const conn = this.newPc(peerId);
8554
+ const dc = conn.pc.createDataChannel("blocks");
8555
+ this.bindChannel(peerId, conn, dc);
8556
+ const offer = await conn.pc.createOffer();
8557
+ await conn.pc.setLocalDescription(offer);
8558
+ this.signaler.send(peerId, "offer", offer);
8559
+ }
8560
+ bindChannel(peerId, conn, dc) {
8561
+ dc.binaryType = "arraybuffer";
8562
+ conn.dc = dc;
8563
+ dc.onopen = () => this.firePeerChange();
8564
+ dc.onclose = () => this.drop(peerId);
8565
+ dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
8566
+ }
8567
+ async onSignal(from, kind, data) {
8568
+ if (kind === "offer") {
8569
+ if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
8570
+ return; // mesh cap
8571
+ const conn = this.conns.get(from) ?? this.newPc(from);
8572
+ await conn.pc.setRemoteDescription(data);
8573
+ const answer = await conn.pc.createAnswer();
8574
+ await conn.pc.setLocalDescription(answer);
8575
+ this.signaler.send(from, "answer", answer);
8576
+ }
8577
+ else if (kind === "answer") {
8578
+ const conn = this.conns.get(from);
8579
+ if (conn)
8580
+ await conn.pc.setRemoteDescription(data);
8581
+ }
8582
+ else if (kind === "ice") {
8583
+ const conn = this.conns.get(from);
8584
+ if (conn && data) {
8585
+ try {
8586
+ await conn.pc.addIceCandidate(data);
8587
+ }
8588
+ catch { /* pre-SRD race */ }
8589
+ }
8590
+ }
8591
+ else if (kind === "bye") {
8592
+ this.drop(from);
8593
+ }
8594
+ }
8595
+ drop(peerId) {
8596
+ const conn = this.conns.get(peerId);
8597
+ if (!conn)
8598
+ return;
8599
+ this.conns.delete(peerId);
8600
+ try {
8601
+ conn.dc?.close();
8602
+ }
8603
+ catch { /* already closing */ }
8604
+ try {
8605
+ conn.pc.close();
8606
+ }
8607
+ catch { /* already closing */ }
8608
+ this.firePeerChange();
8609
+ }
8610
+ firePeerChange() {
8611
+ const peers = this.peers();
8612
+ for (const cb of this.peerCbs)
8613
+ cb(peers);
8614
+ }
8615
+ // -- fragmentation -------------------------------------------------------
8616
+ async sendFragmented(dc, data, channel) {
8617
+ const msgId = this.nextMsgId++ >>> 0;
8618
+ const total = Math.max(1, Math.ceil(data.length / CHUNK));
8619
+ for (let i = 0; i < total; i++) {
8620
+ const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
8621
+ const frame = new Uint8Array(HEADER$1 + slice.length);
8622
+ const dv = new DataView(frame.buffer);
8623
+ dv.setUint32(0, msgId);
8624
+ dv.setUint32(4, i);
8625
+ dv.setUint32(8, total);
8626
+ dv.setUint32(12, channel);
8627
+ frame.set(slice, HEADER$1);
8628
+ if (dc.bufferedAmount > MAX_BUFFERED)
8629
+ await this.drain(dc);
8630
+ dc.send(frame);
8631
+ }
8632
+ }
8633
+ drain(dc) {
8634
+ return new Promise((resolve) => {
8635
+ dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
8636
+ const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
8637
+ dc.addEventListener("bufferedamountlow", onLow);
8638
+ });
8639
+ }
8640
+ onChunk(peerId, conn, frame) {
8641
+ if (frame.length < HEADER$1)
8642
+ return;
8643
+ const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
8644
+ const msgId = dv.getUint32(0);
8645
+ const index = dv.getUint32(4);
8646
+ const total = dv.getUint32(8);
8647
+ const channel = dv.getUint32(12);
8648
+ const chunk = frame.slice(HEADER$1);
8649
+ let r = conn.reasm.get(msgId);
8650
+ if (!r) {
8651
+ r = { chunks: new Array(total), received: 0, total, size: 0, channel };
8652
+ conn.reasm.set(msgId, r);
8653
+ }
8654
+ if (r.chunks[index])
8655
+ return; // dup
8656
+ r.chunks[index] = chunk;
8657
+ r.received++;
8658
+ r.size += chunk.length;
8659
+ if (r.received < r.total)
8660
+ return;
8661
+ conn.reasm.delete(msgId);
8662
+ const out = new Uint8Array(r.size);
8663
+ let off = 0;
8664
+ for (const c of r.chunks) {
8665
+ if (!c)
8666
+ return; // gap — shouldn't happen on reliable ordered
8667
+ out.set(c, off);
8668
+ off += c.length;
8669
+ }
8670
+ for (const cb of this.msgCbs)
8671
+ cb(peerId, out, r.channel);
8672
+ }
8673
+ }
8674
+
8675
+ /**
8676
+ * `PeerTransport` — the pluggable wire under the block engine. It moves opaque
8677
+ * frames between Space peers; it knows nothing about blocks, hashes, or the
8678
+ * protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
8679
+ * a future libp2p/Helia transport can implement the same interface without the
8680
+ * block engine or `ShardClient` changing.
8681
+ *
8682
+ * Implementations are responsible for fragmenting/ reassembling frames larger
8683
+ * than the underlying channel's max message size (a 4 MiB shard won't fit in one
8684
+ * `RTCDataChannel.send`); the engine always deals in whole frames.
8685
+ */
8686
+ /**
8687
+ * Logical channels multiplexed over the one data link. `0` is block exchange
8688
+ * (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
8689
+ * so payloads aren't copied to prepend it.
8690
+ */
8691
+ const CHANNEL_BLOCK = 0;
8692
+ const CHANNEL_GOSSIP = 1;
8693
+
8694
+ /**
8695
+ * Bitswap-lite wire protocol for peer block exchange. Four frame types over a
8696
+ * {@link ../transport/PeerTransport} data channel:
8697
+ *
8698
+ * WANT(hash) — "does anyone have this block?" / "send it to me"
8699
+ * HAVE(hash) — "I hold this block" (announce / reply to a WANT)
8700
+ * BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
8701
+ * CANCEL(hash) — "stop, I got it elsewhere"
8702
+ *
8703
+ * Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
8704
+ *
8705
+ * byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
8706
+ * bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
8707
+ * bytes 33.. : payload (BLOCK only)
8708
+ *
8709
+ * Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
8710
+ * is the capability check (the receiver re-hashes the payload and drops a
8711
+ * mismatch). See {@link ./blockEngine}.
8712
+ */
8713
+ var FrameType;
8714
+ (function (FrameType) {
8715
+ FrameType[FrameType["WANT"] = 0] = "WANT";
8716
+ FrameType[FrameType["HAVE"] = 1] = "HAVE";
8717
+ FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
8718
+ FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
8719
+ })(FrameType || (FrameType = {}));
8720
+ const HASH_BYTES = 32;
8721
+ const HEADER = 1 + HASH_BYTES; // type + hash
8722
+ function hexToBytes(hex) {
8723
+ if (hex.length !== HASH_BYTES * 2) {
8724
+ throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
8725
+ }
8726
+ const out = new Uint8Array(HASH_BYTES);
8727
+ for (let i = 0; i < HASH_BYTES; i++)
8728
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
8729
+ return out;
8730
+ }
8731
+ const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
8732
+ function bytesToHex(bytes) {
8733
+ let s = "";
8734
+ for (let i = 0; i < bytes.length; i++)
8735
+ s += HEX[bytes[i]];
8736
+ return s;
8737
+ }
8738
+ function header(type, hash, extra = 0) {
8739
+ const buf = new Uint8Array(HEADER + extra);
8740
+ buf[0] = type;
8741
+ buf.set(hexToBytes(hash), 1);
8742
+ return { buf, offset: HEADER };
8743
+ }
8744
+ function encodeWant(hash) {
8745
+ return header(FrameType.WANT, hash).buf;
8746
+ }
8747
+ function encodeHave(hash) {
8748
+ return header(FrameType.HAVE, hash).buf;
8749
+ }
8750
+ function encodeCancel(hash) {
8751
+ return header(FrameType.CANCEL, hash).buf;
8752
+ }
8753
+ function encodeBlock(hash, payload) {
8754
+ const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
8755
+ buf.set(payload, offset);
8756
+ return buf;
8757
+ }
8758
+ function decode(frame) {
8759
+ if (frame.length < HEADER)
8760
+ throw new Error("protocol: frame too short");
8761
+ const type = frame[0];
8762
+ const hash = bytesToHex(frame.subarray(1, HEADER));
8763
+ if (type === FrameType.BLOCK) {
8764
+ // Copy the payload out so it doesn't alias a transferred/pooled buffer.
8765
+ return { type, hash, payload: frame.slice(HEADER) };
8766
+ }
8767
+ return { type, hash };
8768
+ }
8769
+
8770
+ var protocol = /*#__PURE__*/Object.freeze({
8771
+ __proto__: null,
8772
+ get FrameType () { return FrameType; },
8773
+ decode: decode,
8774
+ encodeBlock: encodeBlock,
8775
+ encodeCancel: encodeCancel,
8776
+ encodeHave: encodeHave,
8777
+ encodeWant: encodeWant
8778
+ });
8779
+
8780
+ /**
8781
+ * `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
8782
+ * only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
8783
+ * encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
8784
+ * `handleFrame` to a real transport — on the main thread or inside the Web
8785
+ * Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
8786
+ *
8787
+ * It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
8788
+ * one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
8789
+ * mismatch, so a malicious peer can't poison the store (the requester just
8790
+ * falls back to origin).
8791
+ */
8792
+ class BlockEngine {
8793
+ store;
8794
+ out;
8795
+ hash;
8796
+ wants = new Map();
8797
+ constructor(store, out, hash) {
8798
+ this.store = store;
8799
+ this.out = out;
8800
+ this.hash = hash;
8801
+ }
8802
+ /**
8803
+ * Ask the swarm for a block. Resolves with the verified bytes, or `null` on
8804
+ * timeout (caller then falls back to origin). Returns immediately if we
8805
+ * already hold it; concurrent wants for the same hash share one request.
8806
+ */
8807
+ want(hash, timeoutMs) {
8808
+ return new Promise((resolve) => {
8809
+ void this.store.get(hash).then((local) => {
8810
+ if (local)
8811
+ return resolve(local);
8812
+ const existing = this.wants.get(hash);
8813
+ if (existing) {
8814
+ existing.resolvers.push(resolve);
8815
+ return;
8816
+ }
8817
+ const timer = setTimeout(() => {
8818
+ const w = this.wants.get(hash);
8819
+ this.wants.delete(hash);
8820
+ this.out("*", encodeCancel(hash));
8821
+ w?.resolvers.forEach((r) => r(null));
8822
+ }, timeoutMs);
8823
+ this.wants.set(hash, { resolvers: [resolve], timer });
8824
+ this.out("*", encodeWant(hash));
8825
+ });
8826
+ });
8827
+ }
8828
+ /** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
8829
+ announce(hash) {
8830
+ this.out("*", encodeHave(hash));
8831
+ }
8832
+ /** Handle one inbound frame from `peer`. */
8833
+ async handleFrame(peer, frame) {
8834
+ let f;
8835
+ try {
8836
+ f = decode(frame);
8837
+ }
8838
+ catch {
8839
+ return; // malformed — ignore
8840
+ }
8841
+ switch (f.type) {
8842
+ case FrameType.WANT: {
8843
+ const bytes = await this.store.get(f.hash);
8844
+ if (bytes)
8845
+ this.out(peer, encodeBlock(f.hash, bytes));
8846
+ return;
8847
+ }
8848
+ case FrameType.HAVE: {
8849
+ // A peer announced a block we're still waiting for → pull from them.
8850
+ if (this.wants.has(f.hash))
8851
+ this.out(peer, encodeWant(f.hash));
8852
+ return;
8853
+ }
8854
+ case FrameType.BLOCK: {
8855
+ const pending = this.wants.get(f.hash);
8856
+ if (!pending || !f.payload)
8857
+ return; // unsolicited / empty — ignore
8858
+ // Integrity: a peer can't poison the store. Drop a mismatch and
8859
+ // keep waiting (the timeout will fall back to origin).
8860
+ const got = await this.hash(f.payload);
8861
+ if (got !== f.hash)
8862
+ return;
8863
+ await this.store.put(f.hash, f.payload);
8864
+ this.wants.delete(f.hash);
8865
+ clearTimeout(pending.timer);
8866
+ this.out("*", encodeCancel(f.hash)); // others can stop sending
8867
+ pending.resolvers.forEach((r) => r(f.payload));
8868
+ return;
8869
+ }
8870
+ case FrameType.CANCEL:
8871
+ // We serve WANTs synchronously, so there's nothing in flight to
8872
+ // cancel yet. Reserved for chunked-send cancellation later.
8873
+ return;
8874
+ }
8875
+ }
8876
+ /** Drop all pending waits (resolve null) — on teardown. */
8877
+ close() {
8878
+ for (const [, w] of this.wants) {
8879
+ clearTimeout(w.timer);
8880
+ w.resolvers.forEach((r) => r(null));
8881
+ }
8882
+ this.wants.clear();
8883
+ }
8884
+ }
8885
+
8886
+ /**
8887
+ * `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
8888
+ * abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
8889
+ * - {@link LocalEngineHost} runs it on the calling thread (tests, or a
8890
+ * fallback when the Web Worker can't be built by a consumer's bundler);
8891
+ * - the Worker proxy ({@link ./engineClient}) runs it off-thread.
8892
+ *
8893
+ * Either way the host exposes the same surface: drive `want`/`announce`, feed it
8894
+ * inbound frames via `handleFrame`, and receive its outbound frames via
8895
+ * `onOutbound` (which the network forwards to the transport).
8896
+ */
8897
+ /** Runs the engine in-process (main thread or test). */
8898
+ class LocalEngineHost {
8899
+ engine;
8900
+ cbs = new Set();
8901
+ constructor(store, hasher) {
8902
+ this.engine = new BlockEngine(store, (target, frame) => {
8903
+ for (const cb of this.cbs)
8904
+ cb(target, frame);
8905
+ }, hasher);
8906
+ }
8907
+ want(hash, timeoutMs) {
8908
+ return this.engine.want(hash, timeoutMs);
8909
+ }
8910
+ announce(hash) {
8911
+ this.engine.announce(hash);
8912
+ }
8913
+ handleFrame(peer, frame) {
8914
+ void this.engine.handleFrame(peer, frame);
8915
+ }
8916
+ onOutbound(cb) {
8917
+ this.cbs.add(cb);
8918
+ return () => this.cbs.delete(cb);
8919
+ }
8920
+ close() {
8921
+ this.engine.close();
8922
+ this.cbs.clear();
8923
+ }
8924
+ }
8925
+
8926
+ /**
8927
+ * `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
8928
+ * running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
8929
+ * protocol codec stay off the main thread). It speaks the small message protocol
8930
+ * in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
8931
+ * so {@link ../PeerNetwork} treats it identically to the in-process host.
8932
+ *
8933
+ * Block bytes cross the worker boundary as **transferable** ArrayBuffers
8934
+ * (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
8935
+ */
8936
+ class WorkerEngineHost {
8937
+ worker;
8938
+ wants = new Map();
8939
+ cbs = new Set();
8940
+ nextId = 1;
8941
+ constructor(worker) {
8942
+ this.worker = worker;
8943
+ this.worker.onmessage = (e) => {
8944
+ const m = e.data;
8945
+ if (m.t === "out") {
8946
+ const frame = new Uint8Array(m.data);
8947
+ for (const cb of this.cbs)
8948
+ cb(m.target, frame);
8949
+ }
8950
+ else if (m.t === "want-result") {
8951
+ const resolve = this.wants.get(m.id);
8952
+ if (resolve) {
8953
+ this.wants.delete(m.id);
8954
+ resolve(m.data ? new Uint8Array(m.data) : null);
8955
+ }
8956
+ }
8957
+ };
8958
+ }
8959
+ want(hash, timeoutMs) {
8960
+ return new Promise((resolve) => {
8961
+ const id = this.nextId++;
8962
+ this.wants.set(id, resolve);
8963
+ this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
8964
+ });
8965
+ }
8966
+ announce(hash) {
8967
+ this.worker.postMessage({ t: "announce", hash });
8968
+ }
8969
+ handleFrame(peer, frame) {
8970
+ // Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
8971
+ const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
8972
+ ? frame.buffer
8973
+ : frame.slice().buffer;
8974
+ this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
8975
+ }
8976
+ onOutbound(cb) {
8977
+ this.cbs.add(cb);
8978
+ return () => this.cbs.delete(cb);
8979
+ }
8980
+ close() {
8981
+ try {
8982
+ this.worker.postMessage({ t: "close" });
8983
+ }
8984
+ catch { /* already gone */ }
8985
+ this.worker.terminate();
8986
+ this.wants.forEach((r) => r(null));
8987
+ this.wants.clear();
8988
+ this.cbs.clear();
8989
+ }
8990
+ }
8991
+
8992
+ /**
8993
+ * `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
8994
+ * the main thread:
8995
+ *
8996
+ * transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
8997
+ * ▲ │
8998
+ * └────────────── signaling over the Space relay ─────────────┘
8999
+ *
9000
+ * It owns one {@link ./transport/WebRtcTransport} + one {@link
9001
+ * ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
9002
+ * engine's outbound frames back to the transport, runs peer discovery over the
9003
+ * Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
9004
+ * for `ShardClient` to consult. Scope is one Space (private swarm).
9005
+ *
9006
+ * `engineHost` defaults to in-process; pass `workerFactory` to run the block
9007
+ * engine off the main thread (the consumer's bundler resolves the worker URL).
9008
+ */
9009
+ /**
9010
+ * Build the engine host, preferring an off-thread Web Worker:
9011
+ * 1. an explicit `workerFactory` (most reliable in app bundlers), else
9012
+ * 2. the bundled worker resolved relative to this module (works where the
9013
+ * bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
9014
+ * 3. the in-process engine (always works; tests + non-worker runtimes).
9015
+ * Any worker construction failure degrades to in-process.
9016
+ */
9017
+ function buildEngineHost(opts) {
9018
+ try {
9019
+ const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
9020
+ if (worker)
9021
+ return new WorkerEngineHost(worker);
9022
+ }
9023
+ catch {
9024
+ /* no Worker global / bundler can't resolve it → in-process */
9025
+ }
9026
+ return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
9027
+ }
9028
+ /** Construct the bundled worker if the runtime + bundler support it, else null. */
9029
+ function defaultWorker() {
9030
+ try {
9031
+ if (typeof Worker === "undefined")
9032
+ return null;
9033
+ // The string is resolved by the consumer's bundler relative to the
9034
+ // built connect module (sibling of dist/browser/index.js).
9035
+ return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
9036
+ }
9037
+ catch {
9038
+ return null;
9039
+ }
9040
+ }
9041
+ class PeerNetwork {
9042
+ /** Pass this to a `ShardClient` as `peers`. */
9043
+ exchange;
9044
+ transport;
9045
+ host;
9046
+ signaler;
9047
+ offs = [];
9048
+ gossipCbs = new Set();
9049
+ closed = false;
9050
+ constructor(opts) {
9051
+ this.signaler = new SpaceSignaler(opts.space, opts.myId);
9052
+ this.transport =
9053
+ opts.transport ??
9054
+ new WebRtcTransport(this.signaler, opts.myId, {
9055
+ iceServers: opts.iceServers,
9056
+ maxPeers: opts.maxPeers,
9057
+ });
9058
+ this.host = buildEngineHost(opts);
9059
+ // Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
9060
+ this.offs.push(this.transport.onMessage((peer, data, channel) => {
9061
+ if (channel === CHANNEL_GOSSIP) {
9062
+ for (const cb of this.gossipCbs)
9063
+ cb(peer, data);
9064
+ }
9065
+ else {
9066
+ this.host.handleFrame(peer, data);
9067
+ }
9068
+ }));
9069
+ this.offs.push(this.host.onOutbound((target, frame) => target === "*"
9070
+ ? this.transport.broadcast(frame, CHANNEL_BLOCK)
9071
+ : this.transport.send(target, frame, CHANNEL_BLOCK)));
9072
+ // Discovery: a broadcast hello triggers a unicast hello back (so a
9073
+ // newcomer learns existing members); both sides connectTo (the smaller
9074
+ // id actually dials). Deterministic + idempotent.
9075
+ this.offs.push(this.signaler.onSignal((from, kind, data) => {
9076
+ if (kind !== "hello")
9077
+ return;
9078
+ this.transport.connectTo?.(from);
9079
+ if (data?.broadcast) {
9080
+ this.signaler.send(from, "hello", { broadcast: false });
9081
+ }
9082
+ }));
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
+ }
9088
+ }
9089
+ /**
9090
+ * Begin peer discovery — broadcast a hello so existing members dial us and
9091
+ * we learn them. Call once the signaling channel is live (the Space fires
9092
+ * this on CONNECTED); safe to call again on reconnect.
9093
+ */
9094
+ start() {
9095
+ if (this.closed)
9096
+ return;
9097
+ this.signaler.broadcast("hello", { broadcast: true });
9098
+ }
9099
+ /**
9100
+ * Broadcast an app payload to all connected peers over the gossip channel —
9101
+ * a direct, server-bypassing data path for CRDT ops / high-frequency state
9102
+ * (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
9103
+ */
9104
+ gossip(data) {
9105
+ this.transport.broadcast(data, CHANNEL_GOSSIP);
9106
+ }
9107
+ /** Subscribe to gossip from peers. Returns an unsubscribe function. */
9108
+ onGossip(cb) {
9109
+ this.gossipCbs.add(cb);
9110
+ return () => this.gossipCbs.delete(cb);
9111
+ }
9112
+ /** Connected peer count (open data channels). */
9113
+ peerCount() {
9114
+ return this.transport.peers().length;
9115
+ }
9116
+ close() {
9117
+ if (this.closed)
9118
+ return;
9119
+ this.closed = true;
9120
+ try {
9121
+ this.signaler.broadcast("bye");
9122
+ }
9123
+ catch { /* socket may be gone */ }
9124
+ for (const off of this.offs)
9125
+ off();
9126
+ this.transport.close();
9127
+ this.host.close();
9128
+ }
9129
+ }
9130
+
9131
+ /**
9132
+ * Whether this environment can do WebRTC peering. `RTCPeerConnection` is
9133
+ * Window-only (absent in Node/Workers/SSR), so this gates whether a
9134
+ * {@link ./PeerNetwork} can be built at all.
9135
+ */
9136
+ function isP2pCapable() {
9137
+ try {
9138
+ return typeof RTCPeerConnection !== "undefined";
9139
+ }
9140
+ catch {
9141
+ return false;
9142
+ }
9143
+ }
9144
+
8044
9145
  /**
8045
9146
  * `client.space` — the fan-out group-encryption surface.
8046
9147
  *
@@ -8272,13 +9373,52 @@ class SpaceNamespace {
8272
9373
  : undefined,
8273
9374
  });
8274
9375
  this.openSpaces.set(spaceId, space);
9376
+ // Attach a private P2P swarm scoped to this Space (block exchange among
9377
+ // members; signaled over the Space relay). Discovery starts on connect.
9378
+ if (this.deps.p2p?.enabled && isP2pCapable()) {
9379
+ space.attachPeerNetwork(new PeerNetwork({
9380
+ space,
9381
+ myId: memberId,
9382
+ workerFactory: this.deps.p2p.workerFactory,
9383
+ iceServers: this.deps.p2p.iceServers,
9384
+ maxPeers: this.deps.p2p.maxPeers,
9385
+ debug: this.deps.p2p.debug,
9386
+ }));
9387
+ }
8275
9388
  return space;
8276
9389
  }
8277
9390
  /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
8278
9391
  async ensureIdentity(memberId) {
8279
9392
  const store = KeyStore.getInstance();
8280
9393
  if (!store.getKeyPair(memberId)) {
8281
- 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
+ }
8282
9422
  }
8283
9423
  const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
8284
9424
  const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
@@ -8519,6 +9659,112 @@ class FunctionsNamespace {
8519
9659
  }
8520
9660
  }
8521
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
+
8522
9768
  /**
8523
9769
  * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
8524
9770
  * lexicographically-sortable string so the whole offline layer (and the
@@ -9035,82 +10281,6 @@ class SnapshotNamespace {
9035
10281
  }
9036
10282
  }
9037
10283
 
9038
- /**
9039
- * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
9040
- * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
9041
- * is uniquely safe: a cache hit is self-validating, immutable, and shareable
9042
- * across files. The Cache API (rather than IndexedDB) is the right home for
9043
- * large binary blobs — it streams, it's purpose-built for response bodies, and
9044
- * it has its own eviction.
9045
- *
9046
- * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
9047
- * than the real shard endpoint, so the cache is addressable by hash alone — the
9048
- * sync engine can re-PUT a deferred shard knowing only its hash, without
9049
- * reconstructing the accelerator URL. We never fetch these synthetic URLs; they
9050
- * exist only as cache keys.
9051
- *
9052
- * Every method degrades to a no-op / null where the Cache API is unavailable
9053
- * (Node, Workers, insecure contexts), so callers don't need to branch.
9054
- */
9055
- const CACHE_NAME = "muhkoo-shards-v1";
9056
- class ShardCache {
9057
- static available() {
9058
- try {
9059
- return typeof caches !== "undefined";
9060
- }
9061
- catch {
9062
- return false;
9063
- }
9064
- }
9065
- key(hash) {
9066
- return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
9067
- }
9068
- open() {
9069
- return caches.open(CACHE_NAME);
9070
- }
9071
- /** Cached shard bytes, or `null` on miss / when unavailable. */
9072
- async get(hash) {
9073
- if (!ShardCache.available())
9074
- return null;
9075
- try {
9076
- const cache = await this.open();
9077
- const res = await cache.match(this.key(hash));
9078
- if (!res)
9079
- return null;
9080
- return new Uint8Array(await res.arrayBuffer());
9081
- }
9082
- catch {
9083
- return null;
9084
- }
9085
- }
9086
- /** Store shard bytes under their hash. Best-effort. */
9087
- async put(hash, bytes) {
9088
- if (!ShardCache.available())
9089
- return;
9090
- try {
9091
- const cache = await this.open();
9092
- await cache.put(this.key(hash), new Response(bytes, {
9093
- headers: { "content-type": "application/octet-stream" },
9094
- }));
9095
- }
9096
- catch {
9097
- /* quota / unavailable — degrade to online-only for this shard */
9098
- }
9099
- }
9100
- /** Whether a shard is present in the cache. */
9101
- async has(hash) {
9102
- if (!ShardCache.available())
9103
- return false;
9104
- try {
9105
- const cache = await this.open();
9106
- return Boolean(await cache.match(this.key(hash)));
9107
- }
9108
- catch {
9109
- return false;
9110
- }
9111
- }
9112
- }
9113
-
9114
10284
  /**
9115
10285
  * IndexedDB schema for the offline layer — the single source of truth for the
9116
10286
  * database name, version, and object stores. Bump {@link DB_VERSION} whenever
@@ -9822,17 +10992,46 @@ class Client {
9822
10992
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
9823
10993
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
9824
10994
  // by client.kv), so a returning member hydrates without a fresh keyring
9825
- // 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.
9826
11002
  const spaceKeyCache = {
9827
11003
  loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
9828
- 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
+ },
9829
11025
  };
11026
+ const chatKeys = new ChatKeyVault(chatKeyStore);
9830
11027
  this.space = new SpaceNamespace({
9831
11028
  http: this.http,
9832
11029
  session: this.session,
9833
11030
  wsBaseUrl,
9834
11031
  cache: spaceKeyCache,
9835
11032
  offline: this.offline,
11033
+ p2p: options.p2p,
11034
+ chatKeys,
9836
11035
  });
9837
11036
  this.agents = new AgentsNamespace({ http: this.http });
9838
11037
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
@@ -10724,5 +11923,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
10724
11923
  default: loadRsWasm
10725
11924
  });
10726
11925
 
10727
- export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
11926
+ export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, DbNamespace, DbTable, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compareHlc, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64$1 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, serialize, shardHash, signMessage, stableStringify, toBase64$1 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
10728
11927
  //# sourceMappingURL=index.js.map