@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.
@@ -3796,6 +3796,8 @@ class ReedSolomonCodec {
3796
3796
  * skip a PUT they'd otherwise dedup-rewrite. The
3797
3797
  * server can ignore the optimization if it wants.
3798
3798
  */
3799
+ /** Peer head-start (ms) before origin joins the race in {@link ShardClient.getShard}. */
3800
+ const HEDGE_MS = 500;
3799
3801
  /**
3800
3802
  * Compute the lowercase hex SHA-256 of `bytes`. Available via WebCrypto in
3801
3803
  * every runtime this SDK targets.
@@ -3814,6 +3816,7 @@ class ShardClient {
3814
3816
  fetchFn;
3815
3817
  cache;
3816
3818
  deferUpload;
3819
+ peers;
3817
3820
  constructor(opts) {
3818
3821
  if (!opts?.baseUrl)
3819
3822
  throw new Error("ShardClient: `baseUrl` is required");
@@ -3831,6 +3834,7 @@ class ShardClient {
3831
3834
  this.fetchFn = f.bind(globalThis);
3832
3835
  this.cache = opts.cache;
3833
3836
  this.deferUpload = opts.deferUpload;
3837
+ this.peers = opts.peers;
3834
3838
  }
3835
3839
  /** Build the URL for a given shard hash — exposed for callers that want to log or pre-resolve. */
3836
3840
  urlForHash(hash) {
@@ -3849,6 +3853,9 @@ class ShardClient {
3849
3853
  // this is safe to keep regardless of upload outcome.
3850
3854
  if (this.cache)
3851
3855
  await this.cache.put(hash, bytes);
3856
+ // Tell the swarm we now hold this block, so peers can pull it from us.
3857
+ if (this.peers)
3858
+ this.peers.announce(hash);
3852
3859
  let response;
3853
3860
  try {
3854
3861
  response = await this.fetchFn(url, {
@@ -3892,15 +3899,63 @@ class ShardClient {
3892
3899
  if (cached)
3893
3900
  return cached;
3894
3901
  }
3902
+ // No peers → straight to origin.
3903
+ if (!this.peers)
3904
+ return this.fetchFromOrigin(hash);
3905
+ // HEDGED RACE: prefer a peer (saves origin egress) but never let a slow
3906
+ // or missing peer stall delivery — give the peer a short head start, then
3907
+ // start origin in parallel and take whichever returns the block first.
3908
+ const peers = this.peers;
3909
+ return new Promise((resolve, reject) => {
3910
+ let done = false;
3911
+ let originStarted = false;
3912
+ let timer = null;
3913
+ const win = (b) => {
3914
+ if (done)
3915
+ return;
3916
+ done = true;
3917
+ if (timer)
3918
+ clearTimeout(timer);
3919
+ resolve(b);
3920
+ };
3921
+ const fail = (e) => {
3922
+ if (!done) {
3923
+ done = true;
3924
+ reject(e);
3925
+ }
3926
+ };
3927
+ const startOrigin = () => {
3928
+ if (originStarted || done)
3929
+ return;
3930
+ originStarted = true;
3931
+ if (timer) {
3932
+ clearTimeout(timer);
3933
+ timer = null;
3934
+ }
3935
+ this.fetchFromOrigin(hash).then(win, fail);
3936
+ };
3937
+ timer = setTimeout(startOrigin, HEDGE_MS);
3938
+ peers.getBlock(hash).then((b) => {
3939
+ if (b) {
3940
+ if (this.cache)
3941
+ void this.cache.put(hash, b);
3942
+ win(b); // peer delivered first
3943
+ }
3944
+ else {
3945
+ startOrigin(); // peer miss → don't wait out the hedge
3946
+ }
3947
+ }, () => startOrigin());
3948
+ });
3949
+ }
3950
+ /** Fetch a shard from origin. Returns `null` for 404 (or offline when a
3951
+ * cache is present, so RS recovery can proceed); throws otherwise. */
3952
+ async fetchFromOrigin(hash) {
3895
3953
  const url = this.urlForHash(hash);
3896
3954
  let response;
3897
3955
  try {
3898
3956
  response = await this.fetchFn(url, { method: "GET" });
3899
3957
  }
3900
3958
  catch (err) {
3901
- // Offline with no cache hit: report the shard as missing so
3902
- // FileStorage's Reed–Solomon recovery can still reconstruct from the
3903
- // shards that ARE cached. Rethrow when there's no cache to fall back on.
3904
3959
  if (this.cache)
3905
3960
  return null;
3906
3961
  throw err;
@@ -3911,8 +3966,7 @@ class ShardClient {
3911
3966
  const text = await safeText(response);
3912
3967
  throw new Error(`ShardClient.getShard(${hash}) failed (${response.status}): ${text}`);
3913
3968
  }
3914
- const buf = await response.arrayBuffer();
3915
- const bytes = new Uint8Array(buf);
3969
+ const bytes = new Uint8Array(await response.arrayBuffer());
3916
3970
  if (this.cache)
3917
3971
  void this.cache.put(hash, bytes);
3918
3972
  return bytes;
@@ -4940,7 +4994,7 @@ function base64UrlDecode(s) {
4940
4994
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
4941
4995
  * `package.json` `version`.
4942
4996
  */
4943
- const VERSION = "0.8.0-alpha.0";
4997
+ const VERSION = "0.10.0-alpha.0";
4944
4998
 
4945
4999
  /**
4946
5000
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -4963,6 +5017,28 @@ const VERSION = "0.8.0-alpha.0";
4963
5017
  * convenience JSON helpers (`get`/`post`/`del`) for code that talks to the
4964
5018
  * accelerator directly.
4965
5019
  */
5020
+ /** Per-request timing logs, enabled on staging only (temporary diagnostic). */
5021
+ const HTTP_DEBUG = (() => {
5022
+ try {
5023
+ return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
5024
+ }
5025
+ catch {
5026
+ return false;
5027
+ }
5028
+ })();
5029
+ /** Short, readable request path (pathname + search) for the timing log. */
5030
+ function reqPath(input) {
5031
+ try {
5032
+ const u = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
5033
+ if (!/^https?:\/\//i.test(u))
5034
+ return u;
5035
+ const parsed = new URL(u);
5036
+ return parsed.pathname + parsed.search;
5037
+ }
5038
+ catch {
5039
+ return "?";
5040
+ }
5041
+ }
4966
5042
  /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
4967
5043
  class HttpError extends Error {
4968
5044
  status;
@@ -5019,7 +5095,14 @@ class HttpClient {
5019
5095
  if (token)
5020
5096
  headers.set("X-Muhkoo-Session", token);
5021
5097
  }
5022
- return this.fetchFn(input, { ...init, headers });
5098
+ const finalInit = { ...init, headers };
5099
+ if (!HTTP_DEBUG)
5100
+ return this.fetchFn(input, finalInit);
5101
+ // Staging diagnostic: time every accelerator request (this `fetch` is the
5102
+ // single chokepoint — JSON helpers + the roster/shard fetches ride it).
5103
+ const t = performance.now();
5104
+ const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
5105
+ return this.fetchFn(input, finalInit).then((res) => { console.info(`[muhkoo:http] ${tag} → ${res.status} ${Math.round(performance.now() - t)}ms`); return res; }, (err) => { console.info(`[muhkoo:http] ${tag} → ERROR ${Math.round(performance.now() - t)}ms`); throw err; });
5023
5106
  };
5024
5107
  /** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
5025
5108
  isSameOrigin(input) {
@@ -5833,6 +5916,34 @@ class ZkAuth {
5833
5916
  return null;
5834
5917
  }
5835
5918
  }
5919
+ /**
5920
+ * Boot helper — call this once on app start. Restores the session AND, when a
5921
+ * passkey is enrolled, silently unlocks the identity via WebAuthn (recovering
5922
+ * the master seed so the ratchet keypair rehydrates and `client.kv` works).
5923
+ * The seed is never persisted, so a passkey (or password) is the only way to
5924
+ * unlock after a reload — this keeps that off the app's plate.
5925
+ *
5926
+ * Returns `{ user, unlocked }`. When `unlocked` is false (no passkey, the
5927
+ * user cancelled, or the browser required a gesture), prompt for a password
5928
+ * and call {@link unlock}. Apps need no key-management scaffolding beyond this.
5929
+ */
5930
+ async resume() {
5931
+ const user = await this.restore();
5932
+ if (!user)
5933
+ return { user: null, unlocked: false };
5934
+ if (this.deps.session.isUnlocked)
5935
+ return { user, unlocked: true };
5936
+ try {
5937
+ const factors = await this.listFactors();
5938
+ if (factors.some((f) => f.type === "passkey")) {
5939
+ await this.loginWithPasskey(user.username);
5940
+ }
5941
+ }
5942
+ catch {
5943
+ /* no passkey / cancelled / no user gesture — leave locked for a prompt */
5944
+ }
5945
+ return { user, unlocked: this.deps.session.isUnlocked };
5946
+ }
5836
5947
  /**
5837
5948
  * Re-derive identity material for an already-authenticated (restored)
5838
5949
  * session, so the client can decrypt storage and run the ratchet. Verifies
@@ -7037,6 +7148,8 @@ class Space {
7037
7148
  events = new EventTarget();
7038
7149
  rawWired = false;
7039
7150
  catchUpUnsub = null;
7151
+ /** Optional P2P swarm for this Space (block exchange among members). */
7152
+ peerNet = null;
7040
7153
  constructor(deps) {
7041
7154
  this.deps = deps;
7042
7155
  this.name = deps.name;
@@ -7085,6 +7198,9 @@ class Space {
7085
7198
  if (this.deps.keyring) {
7086
7199
  this.channel.on(BroadcastChannelEvents.CONNECTED, () => {
7087
7200
  this.channel?.sendRaw({ name: this.deps.myId() });
7201
+ // Kick P2P peer discovery now the signaling channel is live
7202
+ // (re-runs on each reconnect to re-form the mesh).
7203
+ this.peerNet?.start();
7088
7204
  });
7089
7205
  this.wireFanout();
7090
7206
  // Register offline catch-up once: on each reconnect the sync
@@ -7100,6 +7216,27 @@ class Space {
7100
7216
  this.channel?.disconnect();
7101
7217
  this.catchUpUnsub?.();
7102
7218
  this.catchUpUnsub = null;
7219
+ this.peerNet?.close();
7220
+ this.peerNet = null;
7221
+ }
7222
+ /** Attach a P2P swarm so this Space's file reads/writes can use peers. */
7223
+ attachPeerNetwork(net) {
7224
+ this.peerNet = net;
7225
+ }
7226
+ /**
7227
+ * Broadcast an app payload directly to connected Space peers over P2P,
7228
+ * **bypassing the server** — for CRDT ops / high-frequency state (live
7229
+ * cursors, presence, collaborative edits) you don't want to push through the
7230
+ * relay. No-op when no P2P mesh is active (requires `client` `p2p`).
7231
+ * Unlike {@link sendEphemeral} (server-relayed, metered, size-limited), this
7232
+ * is peer-direct and fragments large payloads.
7233
+ */
7234
+ gossipToPeers(data) {
7235
+ this.peerNet?.gossip(data);
7236
+ }
7237
+ /** Subscribe to peer gossip ({@link gossipToPeers}). Returns an unsubscribe fn. */
7238
+ onPeerGossip(handler) {
7239
+ return this.peerNet?.onGossip(handler) ?? (() => { });
7103
7240
  }
7104
7241
  /** Subscribe to a raw channel event. Buffered until `connect()` if early. */
7105
7242
  on(event, handler) {
@@ -7158,18 +7295,23 @@ class Space {
7158
7295
  const hlc = await offline.nextHlc();
7159
7296
  const packet = await this.sealMessage(payload, opts.channel, opts.contentType, clientId);
7160
7297
  const frame = { spaceMessage: packet };
7161
- await offline.putMessage(this.id, {
7298
+ const entry = {
7162
7299
  handle: provisionalSpaceKey(clientId),
7163
7300
  packet,
7164
7301
  op: "msg",
7165
7302
  clientId,
7166
7303
  hlc,
7167
7304
  pending: true,
7168
- });
7169
- if (this.isConnected())
7170
- this.sendRaw(frame);
7171
- else
7305
+ };
7306
+ if (this.isConnected()) {
7307
+ this.sendRaw(frame); // send first — don't block the wire on IndexedDB
7308
+ void offline.putMessage(this.id, entry); // optimistic cache off the hot path
7309
+ }
7310
+ else {
7311
+ // Offline: persist the optimistic entry + durable replay queue.
7312
+ void offline.putMessage(this.id, entry);
7172
7313
  await offline.enqueueFrame(this.id, frame, clientId, hlc);
7314
+ }
7173
7315
  }
7174
7316
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
7175
7317
  onMessage(handler) {
@@ -7233,18 +7375,14 @@ class Space {
7233
7375
  const offline = this.deps.offline;
7234
7376
  if (!offline?.enabled || !this.cipher)
7235
7377
  return [];
7236
- const entries = await offline.loadMessages(this.id);
7378
+ const entries = (await offline.loadMessages(this.id)).filter((e) => !e.deleted && e.packet);
7379
+ // Decode in parallel (already verified when cached → skipVerify).
7380
+ const decodedAll = await Promise.all(entries.map((e) => this.decodeFrame(e.packet, this.cipher, /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle), /*skipVerify*/ true)));
7237
7381
  const out = [];
7238
- for (const e of entries) {
7239
- if (e.deleted || !e.packet)
7240
- continue;
7241
- // Already verified when first cached → skip re-verification (which
7242
- // would also fail offline without a warm member directory).
7243
- const decoded = await this.decodeFrame(e.packet, this.cipher,
7244
- /*isRawFrame*/ true, e.pending ? 0 : Number(e.handle),
7245
- /*skipVerify*/ true);
7382
+ for (let i = 0; i < decodedAll.length; i++) {
7383
+ const decoded = decodedAll[i];
7246
7384
  if (decoded) {
7247
- decoded.pending = e.pending;
7385
+ decoded.pending = entries[i].pending;
7248
7386
  out.push(decoded);
7249
7387
  }
7250
7388
  }
@@ -7269,10 +7407,12 @@ class Space {
7269
7407
  * indicators, presence, or live cursors are built on top of this. No-op if
7270
7408
  * the space isn't connected (ephemeral signals aren't worth queuing).
7271
7409
  */
7272
- sendEphemeral(subject, data) {
7410
+ sendEphemeral(subject, data, to) {
7273
7411
  if (!this.isConnected())
7274
7412
  return;
7275
- this.sendRaw({ pub: { subject, data } });
7413
+ // `to` (a member id) lets the server unicast a *directed* ephemeral
7414
+ // instead of broadcasting; omitted ⇒ broadcast to the room (unchanged).
7415
+ this.sendRaw({ pub: to ? { subject, data, to } : { subject, data } });
7276
7416
  }
7277
7417
  /** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
7278
7418
  onEphemeral(handler) {
@@ -7299,25 +7439,45 @@ class Space {
7299
7439
  params.set("limit", String(opts.limit));
7300
7440
  const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
7301
7441
  (params.toString() ? `?${params}` : "");
7442
+ // Temporary staging diagnostic: split fetch / directory / decode time.
7443
+ const dbg = (() => { try {
7444
+ return globalThis.location?.hostname?.includes("staging");
7445
+ }
7446
+ catch {
7447
+ return false;
7448
+ } })();
7449
+ const mark = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
7450
+ const t0 = mark();
7302
7451
  const res = await this.deps.fetch(url);
7303
7452
  if (!res.ok)
7304
7453
  throw new Error(`Space.history: ${res.status} ${res.statusText}`);
7305
7454
  const body = (await res.json());
7455
+ const tFetch = mark();
7306
7456
  // Warm the member directory once so historical senders' signatures verify.
7307
- // (refreshDirectory swallows its own transport errors.)
7308
- await this.deps.keyring?.refreshDirectory();
7457
+ // Force past the cooldown — a history load is an explicit "get fresh".
7458
+ await this.deps.keyring?.refreshDirectory({ force: true });
7459
+ const tDir = mark();
7460
+ // Decode the whole page in PARALLEL — each frame is an AES-GCM decrypt +
7461
+ // ECDSA verify on the WebCrypto thread; doing them sequentially (await
7462
+ // per message) serializes ~100 main↔crypto round-trips and dominates
7463
+ // load time. Promise.all preserves input order.
7464
+ const raws = body.messages ?? [];
7465
+ const decodedAll = await Promise.all(raws.map((raw) => this.decodeFrame(raw, cipher)));
7466
+ if (dbg) {
7467
+ console.info(`[muhkoo:space] history n=${raws.length} | fetch ${Math.round(tFetch - t0)}ms | dir ${Math.round(tDir - tFetch)}ms | decode ${Math.round(mark() - tDir)}ms`);
7468
+ }
7309
7469
  const messages = [];
7310
- for (const raw of body.messages ?? []) {
7311
- const decoded = await this.decodeFrame(raw, cipher);
7470
+ for (let i = 0; i < decodedAll.length; i++) {
7471
+ const decoded = decodedAll[i];
7312
7472
  if (!decoded)
7313
7473
  continue;
7314
7474
  messages.push(decoded);
7315
- // Warm the offline cache with the inner sealed packet at its handle.
7475
+ // Warm the offline cache off the hot path (best-effort, non-blocking).
7316
7476
  if (this.deps.offline?.enabled) {
7317
7477
  try {
7318
- const outer = JSON.parse(raw);
7478
+ const outer = JSON.parse(raws[i]);
7319
7479
  if (typeof outer.spaceMessage === "string") {
7320
- await this.cacheFrame(outer.spaceMessage, decoded, "msg");
7480
+ void this.cacheFrame(outer.spaceMessage, decoded, "msg");
7321
7481
  }
7322
7482
  }
7323
7483
  catch {
@@ -7392,6 +7552,7 @@ class Space {
7392
7552
  fetch: this.deps.fetch,
7393
7553
  cache: this.deps.shardCache,
7394
7554
  deferUpload: this.deps.deferShardUpload,
7555
+ peers: this.peerNet?.exchange,
7395
7556
  });
7396
7557
  return new FileStorage({ shards });
7397
7558
  }
@@ -7413,8 +7574,8 @@ class Space {
7413
7574
  const handle = Number(f.timestamp ?? 0);
7414
7575
  const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
7415
7576
  if (decoded) {
7416
- this.emitEvent("message", decoded);
7417
- await this.cacheFrame(f.spaceMessage, decoded, "msg");
7577
+ this.emitEvent("message", decoded); // UI first
7578
+ void this.cacheFrame(f.spaceMessage, decoded, "msg"); // cache off the hot path
7418
7579
  }
7419
7580
  return;
7420
7581
  }
@@ -7426,7 +7587,7 @@ class Space {
7426
7587
  const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
7427
7588
  if (decoded) {
7428
7589
  this.emitEvent("message-edited", decoded);
7429
- await this.cacheFrame(e.spaceMessage, decoded, "edit");
7590
+ void this.cacheFrame(e.spaceMessage, decoded, "edit");
7430
7591
  }
7431
7592
  }
7432
7593
  return;
@@ -7436,8 +7597,8 @@ class Space {
7436
7597
  const handle = Number(d.ts ?? 0);
7437
7598
  this.emitEvent("message-deleted", { handle });
7438
7599
  if (this.deps.offline?.enabled) {
7439
- await this.deps.offline.putDeleted(this.id, handle);
7440
- await this.deps.offline.observeHandle(this.id, handle);
7600
+ // Cache the tombstone off the hot path.
7601
+ void this.deps.offline.putDeleted(this.id, handle).then(() => this.deps.offline?.observeHandle(this.id, handle));
7441
7602
  }
7442
7603
  return;
7443
7604
  }
@@ -7458,7 +7619,7 @@ class Space {
7458
7619
  this.emitEvent("join-request", req);
7459
7620
  // A new member appeared — refresh the directory so their future
7460
7621
  // messages verify, and (if we hold the key) auto-admit them.
7461
- void this.deps.keyring?.refreshDirectory();
7622
+ void this.deps.keyring?.refreshDirectory({ force: true });
7462
7623
  if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
7463
7624
  void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
7464
7625
  }
@@ -7608,7 +7769,7 @@ class Space {
7608
7769
  const cursor = await offline.getCursor(this.id);
7609
7770
  let since = String(cursor?.lastSeenHandle ?? 0);
7610
7771
  // Warm the member directory once so historical senders' signatures verify.
7611
- await this.deps.keyring?.refreshDirectory();
7772
+ await this.deps.keyring?.refreshDirectory({ force: true });
7612
7773
  for (let page = 0; page < 50; page++) {
7613
7774
  const url = `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/history` +
7614
7775
  `?since=${encodeURIComponent(since)}&limit=100`;
@@ -7871,6 +8032,19 @@ function parseMaybeJson(text) {
7871
8032
  * `KEEPER_MEMBER_ID`.
7872
8033
  */
7873
8034
  const KEEPER_MEMBER_ID = "__keeper__";
8035
+ /** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
8036
+ const KEYRING_DEBUG = (() => {
8037
+ try {
8038
+ return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
8039
+ }
8040
+ catch {
8041
+ return false;
8042
+ }
8043
+ })();
8044
+ function klog(msg) {
8045
+ if (KEYRING_DEBUG)
8046
+ console.info(`[muhkoo:keyring] ${msg}`);
8047
+ }
7874
8048
  class SpaceKeyring {
7875
8049
  deps;
7876
8050
  keys = new Map();
@@ -7878,6 +8052,14 @@ class SpaceKeyring {
7878
8052
  policy;
7879
8053
  /** Cached member directory: memberId → imported ECDSA verify key. */
7880
8054
  memberEcdsa = new Map();
8055
+ /** Coalesce concurrent roster refreshes (the parallel history/backlog decode
8056
+ * would otherwise fire one `roster` fetch PER message — a thundering herd). */
8057
+ dirInFlight = null;
8058
+ dirRefreshedAt = 0;
8059
+ /** Min gap between non-forced roster refreshes. A miss that persists within
8060
+ * this window won't re-fetch (the roster hasn't changed that fast); genuine
8061
+ * membership changes use `{ force: true }` (join requests / history loads). */
8062
+ static DIR_COOLDOWN_MS = 5000;
7881
8063
  constructor(deps) {
7882
8064
  this.deps = deps;
7883
8065
  this.policy = deps.historyPolicy ?? "static";
@@ -7887,8 +8069,27 @@ class SpaceKeyring {
7887
8069
  ecdsaKeyFor(memberId) {
7888
8070
  return this.memberEcdsa.get(memberId);
7889
8071
  }
7890
- /** Refresh the member directory (memberId → ECDSA key) from the roster. */
7891
- async refreshDirectory() {
8072
+ /**
8073
+ * Refresh the member directory (memberId → ECDSA key) from the roster.
8074
+ * Coalesces concurrent callers into ONE fetch, and skips a fetch when one
8075
+ * completed within {@link DIR_COOLDOWN_MS} (unless `force`). Without this, a
8076
+ * page of N messages decoded in parallel — each missing the cold directory —
8077
+ * fires N simultaneous `roster` requests that queue behind the browser's
8078
+ * connection limit and take many seconds.
8079
+ */
8080
+ async refreshDirectory(opts) {
8081
+ if (this.dirInFlight)
8082
+ return this.dirInFlight; // a refresh is already running
8083
+ if (!opts?.force && Date.now() - this.dirRefreshedAt < SpaceKeyring.DIR_COOLDOWN_MS) {
8084
+ return; // refreshed very recently — the roster won't have changed
8085
+ }
8086
+ this.dirInFlight = this.doRefreshDirectory().finally(() => {
8087
+ this.dirRefreshedAt = Date.now();
8088
+ this.dirInFlight = null;
8089
+ });
8090
+ return this.dirInFlight;
8091
+ }
8092
+ async doRefreshDirectory() {
7892
8093
  let roster;
7893
8094
  try {
7894
8095
  roster = await this.deps.transport.fetchRoster();
@@ -7923,34 +8124,63 @@ class SpaceKeyring {
7923
8124
  async loadFromCache() {
7924
8125
  if (!this.deps.cache)
7925
8126
  return false;
8127
+ // The cache entries are ECIES-wrapped to our identity key, so we unwrap
8128
+ // with the keyring's OWN private key — available whenever chat works,
8129
+ // unlike client.kv's StorageCipher which needs the full unlocked identity.
8130
+ const priv = this.deps.ownPrivateKey();
8131
+ if (!priv) {
8132
+ klog("loadFromCache: no private key available yet");
8133
+ return false;
8134
+ }
7926
8135
  let cached;
7927
8136
  try {
7928
8137
  cached = await this.deps.cache.loadKeys(this.deps.spaceId);
7929
8138
  }
7930
- catch {
8139
+ catch (e) {
8140
+ klog(`loadFromCache: ERROR reading cache — ${e instanceof Error ? e.message : e}`);
7931
8141
  return false;
7932
8142
  }
7933
- if (!cached)
8143
+ if (!cached) {
8144
+ klog("loadFromCache: cache empty");
8145
+ return false;
8146
+ }
8147
+ for (const [epochStr, serialized] of Object.entries(cached)) {
8148
+ try {
8149
+ const blob = JSON.parse(serialized);
8150
+ this.keys.set(Number(epochStr), await unwrapSpaceKey(blob, priv));
8151
+ }
8152
+ catch {
8153
+ /* stale / old-format entry — skip; we'll re-admit and re-persist */
8154
+ }
8155
+ }
8156
+ if (this.keys.size === 0) {
8157
+ klog("loadFromCache: no usable entries (stale format → will re-admit)");
7934
8158
  return false;
7935
- for (const [epochStr, b64] of Object.entries(cached)) {
7936
- this.keys.set(Number(epochStr), fromBase64$1(b64));
7937
8159
  }
7938
8160
  this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
7939
- return this.keys.size > 0;
8161
+ klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
8162
+ return true;
7940
8163
  }
7941
8164
  async persist() {
7942
- if (!this.deps.cache)
8165
+ if (!this.deps.cache || this.keys.size === 0)
7943
8166
  return;
7944
- const out = {};
7945
- for (const [epoch, key] of this.keys)
7946
- out[String(epoch)] = toBase64$1(key);
7947
- // Best-effort: caching is an optimization; a write failure (e.g. locked
7948
- // storage) must not break key acquisition.
8167
+ // Best-effort: caching is an optimization; a write failure must not break
8168
+ // key acquisition.
7949
8169
  try {
8170
+ // Wrap each epoch key to our OWN identity key (ECIES-to-self). The
8171
+ // cache stays server-blind (the kv layer stores it with encrypt:false
8172
+ // — it's already encrypted) AND readable with the keyring private key
8173
+ // alone, so it works while the full identity is locked (post-reload).
8174
+ const selfPub = await importEcdhPublicKey(this.deps.identityEcdhPub);
8175
+ const out = {};
8176
+ for (const [epoch, key] of this.keys) {
8177
+ out[String(epoch)] = JSON.stringify(await wrapSpaceKey(key, epoch, selfPub));
8178
+ }
7950
8179
  await this.deps.cache.saveKeys(this.deps.spaceId, out);
8180
+ klog(`persist: saved ${Object.keys(out).length} epoch(s)`);
7951
8181
  }
7952
- catch {
7953
- /* ignore */
8182
+ catch (e) {
8183
+ klog(`persist: FAILED — ${e instanceof Error ? e.message : e}`);
7954
8184
  }
7955
8185
  }
7956
8186
  /** Creator path: mint epoch-0 group key for a brand-new space. */
@@ -7999,8 +8229,24 @@ class SpaceKeyring {
7999
8229
  this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
8000
8230
  await this.persist();
8001
8231
  }
8232
+ klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
8002
8233
  return added;
8003
8234
  }
8235
+ /**
8236
+ * Background freshness check for the cache-hit fast path: confirm we hold the
8237
+ * current epoch (picks up a rotation / our removal) WITHOUT the full newcomer
8238
+ * admit handshake. Cheap + idempotent; never blocks first paint. Returns the
8239
+ * number of newly-obtained epoch keys (0 = already current).
8240
+ */
8241
+ async reconcile() {
8242
+ await this.refreshDirectory({ force: true });
8243
+ try {
8244
+ return await this.pullKeys();
8245
+ }
8246
+ catch {
8247
+ return 0; // best-effort — the live keyringReady frame is the backstop
8248
+ }
8249
+ }
8004
8250
  // -- key-holder: admit + rotate -------------------------------------------
8005
8251
  /**
8006
8252
  * Wrap our group key(s) for a newcomer and post the blobs. In a `static`
@@ -8310,6 +8556,861 @@ class SpaceCache {
8310
8556
  }
8311
8557
  }
8312
8558
 
8559
+ /**
8560
+ * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
8561
+ * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
8562
+ * is uniquely safe: a cache hit is self-validating, immutable, and shareable
8563
+ * across files. The Cache API (rather than IndexedDB) is the right home for
8564
+ * large binary blobs — it streams, it's purpose-built for response bodies, and
8565
+ * it has its own eviction.
8566
+ *
8567
+ * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
8568
+ * than the real shard endpoint, so the cache is addressable by hash alone — the
8569
+ * sync engine can re-PUT a deferred shard knowing only its hash, without
8570
+ * reconstructing the accelerator URL. We never fetch these synthetic URLs; they
8571
+ * exist only as cache keys.
8572
+ *
8573
+ * Every method degrades to a no-op / null where the Cache API is unavailable
8574
+ * (Node, Workers, insecure contexts), so callers don't need to branch.
8575
+ */
8576
+ const CACHE_NAME = "muhkoo-shards-v1";
8577
+ class ShardCache {
8578
+ static available() {
8579
+ try {
8580
+ return typeof caches !== "undefined";
8581
+ }
8582
+ catch {
8583
+ return false;
8584
+ }
8585
+ }
8586
+ key(hash) {
8587
+ return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
8588
+ }
8589
+ open() {
8590
+ return caches.open(CACHE_NAME);
8591
+ }
8592
+ /** Cached shard bytes, or `null` on miss / when unavailable. */
8593
+ async get(hash) {
8594
+ if (!ShardCache.available())
8595
+ return null;
8596
+ try {
8597
+ const cache = await this.open();
8598
+ const res = await cache.match(this.key(hash));
8599
+ if (!res)
8600
+ return null;
8601
+ return new Uint8Array(await res.arrayBuffer());
8602
+ }
8603
+ catch {
8604
+ return null;
8605
+ }
8606
+ }
8607
+ /** Store shard bytes under their hash. Best-effort. */
8608
+ async put(hash, bytes) {
8609
+ if (!ShardCache.available())
8610
+ return;
8611
+ try {
8612
+ const cache = await this.open();
8613
+ await cache.put(this.key(hash), new Response(bytes, {
8614
+ headers: { "content-type": "application/octet-stream" },
8615
+ }));
8616
+ }
8617
+ catch {
8618
+ /* quota / unavailable — degrade to online-only for this shard */
8619
+ }
8620
+ }
8621
+ /** Whether a shard is present in the cache. */
8622
+ async has(hash) {
8623
+ if (!ShardCache.available())
8624
+ return false;
8625
+ try {
8626
+ const cache = await this.open();
8627
+ return Boolean(await cache.match(this.key(hash)));
8628
+ }
8629
+ catch {
8630
+ return false;
8631
+ }
8632
+ }
8633
+ }
8634
+
8635
+ /**
8636
+ * `PeerExchange` — the thin façade the storage layer consults to try fetching a
8637
+ * block from peers before falling back to origin. It satisfies the structural
8638
+ * `PeerBlockSource` interface that {@link ../storage/transport/ShardClient}
8639
+ * declares (so storage never imports the p2p module — the dependency arrow stays
8640
+ * one-way, p2p → storage).
8641
+ *
8642
+ * It delegates to an {@link EngineHandle} — either an in-process {@link
8643
+ * ./worker/blockEngine.BlockEngine} or the Web Worker proxy — so the same façade
8644
+ * works whether the block engine runs on the main thread or off it.
8645
+ */
8646
+ class PeerExchange {
8647
+ engine;
8648
+ timeoutMs;
8649
+ debug;
8650
+ constructor(engine, opts = {}) {
8651
+ this.engine = engine;
8652
+ this.timeoutMs = opts.timeoutMs ?? 2500;
8653
+ this.debug = opts.debug ?? false;
8654
+ }
8655
+ async getBlock(hash, opts) {
8656
+ const bytes = await this.engine.want(hash, opts?.timeoutMs ?? this.timeoutMs);
8657
+ if (this.debug) {
8658
+ console.info(`%c[muhkoo:p2p] ${bytes ? "✓ peer-served" : "✗ miss → origin"} shard ${hash.slice(0, 10)}…`, `color:${bytes ? "#58da7d" : "#888"}`);
8659
+ }
8660
+ return bytes;
8661
+ }
8662
+ announce(hash) {
8663
+ if (this.debug)
8664
+ console.info(`[muhkoo:p2p] announce shard ${hash.slice(0, 10)}…`);
8665
+ this.engine.announce(hash);
8666
+ }
8667
+ }
8668
+
8669
+ /**
8670
+ * `SpaceSignaler` — WebRTC signaling carried over the Space's existing ephemeral
8671
+ * relay. No new server channel: `Space.sendEphemeral`/`onEphemeral` already
8672
+ * blind-relay `pub` frames stamped with the authenticated sender, so SDP
8673
+ * offers/answers and ICE candidates ride a reserved subject (`__p2p__`) scoped
8674
+ * to Space membership. The sender (`from`) is server-stamped and can't be
8675
+ * spoofed; the payload is opaque to the server.
8676
+ *
8677
+ * Envelopes carry a `to` (a member id, or `"*"` for discovery) so the
8678
+ * accelerator can optionally unicast peer-pair signaling instead of broadcasting
8679
+ * (a small, backward-compatible `pub` enhancement); the client filters by `to`
8680
+ * regardless.
8681
+ */
8682
+ const P2P_SUBJECT = "__p2p__";
8683
+ class SpaceSignaler {
8684
+ space;
8685
+ myId;
8686
+ constructor(space, myId) {
8687
+ this.space = space;
8688
+ this.myId = myId;
8689
+ }
8690
+ /** Send a signal to one peer (or `"*"`). */
8691
+ send(to, kind, data) {
8692
+ const env = { to, kind, data };
8693
+ // Ask the server to unicast for a directed target; broadcast for "*".
8694
+ // `to` also stays in the envelope so a broadcast-only server still works
8695
+ // (the client filters by it).
8696
+ this.space.sendEphemeral(P2P_SUBJECT, env, to !== "*" ? to : undefined);
8697
+ }
8698
+ /** Announce/withdraw P2P availability to all Space members. */
8699
+ broadcast(kind, data) {
8700
+ this.send("*", kind, data);
8701
+ }
8702
+ /**
8703
+ * Subscribe to inbound signals addressed to us (or broadcast). Skips our own
8704
+ * echoes. Returns an unsubscribe function.
8705
+ */
8706
+ onSignal(cb) {
8707
+ return this.space.onEphemeral((e) => {
8708
+ if (e.subject !== P2P_SUBJECT || e.from === this.myId)
8709
+ return;
8710
+ const env = e.data;
8711
+ if (!env || typeof env.kind !== "string")
8712
+ return;
8713
+ if (env.to !== this.myId && env.to !== "*")
8714
+ return;
8715
+ cb(e.from, env.kind, env.data);
8716
+ });
8717
+ }
8718
+ }
8719
+
8720
+ /**
8721
+ * `WebRtcTransport` — a {@link ./PeerTransport.PeerTransport} over RTCDataChannels,
8722
+ * signaled by {@link ../signaling/SpaceSignaler}. Runs on the **main thread**
8723
+ * (`RTCPeerConnection` is Window-only — not available in Workers), so the block
8724
+ * engine lives in a Worker and this only shuttles bytes.
8725
+ *
8726
+ * Connection setup uses a deterministic initiator (the peer with the smaller id
8727
+ * dials) to avoid offer/answer glare. STUN handles NAT traversal; there is no
8728
+ * TURN — an unreachable peer simply never connects and the caller falls back to
8729
+ * origin (P2P is best-effort).
8730
+ *
8731
+ * Frames (up to a 4 MiB shard) exceed an RTCDataChannel's max message size, so
8732
+ * each frame is fragmented into `CHUNK`-sized pieces with a 12-byte header
8733
+ * `[msgId u32][index u32][total u32]` and reassembled per peer.
8734
+ */
8735
+ const CHUNK = 16 * 1024;
8736
+ // [msgId u32][index u32][total u32][channel u32]
8737
+ const HEADER$1 = 16;
8738
+ /** Pause sends when a channel has this many bytes buffered (backpressure). */
8739
+ const MAX_BUFFERED = 4 * 1024 * 1024;
8740
+ const DEFAULT_ICE = [{ urls: "stun:stun.cloudflare.com:3478" }];
8741
+ class WebRtcTransport {
8742
+ signaler;
8743
+ myId;
8744
+ conns = new Map();
8745
+ msgCbs = new Set();
8746
+ peerCbs = new Set();
8747
+ offSignal;
8748
+ iceServers;
8749
+ maxPeers;
8750
+ nextMsgId = 1;
8751
+ constructor(signaler, myId, opts = {}) {
8752
+ this.signaler = signaler;
8753
+ this.myId = myId;
8754
+ this.iceServers = opts.iceServers ?? DEFAULT_ICE;
8755
+ this.maxPeers = opts.maxPeers ?? 8;
8756
+ this.offSignal = this.signaler.onSignal((from, kind, data) => this.onSignal(from, kind, data));
8757
+ }
8758
+ peers() {
8759
+ return [...this.conns].filter(([, c]) => c.dc?.readyState === "open").map(([id]) => id);
8760
+ }
8761
+ /** Dial a peer (no-op unless we're the deterministic initiator for the pair). */
8762
+ connectTo(peerId) {
8763
+ if (peerId === this.myId || this.conns.has(peerId))
8764
+ return;
8765
+ if (this.conns.size >= this.maxPeers)
8766
+ return; // mesh cap
8767
+ if (this.myId < peerId)
8768
+ void this.initiate(peerId); // smaller id dials
8769
+ }
8770
+ send(peer, data, channel = 0) {
8771
+ const dc = this.conns.get(peer)?.dc;
8772
+ if (dc?.readyState === "open")
8773
+ void this.sendFragmented(dc, data, channel);
8774
+ }
8775
+ broadcast(data, channel = 0) {
8776
+ for (const [, c] of this.conns) {
8777
+ if (c.dc?.readyState === "open")
8778
+ void this.sendFragmented(c.dc, data, channel);
8779
+ }
8780
+ }
8781
+ onMessage(cb) {
8782
+ this.msgCbs.add(cb);
8783
+ return () => this.msgCbs.delete(cb);
8784
+ }
8785
+ onPeerChange(cb) {
8786
+ this.peerCbs.add(cb);
8787
+ return () => this.peerCbs.delete(cb);
8788
+ }
8789
+ close() {
8790
+ this.offSignal();
8791
+ for (const [, c] of this.conns) {
8792
+ try {
8793
+ c.dc?.close();
8794
+ }
8795
+ catch { /* already closing */ }
8796
+ try {
8797
+ c.pc.close();
8798
+ }
8799
+ catch { /* already closing */ }
8800
+ }
8801
+ this.conns.clear();
8802
+ this.msgCbs.clear();
8803
+ this.peerCbs.clear();
8804
+ }
8805
+ // -- internals -----------------------------------------------------------
8806
+ newPc(peerId) {
8807
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
8808
+ const conn = { pc, reasm: new Map() };
8809
+ this.conns.set(peerId, conn);
8810
+ pc.onicecandidate = (e) => {
8811
+ if (e.candidate)
8812
+ this.signaler.send(peerId, "ice", e.candidate.toJSON());
8813
+ };
8814
+ pc.onconnectionstatechange = () => {
8815
+ if (["failed", "closed", "disconnected"].includes(pc.connectionState))
8816
+ this.drop(peerId);
8817
+ };
8818
+ pc.ondatachannel = (e) => this.bindChannel(peerId, conn, e.channel);
8819
+ return conn;
8820
+ }
8821
+ async initiate(peerId) {
8822
+ const conn = this.newPc(peerId);
8823
+ const dc = conn.pc.createDataChannel("blocks");
8824
+ this.bindChannel(peerId, conn, dc);
8825
+ const offer = await conn.pc.createOffer();
8826
+ await conn.pc.setLocalDescription(offer);
8827
+ this.signaler.send(peerId, "offer", offer);
8828
+ }
8829
+ bindChannel(peerId, conn, dc) {
8830
+ dc.binaryType = "arraybuffer";
8831
+ conn.dc = dc;
8832
+ dc.onopen = () => this.firePeerChange();
8833
+ dc.onclose = () => this.drop(peerId);
8834
+ dc.onmessage = (e) => this.onChunk(peerId, conn, new Uint8Array(e.data));
8835
+ }
8836
+ async onSignal(from, kind, data) {
8837
+ if (kind === "offer") {
8838
+ if (!this.conns.has(from) && this.conns.size >= this.maxPeers)
8839
+ return; // mesh cap
8840
+ const conn = this.conns.get(from) ?? this.newPc(from);
8841
+ await conn.pc.setRemoteDescription(data);
8842
+ const answer = await conn.pc.createAnswer();
8843
+ await conn.pc.setLocalDescription(answer);
8844
+ this.signaler.send(from, "answer", answer);
8845
+ }
8846
+ else if (kind === "answer") {
8847
+ const conn = this.conns.get(from);
8848
+ if (conn)
8849
+ await conn.pc.setRemoteDescription(data);
8850
+ }
8851
+ else if (kind === "ice") {
8852
+ const conn = this.conns.get(from);
8853
+ if (conn && data) {
8854
+ try {
8855
+ await conn.pc.addIceCandidate(data);
8856
+ }
8857
+ catch { /* pre-SRD race */ }
8858
+ }
8859
+ }
8860
+ else if (kind === "bye") {
8861
+ this.drop(from);
8862
+ }
8863
+ }
8864
+ drop(peerId) {
8865
+ const conn = this.conns.get(peerId);
8866
+ if (!conn)
8867
+ return;
8868
+ this.conns.delete(peerId);
8869
+ try {
8870
+ conn.dc?.close();
8871
+ }
8872
+ catch { /* already closing */ }
8873
+ try {
8874
+ conn.pc.close();
8875
+ }
8876
+ catch { /* already closing */ }
8877
+ this.firePeerChange();
8878
+ }
8879
+ firePeerChange() {
8880
+ const peers = this.peers();
8881
+ for (const cb of this.peerCbs)
8882
+ cb(peers);
8883
+ }
8884
+ // -- fragmentation -------------------------------------------------------
8885
+ async sendFragmented(dc, data, channel) {
8886
+ const msgId = this.nextMsgId++ >>> 0;
8887
+ const total = Math.max(1, Math.ceil(data.length / CHUNK));
8888
+ for (let i = 0; i < total; i++) {
8889
+ const slice = data.subarray(i * CHUNK, (i + 1) * CHUNK);
8890
+ const frame = new Uint8Array(HEADER$1 + slice.length);
8891
+ const dv = new DataView(frame.buffer);
8892
+ dv.setUint32(0, msgId);
8893
+ dv.setUint32(4, i);
8894
+ dv.setUint32(8, total);
8895
+ dv.setUint32(12, channel);
8896
+ frame.set(slice, HEADER$1);
8897
+ if (dc.bufferedAmount > MAX_BUFFERED)
8898
+ await this.drain(dc);
8899
+ dc.send(frame);
8900
+ }
8901
+ }
8902
+ drain(dc) {
8903
+ return new Promise((resolve) => {
8904
+ dc.bufferedAmountLowThreshold = MAX_BUFFERED / 2;
8905
+ const onLow = () => { dc.removeEventListener("bufferedamountlow", onLow); resolve(); };
8906
+ dc.addEventListener("bufferedamountlow", onLow);
8907
+ });
8908
+ }
8909
+ onChunk(peerId, conn, frame) {
8910
+ if (frame.length < HEADER$1)
8911
+ return;
8912
+ const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
8913
+ const msgId = dv.getUint32(0);
8914
+ const index = dv.getUint32(4);
8915
+ const total = dv.getUint32(8);
8916
+ const channel = dv.getUint32(12);
8917
+ const chunk = frame.slice(HEADER$1);
8918
+ let r = conn.reasm.get(msgId);
8919
+ if (!r) {
8920
+ r = { chunks: new Array(total), received: 0, total, size: 0, channel };
8921
+ conn.reasm.set(msgId, r);
8922
+ }
8923
+ if (r.chunks[index])
8924
+ return; // dup
8925
+ r.chunks[index] = chunk;
8926
+ r.received++;
8927
+ r.size += chunk.length;
8928
+ if (r.received < r.total)
8929
+ return;
8930
+ conn.reasm.delete(msgId);
8931
+ const out = new Uint8Array(r.size);
8932
+ let off = 0;
8933
+ for (const c of r.chunks) {
8934
+ if (!c)
8935
+ return; // gap — shouldn't happen on reliable ordered
8936
+ out.set(c, off);
8937
+ off += c.length;
8938
+ }
8939
+ for (const cb of this.msgCbs)
8940
+ cb(peerId, out, r.channel);
8941
+ }
8942
+ }
8943
+
8944
+ /**
8945
+ * `PeerTransport` — the pluggable wire under the block engine. It moves opaque
8946
+ * frames between Space peers; it knows nothing about blocks, hashes, or the
8947
+ * protocol. {@link ./WebRtcTransport} implements it over RTCDataChannels today;
8948
+ * a future libp2p/Helia transport can implement the same interface without the
8949
+ * block engine or `ShardClient` changing.
8950
+ *
8951
+ * Implementations are responsible for fragmenting/ reassembling frames larger
8952
+ * than the underlying channel's max message size (a 4 MiB shard won't fit in one
8953
+ * `RTCDataChannel.send`); the engine always deals in whole frames.
8954
+ */
8955
+ /**
8956
+ * Logical channels multiplexed over the one data link. `0` is block exchange
8957
+ * (the engine), `1` is gossip (app/CRDT data). The tag rides the framing header
8958
+ * so payloads aren't copied to prepend it.
8959
+ */
8960
+ const CHANNEL_BLOCK = 0;
8961
+ const CHANNEL_GOSSIP = 1;
8962
+
8963
+ /**
8964
+ * Bitswap-lite wire protocol for peer block exchange. Four frame types over a
8965
+ * {@link ../transport/PeerTransport} data channel:
8966
+ *
8967
+ * WANT(hash) — "does anyone have this block?" / "send it to me"
8968
+ * HAVE(hash) — "I hold this block" (announce / reply to a WANT)
8969
+ * BLOCK(hash, bytes) — the block itself (verified by hash on receipt)
8970
+ * CANCEL(hash) — "stop, I got it elsewhere"
8971
+ *
8972
+ * Frames are compact binary so a 4 MiB shard isn't base64-bloated through JSON:
8973
+ *
8974
+ * byte 0 : type (0 WANT | 1 HAVE | 2 BLOCK | 3 CANCEL)
8975
+ * bytes 1..33 : 32-byte raw SHA-256 of the block (our shard hash, hex-decoded)
8976
+ * bytes 33.. : payload (BLOCK only)
8977
+ *
8978
+ * Blocks are AES-GCM ciphertext, so a frame carries nothing readable; the hash
8979
+ * is the capability check (the receiver re-hashes the payload and drops a
8980
+ * mismatch). See {@link ./blockEngine}.
8981
+ */
8982
+ var FrameType;
8983
+ (function (FrameType) {
8984
+ FrameType[FrameType["WANT"] = 0] = "WANT";
8985
+ FrameType[FrameType["HAVE"] = 1] = "HAVE";
8986
+ FrameType[FrameType["BLOCK"] = 2] = "BLOCK";
8987
+ FrameType[FrameType["CANCEL"] = 3] = "CANCEL";
8988
+ })(FrameType || (FrameType = {}));
8989
+ const HASH_BYTES = 32;
8990
+ const HEADER = 1 + HASH_BYTES; // type + hash
8991
+ function hexToBytes(hex) {
8992
+ if (hex.length !== HASH_BYTES * 2) {
8993
+ throw new Error(`protocol: expected a ${HASH_BYTES}-byte hex hash, got ${hex.length / 2} bytes`);
8994
+ }
8995
+ const out = new Uint8Array(HASH_BYTES);
8996
+ for (let i = 0; i < HASH_BYTES; i++)
8997
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
8998
+ return out;
8999
+ }
9000
+ const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
9001
+ function bytesToHex(bytes) {
9002
+ let s = "";
9003
+ for (let i = 0; i < bytes.length; i++)
9004
+ s += HEX[bytes[i]];
9005
+ return s;
9006
+ }
9007
+ function header(type, hash, extra = 0) {
9008
+ const buf = new Uint8Array(HEADER + extra);
9009
+ buf[0] = type;
9010
+ buf.set(hexToBytes(hash), 1);
9011
+ return { buf, offset: HEADER };
9012
+ }
9013
+ function encodeWant(hash) {
9014
+ return header(FrameType.WANT, hash).buf;
9015
+ }
9016
+ function encodeHave(hash) {
9017
+ return header(FrameType.HAVE, hash).buf;
9018
+ }
9019
+ function encodeCancel(hash) {
9020
+ return header(FrameType.CANCEL, hash).buf;
9021
+ }
9022
+ function encodeBlock(hash, payload) {
9023
+ const { buf, offset } = header(FrameType.BLOCK, hash, payload.length);
9024
+ buf.set(payload, offset);
9025
+ return buf;
9026
+ }
9027
+ function decode(frame) {
9028
+ if (frame.length < HEADER)
9029
+ throw new Error("protocol: frame too short");
9030
+ const type = frame[0];
9031
+ const hash = bytesToHex(frame.subarray(1, HEADER));
9032
+ if (type === FrameType.BLOCK) {
9033
+ // Copy the payload out so it doesn't alias a transferred/pooled buffer.
9034
+ return { type, hash, payload: frame.slice(HEADER) };
9035
+ }
9036
+ return { type, hash };
9037
+ }
9038
+
9039
+ var protocol = /*#__PURE__*/Object.freeze({
9040
+ __proto__: null,
9041
+ get FrameType () { return FrameType; },
9042
+ decode: decode,
9043
+ encodeBlock: encodeBlock,
9044
+ encodeCancel: encodeCancel,
9045
+ encodeHave: encodeHave,
9046
+ encodeWant: encodeWant
9047
+ });
9048
+
9049
+ /**
9050
+ * `BlockEngine` — the bitswap-lite want/have ledger. Host-agnostic: it speaks
9051
+ * only to a {@link BlockStore} (get/put/has) and an `out` sink that ships
9052
+ * encoded frames to a peer (or `"*"` to broadcast). The host wires `out` and
9053
+ * `handleFrame` to a real transport — on the main thread or inside the Web
9054
+ * Worker (see {@link ./engineClient}) — so this class is pure and unit-testable.
9055
+ *
9056
+ * It never sees keys: blocks are AES-GCM ciphertext, addressed by SHA-256. The
9057
+ * one trust check is integrity — an inbound BLOCK is re-hashed and dropped on
9058
+ * mismatch, so a malicious peer can't poison the store (the requester just
9059
+ * falls back to origin).
9060
+ */
9061
+ class BlockEngine {
9062
+ store;
9063
+ out;
9064
+ hash;
9065
+ wants = new Map();
9066
+ constructor(store, out, hash) {
9067
+ this.store = store;
9068
+ this.out = out;
9069
+ this.hash = hash;
9070
+ }
9071
+ /**
9072
+ * Ask the swarm for a block. Resolves with the verified bytes, or `null` on
9073
+ * timeout (caller then falls back to origin). Returns immediately if we
9074
+ * already hold it; concurrent wants for the same hash share one request.
9075
+ */
9076
+ want(hash, timeoutMs) {
9077
+ return new Promise((resolve) => {
9078
+ void this.store.get(hash).then((local) => {
9079
+ if (local)
9080
+ return resolve(local);
9081
+ const existing = this.wants.get(hash);
9082
+ if (existing) {
9083
+ existing.resolvers.push(resolve);
9084
+ return;
9085
+ }
9086
+ const timer = setTimeout(() => {
9087
+ const w = this.wants.get(hash);
9088
+ this.wants.delete(hash);
9089
+ this.out("*", encodeCancel(hash));
9090
+ w?.resolvers.forEach((r) => r(null));
9091
+ }, timeoutMs);
9092
+ this.wants.set(hash, { resolvers: [resolve], timer });
9093
+ this.out("*", encodeWant(hash));
9094
+ });
9095
+ });
9096
+ }
9097
+ /** Tell the swarm we now hold `hash` (so peers wanting it can pull from us). */
9098
+ announce(hash) {
9099
+ this.out("*", encodeHave(hash));
9100
+ }
9101
+ /** Handle one inbound frame from `peer`. */
9102
+ async handleFrame(peer, frame) {
9103
+ let f;
9104
+ try {
9105
+ f = decode(frame);
9106
+ }
9107
+ catch {
9108
+ return; // malformed — ignore
9109
+ }
9110
+ switch (f.type) {
9111
+ case FrameType.WANT: {
9112
+ const bytes = await this.store.get(f.hash);
9113
+ if (bytes)
9114
+ this.out(peer, encodeBlock(f.hash, bytes));
9115
+ return;
9116
+ }
9117
+ case FrameType.HAVE: {
9118
+ // A peer announced a block we're still waiting for → pull from them.
9119
+ if (this.wants.has(f.hash))
9120
+ this.out(peer, encodeWant(f.hash));
9121
+ return;
9122
+ }
9123
+ case FrameType.BLOCK: {
9124
+ const pending = this.wants.get(f.hash);
9125
+ if (!pending || !f.payload)
9126
+ return; // unsolicited / empty — ignore
9127
+ // Integrity: a peer can't poison the store. Drop a mismatch and
9128
+ // keep waiting (the timeout will fall back to origin).
9129
+ const got = await this.hash(f.payload);
9130
+ if (got !== f.hash)
9131
+ return;
9132
+ await this.store.put(f.hash, f.payload);
9133
+ this.wants.delete(f.hash);
9134
+ clearTimeout(pending.timer);
9135
+ this.out("*", encodeCancel(f.hash)); // others can stop sending
9136
+ pending.resolvers.forEach((r) => r(f.payload));
9137
+ return;
9138
+ }
9139
+ case FrameType.CANCEL:
9140
+ // We serve WANTs synchronously, so there's nothing in flight to
9141
+ // cancel yet. Reserved for chunked-send cancellation later.
9142
+ return;
9143
+ }
9144
+ }
9145
+ /** Drop all pending waits (resolve null) — on teardown. */
9146
+ close() {
9147
+ for (const [, w] of this.wants) {
9148
+ clearTimeout(w.timer);
9149
+ w.resolvers.forEach((r) => r(null));
9150
+ }
9151
+ this.wants.clear();
9152
+ }
9153
+ }
9154
+
9155
+ /**
9156
+ * `EngineHost` — the boundary {@link ../PeerNetwork} wires a transport to. It
9157
+ * abstracts *where* the {@link ./blockEngine.BlockEngine} runs:
9158
+ * - {@link LocalEngineHost} runs it on the calling thread (tests, or a
9159
+ * fallback when the Web Worker can't be built by a consumer's bundler);
9160
+ * - the Worker proxy ({@link ./engineClient}) runs it off-thread.
9161
+ *
9162
+ * Either way the host exposes the same surface: drive `want`/`announce`, feed it
9163
+ * inbound frames via `handleFrame`, and receive its outbound frames via
9164
+ * `onOutbound` (which the network forwards to the transport).
9165
+ */
9166
+ /** Runs the engine in-process (main thread or test). */
9167
+ class LocalEngineHost {
9168
+ engine;
9169
+ cbs = new Set();
9170
+ constructor(store, hasher) {
9171
+ this.engine = new BlockEngine(store, (target, frame) => {
9172
+ for (const cb of this.cbs)
9173
+ cb(target, frame);
9174
+ }, hasher);
9175
+ }
9176
+ want(hash, timeoutMs) {
9177
+ return this.engine.want(hash, timeoutMs);
9178
+ }
9179
+ announce(hash) {
9180
+ this.engine.announce(hash);
9181
+ }
9182
+ handleFrame(peer, frame) {
9183
+ void this.engine.handleFrame(peer, frame);
9184
+ }
9185
+ onOutbound(cb) {
9186
+ this.cbs.add(cb);
9187
+ return () => this.cbs.delete(cb);
9188
+ }
9189
+ close() {
9190
+ this.engine.close();
9191
+ this.cbs.clear();
9192
+ }
9193
+ }
9194
+
9195
+ /**
9196
+ * `WorkerEngineHost` — the main-thread proxy to a {@link ./blockEngine.BlockEngine}
9197
+ * running inside a dedicated Web Worker (so hashing, blockstore I/O, and the
9198
+ * protocol codec stay off the main thread). It speaks the small message protocol
9199
+ * in {@link ./blockEngine.worker} and satisfies {@link ./engineHost.EngineHost},
9200
+ * so {@link ../PeerNetwork} treats it identically to the in-process host.
9201
+ *
9202
+ * Block bytes cross the worker boundary as **transferable** ArrayBuffers
9203
+ * (zero-copy). Only encrypted blocks ever cross — keys never enter the worker.
9204
+ */
9205
+ class WorkerEngineHost {
9206
+ worker;
9207
+ wants = new Map();
9208
+ cbs = new Set();
9209
+ nextId = 1;
9210
+ constructor(worker) {
9211
+ this.worker = worker;
9212
+ this.worker.onmessage = (e) => {
9213
+ const m = e.data;
9214
+ if (m.t === "out") {
9215
+ const frame = new Uint8Array(m.data);
9216
+ for (const cb of this.cbs)
9217
+ cb(m.target, frame);
9218
+ }
9219
+ else if (m.t === "want-result") {
9220
+ const resolve = this.wants.get(m.id);
9221
+ if (resolve) {
9222
+ this.wants.delete(m.id);
9223
+ resolve(m.data ? new Uint8Array(m.data) : null);
9224
+ }
9225
+ }
9226
+ };
9227
+ }
9228
+ want(hash, timeoutMs) {
9229
+ return new Promise((resolve) => {
9230
+ const id = this.nextId++;
9231
+ this.wants.set(id, resolve);
9232
+ this.worker.postMessage({ t: "want", id, hash, timeout: timeoutMs });
9233
+ });
9234
+ }
9235
+ announce(hash) {
9236
+ this.worker.postMessage({ t: "announce", hash });
9237
+ }
9238
+ handleFrame(peer, frame) {
9239
+ // Transfer an exact-size buffer; copy if the view doesn't own the whole buffer.
9240
+ const buf = frame.byteOffset === 0 && frame.byteLength === frame.buffer.byteLength
9241
+ ? frame.buffer
9242
+ : frame.slice().buffer;
9243
+ this.worker.postMessage({ t: "frame", peer, data: buf }, [buf]);
9244
+ }
9245
+ onOutbound(cb) {
9246
+ this.cbs.add(cb);
9247
+ return () => this.cbs.delete(cb);
9248
+ }
9249
+ close() {
9250
+ try {
9251
+ this.worker.postMessage({ t: "close" });
9252
+ }
9253
+ catch { /* already gone */ }
9254
+ this.worker.terminate();
9255
+ this.wants.forEach((r) => r(null));
9256
+ this.wants.clear();
9257
+ this.cbs.clear();
9258
+ }
9259
+ }
9260
+
9261
+ /**
9262
+ * `PeerNetwork` — the per-Space orchestrator that ties the pieces together on
9263
+ * the main thread:
9264
+ *
9265
+ * transport (WebRTC) ⇄ engine host (Worker or in-process) → PeerExchange
9266
+ * ▲ │
9267
+ * └────────────── signaling over the Space relay ─────────────┘
9268
+ *
9269
+ * It owns one {@link ./transport/WebRtcTransport} + one {@link
9270
+ * ./worker/engineHost.EngineHost}, wires inbound frames to the engine and the
9271
+ * engine's outbound frames back to the transport, runs peer discovery over the
9272
+ * Space's ephemeral channel, and exposes a {@link ./PeerExchange.PeerExchange}
9273
+ * for `ShardClient` to consult. Scope is one Space (private swarm).
9274
+ *
9275
+ * `engineHost` defaults to in-process; pass `workerFactory` to run the block
9276
+ * engine off the main thread (the consumer's bundler resolves the worker URL).
9277
+ */
9278
+ /**
9279
+ * Build the engine host, preferring an off-thread Web Worker:
9280
+ * 1. an explicit `workerFactory` (most reliable in app bundlers), else
9281
+ * 2. the bundled worker resolved relative to this module (works where the
9282
+ * bundler supports `new Worker(new URL(...))`, e.g. Vite builds), else
9283
+ * 3. the in-process engine (always works; tests + non-worker runtimes).
9284
+ * Any worker construction failure degrades to in-process.
9285
+ */
9286
+ function buildEngineHost(opts) {
9287
+ try {
9288
+ const worker = opts.workerFactory ? opts.workerFactory() : defaultWorker();
9289
+ if (worker)
9290
+ return new WorkerEngineHost(worker);
9291
+ }
9292
+ catch {
9293
+ /* no Worker global / bundler can't resolve it → in-process */
9294
+ }
9295
+ return new LocalEngineHost(opts.store ?? new ShardCache(), shardHash);
9296
+ }
9297
+ /** Construct the bundled worker if the runtime + bundler support it, else null. */
9298
+ function defaultWorker() {
9299
+ try {
9300
+ if (typeof Worker === "undefined")
9301
+ return null;
9302
+ // The string is resolved by the consumer's bundler relative to the
9303
+ // built connect module (sibling of dist/browser/index.js).
9304
+ return new Worker(new URL("./blockEngine.worker.js", import.meta.url), { type: "module" });
9305
+ }
9306
+ catch {
9307
+ return null;
9308
+ }
9309
+ }
9310
+ class PeerNetwork {
9311
+ /** Pass this to a `ShardClient` as `peers`. */
9312
+ exchange;
9313
+ transport;
9314
+ host;
9315
+ signaler;
9316
+ offs = [];
9317
+ gossipCbs = new Set();
9318
+ closed = false;
9319
+ constructor(opts) {
9320
+ this.signaler = new SpaceSignaler(opts.space, opts.myId);
9321
+ this.transport =
9322
+ opts.transport ??
9323
+ new WebRtcTransport(this.signaler, opts.myId, {
9324
+ iceServers: opts.iceServers,
9325
+ maxPeers: opts.maxPeers,
9326
+ });
9327
+ this.host = buildEngineHost(opts);
9328
+ // Wire transport ⇄ engine, demuxing block (engine) from gossip (app).
9329
+ this.offs.push(this.transport.onMessage((peer, data, channel) => {
9330
+ if (channel === CHANNEL_GOSSIP) {
9331
+ for (const cb of this.gossipCbs)
9332
+ cb(peer, data);
9333
+ }
9334
+ else {
9335
+ this.host.handleFrame(peer, data);
9336
+ }
9337
+ }));
9338
+ this.offs.push(this.host.onOutbound((target, frame) => target === "*"
9339
+ ? this.transport.broadcast(frame, CHANNEL_BLOCK)
9340
+ : this.transport.send(target, frame, CHANNEL_BLOCK)));
9341
+ // Discovery: a broadcast hello triggers a unicast hello back (so a
9342
+ // newcomer learns existing members); both sides connectTo (the smaller
9343
+ // id actually dials). Deterministic + idempotent.
9344
+ this.offs.push(this.signaler.onSignal((from, kind, data) => {
9345
+ if (kind !== "hello")
9346
+ return;
9347
+ this.transport.connectTo?.(from);
9348
+ if (data?.broadcast) {
9349
+ this.signaler.send(from, "hello", { broadcast: false });
9350
+ }
9351
+ }));
9352
+ this.exchange = new PeerExchange(this.host, { timeoutMs: opts.timeoutMs, debug: opts.debug });
9353
+ if (opts.debug) {
9354
+ console.info("[muhkoo:p2p] mesh attached for space; awaiting peers…");
9355
+ this.offs.push(this.transport.onPeerChange((peers) => console.info(`%c[muhkoo:p2p] connected peers: ${peers.length}`, "color:#58da7d", peers)));
9356
+ }
9357
+ }
9358
+ /**
9359
+ * Begin peer discovery — broadcast a hello so existing members dial us and
9360
+ * we learn them. Call once the signaling channel is live (the Space fires
9361
+ * this on CONNECTED); safe to call again on reconnect.
9362
+ */
9363
+ start() {
9364
+ if (this.closed)
9365
+ return;
9366
+ this.signaler.broadcast("hello", { broadcast: true });
9367
+ }
9368
+ /**
9369
+ * Broadcast an app payload to all connected peers over the gossip channel —
9370
+ * a direct, server-bypassing data path for CRDT ops / high-frequency state
9371
+ * (cursors, presence, collaborative edits). Single-hop (no re-forwarding).
9372
+ */
9373
+ gossip(data) {
9374
+ this.transport.broadcast(data, CHANNEL_GOSSIP);
9375
+ }
9376
+ /** Subscribe to gossip from peers. Returns an unsubscribe function. */
9377
+ onGossip(cb) {
9378
+ this.gossipCbs.add(cb);
9379
+ return () => this.gossipCbs.delete(cb);
9380
+ }
9381
+ /** Connected peer count (open data channels). */
9382
+ peerCount() {
9383
+ return this.transport.peers().length;
9384
+ }
9385
+ close() {
9386
+ if (this.closed)
9387
+ return;
9388
+ this.closed = true;
9389
+ try {
9390
+ this.signaler.broadcast("bye");
9391
+ }
9392
+ catch { /* socket may be gone */ }
9393
+ for (const off of this.offs)
9394
+ off();
9395
+ this.transport.close();
9396
+ this.host.close();
9397
+ }
9398
+ }
9399
+
9400
+ /**
9401
+ * Whether this environment can do WebRTC peering. `RTCPeerConnection` is
9402
+ * Window-only (absent in Node/Workers/SSR), so this gates whether a
9403
+ * {@link ./PeerNetwork} can be built at all.
9404
+ */
9405
+ function isP2pCapable() {
9406
+ try {
9407
+ return typeof RTCPeerConnection !== "undefined";
9408
+ }
9409
+ catch {
9410
+ return false;
9411
+ }
9412
+ }
9413
+
8313
9414
  /**
8314
9415
  * `client.space` — the fan-out group-encryption surface.
8315
9416
  *
@@ -8541,13 +9642,52 @@ class SpaceNamespace {
8541
9642
  : undefined,
8542
9643
  });
8543
9644
  this.openSpaces.set(spaceId, space);
9645
+ // Attach a private P2P swarm scoped to this Space (block exchange among
9646
+ // members; signaled over the Space relay). Discovery starts on connect.
9647
+ if (this.deps.p2p?.enabled && isP2pCapable()) {
9648
+ space.attachPeerNetwork(new PeerNetwork({
9649
+ space,
9650
+ myId: memberId,
9651
+ workerFactory: this.deps.p2p.workerFactory,
9652
+ iceServers: this.deps.p2p.iceServers,
9653
+ maxPeers: this.deps.p2p.maxPeers,
9654
+ debug: this.deps.p2p.debug,
9655
+ }));
9656
+ }
8544
9657
  return space;
8545
9658
  }
8546
9659
  /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
8547
9660
  async ensureIdentity(memberId) {
8548
9661
  const store = KeyStore.getInstance();
8549
9662
  if (!store.getKeyPair(memberId)) {
8550
- await store.generateOwnKeyPair(memberId);
9663
+ // Unlocked (seed in hand): rehydrate the member's STABLE keypair from
9664
+ // the vault, or provision + persist one on first use. This keeps the
9665
+ // member the same identity across reloads — no re-admit each session,
9666
+ // and the group-key cache round-trips. Best-effort: a personal-space
9667
+ // hiccup falls through to an ephemeral keypair (today's behavior).
9668
+ const seed = this.deps.session.seed;
9669
+ const staging = (() => { try {
9670
+ return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
9671
+ }
9672
+ catch {
9673
+ return false;
9674
+ } })();
9675
+ if (staging)
9676
+ console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
9677
+ if (this.deps.chatKeys && seed) {
9678
+ try {
9679
+ await this.deps.chatKeys.ensure(memberId, toBase64$1(seed));
9680
+ }
9681
+ catch (e) {
9682
+ if (staging)
9683
+ console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
9684
+ }
9685
+ }
9686
+ if (!store.getKeyPair(memberId)) {
9687
+ if (staging)
9688
+ console.info("[muhkoo:vault] ensureIdentity: EPHEMERAL keypair (locked / no vault) → will re-admit");
9689
+ await store.generateOwnKeyPair(memberId);
9690
+ }
8551
9691
  }
8552
9692
  const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
8553
9693
  const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
@@ -8788,6 +9928,112 @@ class FunctionsNamespace {
8788
9928
  }
8789
9929
  }
8790
9930
 
9931
+ /**
9932
+ * `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
9933
+ * keypair (the connect `KeyStore` entry keyed by the member id / username).
9934
+ *
9935
+ * Historically each app hand-rolled this (see the web app's old
9936
+ * `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
9937
+ * PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
9938
+ * That scaffolding now lives here so ANY app gets a **stable** member keypair
9939
+ * across reloads for free — without which the member re-admits to every space on
9940
+ * every load and the group-key cache can never round-trip.
9941
+ *
9942
+ * Security invariant: the wrapping secret is the **master seed** (not the
9943
+ * password), so the blob survives password changes and unlocks under a passkey.
9944
+ * The seed is only ever held in memory (never persisted), so this vault can only
9945
+ * provision/rehydrate while the client is unlocked.
9946
+ */
9947
+ /** Staging-only diagnostics: is the member keypair stable across reloads? */
9948
+ const VAULT_DEBUG = (() => {
9949
+ try {
9950
+ return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
9951
+ }
9952
+ catch {
9953
+ return false;
9954
+ }
9955
+ })();
9956
+ function vlog(msg) {
9957
+ if (VAULT_DEBUG)
9958
+ console.info(`[muhkoo:vault] ${msg}`);
9959
+ }
9960
+ /** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
9961
+ async function fingerprint(memberId) {
9962
+ try {
9963
+ const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
9964
+ if (!raw)
9965
+ return "none";
9966
+ let s = 0;
9967
+ for (const b of raw)
9968
+ s = (s * 31 + b) >>> 0;
9969
+ return s.toString(16).padStart(8, "0");
9970
+ }
9971
+ catch {
9972
+ return "err";
9973
+ }
9974
+ }
9975
+ /** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
9976
+ const CHAT_KEYS_KEY = "chat-keys";
9977
+ class ChatKeyVault {
9978
+ store;
9979
+ constructor(store) {
9980
+ this.store = store;
9981
+ }
9982
+ /**
9983
+ * Register-time: ensure a keypair exists for `memberId`, seed-wrap the
9984
+ * dehydrated form, and persist it to the personal space. Idempotent — reuses
9985
+ * an existing in-tab keypair rather than minting a second one.
9986
+ */
9987
+ async provision(memberId, seedSecret) {
9988
+ const ks = KeyStore.getInstance();
9989
+ if (!ks.getKeyPair(memberId)) {
9990
+ try {
9991
+ await ks.generateOwnKeyPair(memberId);
9992
+ }
9993
+ catch (e) {
9994
+ if (!String(e?.message ?? e).includes("already exists"))
9995
+ throw e;
9996
+ }
9997
+ }
9998
+ const dehydrated = await ks.dehydrateKeyPair(memberId);
9999
+ const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
10000
+ await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
10001
+ vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
10002
+ }
10003
+ /**
10004
+ * Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
10005
+ * hydrate the `KeyStore` — restoring the SAME keypair the member had before.
10006
+ * Returns `true` when a keypair is now in hand (already present or hydrated),
10007
+ * `false` when there's no blob yet (a brand-new member → caller provisions).
10008
+ * Throws only on a genuine unwrap failure (wrong secret / tampered blob).
10009
+ */
10010
+ async rehydrate(memberId, seedSecret) {
10011
+ // Already unlocked in this tab — nothing to do.
10012
+ if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
10013
+ return true;
10014
+ const wrapped = await this.store.get(CHAT_KEYS_KEY);
10015
+ if (!wrapped) {
10016
+ vlog("rehydrate: no chat-keys blob (new member → will provision)");
10017
+ return false;
10018
+ }
10019
+ const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
10020
+ const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
10021
+ await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
10022
+ vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
10023
+ return true;
10024
+ }
10025
+ /**
10026
+ * Ensure a stable keypair is in hand: rehydrate from the vault if a blob
10027
+ * exists, otherwise mint one and persist it (so it's stable next session).
10028
+ * Used at register and lazily when a space is first opened.
10029
+ */
10030
+ async ensure(memberId, seedSecret) {
10031
+ if (await this.rehydrate(memberId, seedSecret))
10032
+ return;
10033
+ await this.provision(memberId, seedSecret);
10034
+ }
10035
+ }
10036
+
8791
10037
  /**
8792
10038
  * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
8793
10039
  * lexicographically-sortable string so the whole offline layer (and the
@@ -9304,82 +10550,6 @@ class SnapshotNamespace {
9304
10550
  }
9305
10551
  }
9306
10552
 
9307
- /**
9308
- * `ShardCache` — a Cache-API-backed store for file-shard bytes. Shards are
9309
- * content-addressed (the key IS the SHA-256 of the ciphertext), so caching them
9310
- * is uniquely safe: a cache hit is self-validating, immutable, and shareable
9311
- * across files. The Cache API (rather than IndexedDB) is the right home for
9312
- * large binary blobs — it streams, it's purpose-built for response bodies, and
9313
- * it has its own eviction.
9314
- *
9315
- * Keys are SYNTHETIC absolute URLs (`https://shard.muhkoo.local/<hash>`) rather
9316
- * than the real shard endpoint, so the cache is addressable by hash alone — the
9317
- * sync engine can re-PUT a deferred shard knowing only its hash, without
9318
- * reconstructing the accelerator URL. We never fetch these synthetic URLs; they
9319
- * exist only as cache keys.
9320
- *
9321
- * Every method degrades to a no-op / null where the Cache API is unavailable
9322
- * (Node, Workers, insecure contexts), so callers don't need to branch.
9323
- */
9324
- const CACHE_NAME = "muhkoo-shards-v1";
9325
- class ShardCache {
9326
- static available() {
9327
- try {
9328
- return typeof caches !== "undefined";
9329
- }
9330
- catch {
9331
- return false;
9332
- }
9333
- }
9334
- key(hash) {
9335
- return `https://shard.muhkoo.local/${encodeURIComponent(hash)}`;
9336
- }
9337
- open() {
9338
- return caches.open(CACHE_NAME);
9339
- }
9340
- /** Cached shard bytes, or `null` on miss / when unavailable. */
9341
- async get(hash) {
9342
- if (!ShardCache.available())
9343
- return null;
9344
- try {
9345
- const cache = await this.open();
9346
- const res = await cache.match(this.key(hash));
9347
- if (!res)
9348
- return null;
9349
- return new Uint8Array(await res.arrayBuffer());
9350
- }
9351
- catch {
9352
- return null;
9353
- }
9354
- }
9355
- /** Store shard bytes under their hash. Best-effort. */
9356
- async put(hash, bytes) {
9357
- if (!ShardCache.available())
9358
- return;
9359
- try {
9360
- const cache = await this.open();
9361
- await cache.put(this.key(hash), new Response(bytes, {
9362
- headers: { "content-type": "application/octet-stream" },
9363
- }));
9364
- }
9365
- catch {
9366
- /* quota / unavailable — degrade to online-only for this shard */
9367
- }
9368
- }
9369
- /** Whether a shard is present in the cache. */
9370
- async has(hash) {
9371
- if (!ShardCache.available())
9372
- return false;
9373
- try {
9374
- const cache = await this.open();
9375
- return Boolean(await cache.match(this.key(hash)));
9376
- }
9377
- catch {
9378
- return false;
9379
- }
9380
- }
9381
- }
9382
-
9383
10553
  /**
9384
10554
  * IndexedDB schema for the offline layer — the single source of truth for the
9385
10555
  * database name, version, and object stores. Bump {@link DB_VERSION} whenever
@@ -10091,17 +11261,46 @@ class Client {
10091
11261
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
10092
11262
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
10093
11263
  // by client.kv), so a returning member hydrates without a fresh keyring
10094
- // round-trip. The server only ever sees the ciphertext.
11264
+ // round-trip. The server only ever sees ciphertext.
11265
+ //
11266
+ // `encrypt: false` is deliberate: the keyring already ECIES-wraps each
11267
+ // key to the member's identity (server-blind), and StorageCipher would
11268
+ // additionally require the FULL unlocked identity (`requireIdentity()`),
11269
+ // which is locked after a page reload — that coupling is exactly why the
11270
+ // cache previously never read or persisted. The wrapped blobs are opaque.
10095
11271
  const spaceKeyCache = {
10096
11272
  loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
10097
- saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
11273
+ saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys, { encrypt: false }),
11274
+ };
11275
+ // SDK-owned vault for the member's long-lived ratchet/space keypair.
11276
+ // Stored at the personal-space `chat-keys` key (same key the app used to
11277
+ // manage by hand), seed-wrapped, session-token authed (no snarkjs). This
11278
+ // gives every app a STABLE keypair across reloads — no per-app scaffolding
11279
+ // — so members don't re-admit each load and the group-key cache works.
11280
+ const chatKeyStore = {
11281
+ get: async (key) => {
11282
+ const c = this.session.commitment;
11283
+ if (!c)
11284
+ return null;
11285
+ const res = await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}/get`, {});
11286
+ return (res?.value ?? null);
11287
+ },
11288
+ put: async (key, value) => {
11289
+ const c = this.session.commitment;
11290
+ if (!c)
11291
+ throw new Error("ChatKeyVault: no session — sign in first.");
11292
+ await this.http.post(`/api/personal/${encodeURIComponent(c)}/kv/${encodeURIComponent(key)}`, { value });
11293
+ },
10098
11294
  };
11295
+ const chatKeys = new ChatKeyVault(chatKeyStore);
10099
11296
  this.space = new SpaceNamespace({
10100
11297
  http: this.http,
10101
11298
  session: this.session,
10102
11299
  wsBaseUrl,
10103
11300
  cache: spaceKeyCache,
10104
11301
  offline: this.offline,
11302
+ p2p: options.p2p,
11303
+ chatKeys,
10105
11304
  });
10106
11305
  this.agents = new AgentsNamespace({ http: this.http });
10107
11306
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
@@ -10523,5 +11722,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
10523
11722
  default: loadRsWasm
10524
11723
  });
10525
11724
 
10526
- 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_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, 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, 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, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
11725
+ 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_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DbCache, 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, 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, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, buildCommitment, compareHlc, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, generatePkce, getMuhkooAppDescriptor, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isOfflineCapable, isP2pCapable, isProvisional, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, sealSeed, shardHash, signMessage, stableStringify, toField, tombstoneRow, unpack, unsealSeed, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
10527
11726
  //# sourceMappingURL=index.js.map