@muhkoo/connect 0.2.0-alpha.2 → 0.3.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.
@@ -2562,7 +2562,7 @@ async function poseidonHash(inputs) {
2562
2562
  * worker depending on `VITE_WORKER_URL`.
2563
2563
  */
2564
2564
  let _snarkjs$1 = null;
2565
- async function loadSnarkjs$2() {
2565
+ async function loadSnarkjs$1() {
2566
2566
  if (_snarkjs$1)
2567
2567
  return _snarkjs$1;
2568
2568
  _snarkjs$1 = (await import('snarkjs'));
@@ -2584,7 +2584,7 @@ function defaultCircuitUrls(baseUrl) {
2584
2584
  };
2585
2585
  }
2586
2586
  /** BN254 scalar field modulus — the prime the circuit's signals live in. */
2587
- const FIELD_SIZE$2 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
2587
+ const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
2588
2588
  /**
2589
2589
  * Reduce any hex/decimal/byte input to a decimal-string field element in
2590
2590
  * `[0, FIELD_SIZE)`. snarkjs and the `preimagePoK` circuit both want decimal
@@ -2625,9 +2625,9 @@ function toField(value) {
2625
2625
  hex += b.toString(16).padStart(2, "0");
2626
2626
  v = BigInt("0x" + hex);
2627
2627
  }
2628
- v = v % FIELD_SIZE$2;
2628
+ v = v % FIELD_SIZE$1;
2629
2629
  if (v < 0n)
2630
- v += FIELD_SIZE$2;
2630
+ v += FIELD_SIZE$1;
2631
2631
  return v.toString();
2632
2632
  }
2633
2633
  /**
@@ -2667,7 +2667,7 @@ async function generateAuthProof(args) {
2667
2667
  salt: saltField,
2668
2668
  ecdsaPub: ecdsaPubField,
2669
2669
  };
2670
- const snarkjs = await loadSnarkjs$2();
2670
+ const snarkjs = await loadSnarkjs$1();
2671
2671
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(circuitInput, args.circuits.wasmUrl, args.circuits.zkeyUrl);
2672
2672
  return {
2673
2673
  proof: proof,
@@ -2752,6 +2752,7 @@ class HttpClient {
2752
2752
  baseUrl;
2753
2753
  apiKey;
2754
2754
  getSessionToken;
2755
+ onUnauthorized;
2755
2756
  fetchFn;
2756
2757
  constructor(opts) {
2757
2758
  if (!opts?.baseUrl)
@@ -2759,6 +2760,7 @@ class HttpClient {
2759
2760
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
2760
2761
  this.apiKey = opts.apiKey ?? null;
2761
2762
  this.getSessionToken = opts.getSessionToken ?? (() => null);
2763
+ this.onUnauthorized = opts.onUnauthorized ?? null;
2762
2764
  const f = opts.fetch ?? globalThis.fetch;
2763
2765
  if (typeof f !== "function") {
2764
2766
  throw new Error("HttpClient: `globalThis.fetch` is unavailable; pass an explicit `fetch`.");
@@ -2791,7 +2793,7 @@ class HttpClient {
2791
2793
  del(path, body) {
2792
2794
  return this.request("DELETE", path, body);
2793
2795
  }
2794
- async request(method, path, body) {
2796
+ async request(method, path, body, allowRecovery = true) {
2795
2797
  const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
2796
2798
  const init = { method };
2797
2799
  if (body !== undefined) {
@@ -2799,6 +2801,15 @@ class HttpClient {
2799
2801
  init.body = JSON.stringify(body);
2800
2802
  }
2801
2803
  const res = await this.fetch(url, init);
2804
+ // Stale session: try to silently re-auth and replay the request once.
2805
+ // `allowRecovery` is cleared on the retry so a still-401 response (e.g.
2806
+ // recovery minted a token the endpoint still rejects) can't loop.
2807
+ if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
2808
+ const recovered = await this.onUnauthorized();
2809
+ if (recovered) {
2810
+ return this.request(method, path, body, false);
2811
+ }
2812
+ }
2802
2813
  let parsed = null;
2803
2814
  try {
2804
2815
  parsed = await res.json();
@@ -3028,6 +3039,44 @@ class ZkAuth {
3028
3039
  */
3029
3040
  async login(username, password, opts = {}) {
3030
3041
  const identity = await deriveIdentity(username, password);
3042
+ return this.proveAndStore(username, identity, opts);
3043
+ }
3044
+ /**
3045
+ * Silently re-authenticate the current user without prompting for a
3046
+ * password. Only possible while the client is **unlocked** — i.e. the
3047
+ * derived {@link ZkIdentity} (secret + keypairs) is still in memory from a
3048
+ * prior `login()`/`unlock()`. Re-runs the full challenge→proof→authenticate
3049
+ * dance with that in-memory identity and swaps in the fresh token.
3050
+ *
3051
+ * Returns `true` if a new session was minted, `false` if recovery isn't
3052
+ * possible (no active user, or locked — the identity was never derived or
3053
+ * was lost on reload). A `false` result is the app's cue to send the user
3054
+ * back to the login screen.
3055
+ *
3056
+ * Used automatically by the {@link Client} when a token-gated request comes
3057
+ * back `401` (stale/expired token), so transient session expiry self-heals
3058
+ * instead of surfacing errors mid-session.
3059
+ */
3060
+ async recover() {
3061
+ const username = this.deps.session.username;
3062
+ const identity = this.deps.session.identity;
3063
+ if (!username || !identity)
3064
+ return false;
3065
+ try {
3066
+ await this.proveAndStore(username, identity, {});
3067
+ return true;
3068
+ }
3069
+ catch {
3070
+ return false;
3071
+ }
3072
+ }
3073
+ /**
3074
+ * Shared sign-in core: prove knowledge of `identity` against a fresh
3075
+ * challenge and persist the resulting session + identity. Used by both
3076
+ * `login()` (identity derived from a password) and `recover()` (identity
3077
+ * already in memory).
3078
+ */
3079
+ async proveAndStore(username, identity, opts) {
3031
3080
  const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
3032
3081
  const challenge = await this.deps.auth.getChallenge(username);
3033
3082
  const { proof, publicSignals, commitment } = await generateAuthProof({
@@ -3184,144 +3233,6 @@ class StorageCipher {
3184
3233
  }
3185
3234
  }
3186
3235
 
3187
- /**
3188
- * `client.storage` — per-user persistent storage backed by the accelerator's
3189
- * personal space.
3190
- *
3191
- * await client.storage.set('todos', id, { title, completed })
3192
- * const todo = await client.storage.get('todos', id)
3193
- * const ids = await client.storage.list('todos')
3194
- * await client.storage.delete('todos', id)
3195
- * const off = client.storage.on('change', e => …) // realtime, cross-device
3196
- *
3197
- * A *collection* is a key namespace within the user's space; the stored key is
3198
- * `${collection}/${id}`. Operations are authorized by the user's session token
3199
- * (stamped on every request by {@link HttpClient}) — no per-op Groth16 proof.
3200
- *
3201
- * Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
3202
- * key derived from the ZK identity); pass `{ encrypt: false }` to store
3203
- * plaintext. The realtime feed rides the personal space's own websocket, so no
3204
- * separate transport is involved.
3205
- *
3206
- * `query()` is intentionally deferred — see the SDK overhaul plan.
3207
- */
3208
- class StorageNamespace {
3209
- deps;
3210
- cipher = null;
3211
- cipherIdentity = null;
3212
- constructor(deps) {
3213
- this.deps = deps;
3214
- }
3215
- /** Store `value` under `collection`/`id` (encrypted at rest by default). */
3216
- async set(collection, id, value, opts) {
3217
- const encrypt = opts?.encrypt !== false;
3218
- const stored = encrypt ? await this.getCipher().encrypt(value) : value;
3219
- await this.deps.http.post(this.kvPath(collection, id), { value: stored });
3220
- }
3221
- /** Fetch the value at `collection`/`id`, or `null` if absent. */
3222
- async get(collection, id) {
3223
- const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
3224
- return this.decode(res.value);
3225
- }
3226
- /** Delete `collection`/`id`. Returns whether the key existed. */
3227
- async delete(collection, id) {
3228
- const res = await this.deps.http.del(this.kvPath(collection, id), {});
3229
- return Boolean(res.existed);
3230
- }
3231
- /** List the ids present in `collection`. */
3232
- async list(collection) {
3233
- const res = await this.deps.http.post(this.spacePath("/list"), {});
3234
- const prefix = `${collection}/`;
3235
- return (res.keys ?? [])
3236
- .filter((k) => k.startsWith(prefix))
3237
- .map((k) => k.slice(prefix.length));
3238
- }
3239
- /** Reserved — server-side query is deferred (see the overhaul plan). */
3240
- async query(_collection, _filter) {
3241
- throw new Error("client.storage.query is not implemented yet (deferred in the SDK overhaul).");
3242
- }
3243
- /**
3244
- * Subscribe to realtime changes to this user's data across devices. Opens
3245
- * the personal space's websocket and decrypts each change before handing
3246
- * it to `handler`. Returns an unsubscribe function.
3247
- *
3248
- * Requires a `WebSocket` global (browsers; Node 22+ or a `ws` shim).
3249
- */
3250
- on(event, handler) {
3251
- if (event !== "change")
3252
- throw new Error(`client.storage: unknown event "${event}"`);
3253
- const WS = globalThis.WebSocket;
3254
- if (typeof WS !== "function") {
3255
- throw new Error("client.storage.on: no `WebSocket` global available in this runtime.");
3256
- }
3257
- const token = this.deps.session.token;
3258
- if (!token)
3259
- throw new Error("client.storage.on: not signed in.");
3260
- const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(this.commitment())}/websocket?session=${encodeURIComponent(token)}`;
3261
- const socket = new WS(url);
3262
- socket.addEventListener("message", (ev) => {
3263
- void this.dispatchChange(ev.data, handler);
3264
- });
3265
- return () => {
3266
- try {
3267
- socket.close();
3268
- }
3269
- catch { /* already closed */ }
3270
- };
3271
- }
3272
- // -------------------------------------------------------------------------
3273
- // Internals
3274
- // -------------------------------------------------------------------------
3275
- async dispatchChange(raw, handler) {
3276
- let frame;
3277
- try {
3278
- frame = typeof raw === "string" ? JSON.parse(raw) : raw;
3279
- }
3280
- catch {
3281
- return;
3282
- }
3283
- if (!frame || frame._t !== "change" || typeof frame.key !== "string")
3284
- return;
3285
- const slash = frame.key.indexOf("/");
3286
- if (slash < 0)
3287
- return;
3288
- const collection = frame.key.slice(0, slash);
3289
- const id = frame.key.slice(slash + 1);
3290
- const data = frame.op === "set" ? await this.decode(frame.value) : null;
3291
- handler({ collection, id, type: frame.op, data });
3292
- }
3293
- /** Decode a stored value: decrypt envelopes, pass plaintext through. */
3294
- async decode(value) {
3295
- if (value == null)
3296
- return null;
3297
- if (StorageCipher.isEnvelope(value))
3298
- return this.getCipher().decrypt(value);
3299
- return value;
3300
- }
3301
- commitment() {
3302
- const c = this.deps.session.commitment;
3303
- if (!c)
3304
- throw new Error("client.storage: not signed in — call client.auth.zk.login() first.");
3305
- return c;
3306
- }
3307
- spacePath(suffix) {
3308
- return `/api/personal/${encodeURIComponent(this.commitment())}${suffix}`;
3309
- }
3310
- kvPath(collection, id) {
3311
- const key = `${collection}/${id}`;
3312
- return this.spacePath(`/kv/${encodeURIComponent(key)}`);
3313
- }
3314
- /** The at-rest cipher for the current identity (re-derived if it changes). */
3315
- getCipher() {
3316
- const identity = this.deps.session.requireIdentity();
3317
- if (this.cipher && this.cipherIdentity === identity)
3318
- return this.cipher;
3319
- this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
3320
- this.cipherIdentity = identity;
3321
- return this.cipher;
3322
- }
3323
- }
3324
-
3325
3236
  /**
3326
3237
  * Pure WebSocket transport — owns the socket lifecycle and nothing else.
3327
3238
  *
@@ -3337,10 +3248,29 @@ class StorageNamespace {
3337
3248
  * - Emit lifecycle events (CONNECTED / DISCONNECTED / RECONNECTING / ERROR)
3338
3249
  * plus raw inbound MESSAGE events
3339
3250
  */
3251
+ /** Monotonic source of unique transport ids when a caller doesn't supply one. */
3252
+ let __wsInstanceSeq = 0;
3340
3253
  class WSTransport extends EventCore {
3341
- emit = EventCore.emit;
3342
- on = EventCore.on;
3343
- off = EventCore.off;
3254
+ /**
3255
+ * Per-instance event scope. `EventCore` is a process-global static emitter,
3256
+ * so without namespacing every WSTransport would hear every other one's
3257
+ * frames. We prefix each event with this transport's `id` and expose
3258
+ * `on`/`off`/`emit` that scope transparently — callers still use the plain
3259
+ * `EventCoreEvents` enum and only ever see their own connection's events.
3260
+ */
3261
+ id;
3262
+ emit = (event, data) => {
3263
+ EventCore.emit(this.scopedEvent(event), data);
3264
+ };
3265
+ on = (event, handler) => {
3266
+ EventCore.on(this.scopedEvent(event), handler);
3267
+ };
3268
+ off = (event, handler) => {
3269
+ EventCore.off(this.scopedEvent(event), handler);
3270
+ };
3271
+ scopedEvent(event) {
3272
+ return `${this.id}:${event}`;
3273
+ }
3344
3274
  url;
3345
3275
  socket = null;
3346
3276
  _isConnected = false;
@@ -3354,16 +3284,28 @@ class WSTransport extends EventCore {
3354
3284
  // Outbound queue (raw frame strings — already serialized by caller)
3355
3285
  outboundQueue = [];
3356
3286
  maxQueueSize;
3287
+ // Heartbeat / liveness state
3288
+ heartbeatInterval;
3289
+ heartbeatTimeout;
3290
+ pingFrame;
3291
+ pongFrame;
3292
+ heartbeatTimer = null;
3293
+ pongTimer = null;
3357
3294
  constructor(opts) {
3358
3295
  super();
3359
3296
  if (!opts || !opts.url) {
3360
3297
  throw new Error("WSTransport: `url` is required");
3361
3298
  }
3362
3299
  this.url = opts.url;
3300
+ this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
3363
3301
  this.autoReconnect = opts.autoReconnect ?? true;
3364
3302
  this.reconnectDelay = opts.reconnectDelay ?? 3000;
3365
3303
  this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
3366
3304
  this.maxQueueSize = opts.maxQueueSize ?? 100;
3305
+ this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
3306
+ this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
3307
+ this.pingFrame = opts.pingFrame ?? "ping";
3308
+ this.pongFrame = opts.pongFrame ?? "pong";
3367
3309
  }
3368
3310
  /** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
3369
3311
  async connect() {
@@ -3381,9 +3323,19 @@ class WSTransport extends EventCore {
3381
3323
  this.reconnectAttempts = 0;
3382
3324
  this.emit(EventCoreEvents.CONNECTED, undefined);
3383
3325
  this.flushQueue();
3326
+ this.startHeartbeat();
3384
3327
  resolve();
3385
3328
  };
3386
3329
  socket.onmessage = (event) => {
3330
+ // Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
3331
+ // the liveness deadline. The layer above never sees keep-alive frames.
3332
+ if (event.data === this.pongFrame) {
3333
+ if (this.pongTimer) {
3334
+ clearTimeout(this.pongTimer);
3335
+ this.pongTimer = null;
3336
+ }
3337
+ return;
3338
+ }
3387
3339
  // Raw inbound frame — layer above is responsible for parsing.
3388
3340
  this.emit(EventCoreEvents.MESSAGE, event.data);
3389
3341
  };
@@ -3398,6 +3350,7 @@ class WSTransport extends EventCore {
3398
3350
  const wasConnected = this._isConnected;
3399
3351
  this._isConnected = false;
3400
3352
  this._isConnecting = false;
3353
+ this.stopHeartbeat();
3401
3354
  this.emit(EventCoreEvents.DISCONNECTED, undefined);
3402
3355
  if (this.autoReconnect && wasConnected) {
3403
3356
  this.scheduleReconnect();
@@ -3416,6 +3369,7 @@ class WSTransport extends EventCore {
3416
3369
  clearTimeout(this.reconnectTimeout);
3417
3370
  this.reconnectTimeout = null;
3418
3371
  }
3372
+ this.stopHeartbeat();
3419
3373
  this.autoReconnect = false;
3420
3374
  if (this.socket) {
3421
3375
  try {
@@ -3446,6 +3400,78 @@ class WSTransport extends EventCore {
3446
3400
  queuedFrames() { return this.outboundQueue.length; }
3447
3401
  reconnectAttemptCount() { return this.reconnectAttempts; }
3448
3402
  // -- internals -------------------------------------------------------------
3403
+ /** Begin the keep-alive loop (no-op when disabled). */
3404
+ startHeartbeat() {
3405
+ if (this.heartbeatInterval <= 0)
3406
+ return;
3407
+ this.stopHeartbeat();
3408
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
3409
+ }
3410
+ /** Stop the keep-alive loop and clear any pending liveness deadline. */
3411
+ stopHeartbeat() {
3412
+ if (this.heartbeatTimer) {
3413
+ clearInterval(this.heartbeatTimer);
3414
+ this.heartbeatTimer = null;
3415
+ }
3416
+ if (this.pongTimer) {
3417
+ clearTimeout(this.pongTimer);
3418
+ this.pongTimer = null;
3419
+ }
3420
+ }
3421
+ /**
3422
+ * Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
3423
+ * `heartbeatTimeout`, the connection is considered dead and we force a
3424
+ * reconnect — this is what catches a silently-dropped TCP connection that
3425
+ * never fired `onclose` (the failure mode that "loses connection with the
3426
+ * Accelerator" on long sessions).
3427
+ */
3428
+ sendHeartbeat() {
3429
+ if (!this._isConnected || !this.socket)
3430
+ return;
3431
+ try {
3432
+ this.socket.send(this.pingFrame);
3433
+ }
3434
+ catch {
3435
+ this.forceReconnect();
3436
+ return;
3437
+ }
3438
+ // A still-pending deadline means the previous pong never came back.
3439
+ if (this.pongTimer)
3440
+ return;
3441
+ this.pongTimer = setTimeout(() => {
3442
+ this.pongTimer = null;
3443
+ this.forceReconnect();
3444
+ }, this.heartbeatTimeout);
3445
+ }
3446
+ /**
3447
+ * Tear down a connection we believe is dead and kick off reconnect, without
3448
+ * waiting on the OS/browser to eventually fire `onclose` (which a half-open
3449
+ * socket may never do). We detach the handlers first so the normal close path
3450
+ * can't double-fire the reconnect.
3451
+ */
3452
+ forceReconnect() {
3453
+ this.stopHeartbeat();
3454
+ const sock = this.socket;
3455
+ const wasConnected = this._isConnected;
3456
+ this.socket = null;
3457
+ this._isConnected = false;
3458
+ this._isConnecting = false;
3459
+ if (sock) {
3460
+ sock.onopen = null;
3461
+ sock.onmessage = null;
3462
+ sock.onerror = null;
3463
+ sock.onclose = null;
3464
+ try {
3465
+ sock.close();
3466
+ }
3467
+ catch { /* already gone */ }
3468
+ }
3469
+ this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
3470
+ this.emit(EventCoreEvents.DISCONNECTED, undefined);
3471
+ if (this.autoReconnect && wasConnected) {
3472
+ this.scheduleReconnect();
3473
+ }
3474
+ }
3449
3475
  scheduleReconnect() {
3450
3476
  if (this.maxReconnectAttempts > 0 &&
3451
3477
  this.reconnectAttempts >= this.maxReconnectAttempts) {
@@ -3481,473 +3507,160 @@ class WSTransport extends EventCore {
3481
3507
  }
3482
3508
 
3483
3509
  /**
3484
- * An end-to-end-encrypted communication session for one local identity
3485
- * (`myId`) talking to an arbitrary set of peers. Wraps `KeyStore` +
3486
- * per-pair `DoubleRatchet` instances and owns the protocol bookkeeping
3487
- * (handshake reciprocation, dedup, role assignment, broadcast fan-out,
3488
- * recipient filtering) so application code doesn't have to.
3510
+ * `client.kv` — per-user key/value storage backed by the accelerator's personal
3511
+ * space.
3489
3512
  *
3490
- * Transport-agnostic by design: the Session never touches a WebSocket
3491
- * (or HTTP, or anything else). Application code:
3492
- * - calls `session.encrypt(text)` and gets back an array of frames it
3493
- * ships over whatever transport it has;
3494
- * - calls `session.receive(frame)` with each inbound frame and acts on
3495
- * the typed result (plaintext, handshake-done, ignored).
3513
+ * await client.kv.set('todos', id, { title, completed })
3514
+ * const todo = await client.kv.get('todos', id)
3515
+ * const ids = await client.kv.list('todos')
3516
+ * await client.kv.delete('todos', id)
3517
+ * const off = client.kv.on('change', e => …) // realtime, cross-device
3496
3518
  *
3497
- * Frames are plain JSON objects with the same shape the existing chat
3498
- * protocol uses (`{ keyExchange: {...} }` and `{ cipherMessage: {...} }`),
3499
- * so this can drop into a chat app without changing the wire format.
3519
+ * A *collection* is a key namespace within the user's space; the stored key is
3520
+ * `${collection}/${id}`. Operations are authorized by the user's session token
3521
+ * (stamped on every request by {@link HttpClient}) — no per-op Groth16 proof.
3522
+ *
3523
+ * Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
3524
+ * key derived from the ZK identity); pass `{ encrypt: false }` to store
3525
+ * plaintext. The realtime feed rides the personal space's websocket over a
3526
+ * {@link WSTransport} (heartbeat keep-alive + auto-reconnect, event-namespaced
3527
+ * so it doesn't cross wires with chat/space transports).
3528
+ *
3529
+ * For **files** (blobs), use `client.storage` instead — `client.kv` is for
3530
+ * small structured values. `query()` is intentionally deferred.
3500
3531
  */
3501
- // ---------------------------------------------------------------------------
3502
- // EncryptedSession
3503
- // ---------------------------------------------------------------------------
3504
- class EncryptedSession {
3505
- myId;
3506
- keyStore;
3507
- /** One DoubleRatchet per peer pair. */
3508
- ratchets = new Map();
3509
- /** Peers we've already sent our keyExchange to in this Session. */
3510
- sentHandshakeTo = new Set();
3511
- /** Resolved when own keys are generated and ready to use. */
3512
- _ready;
3513
- _resolveReady;
3514
- _initialized = false;
3515
- constructor(opts) {
3516
- if (!opts || !opts.myId) {
3517
- throw new Error("EncryptedSession: `myId` is required");
3518
- }
3519
- this.myId = opts.myId;
3520
- this.keyStore = KeyStore.getInstance();
3521
- this._ready = new Promise((resolve) => {
3522
- this._resolveReady = resolve;
3523
- });
3532
+ class KvNamespace {
3533
+ deps;
3534
+ cipher = null;
3535
+ cipherIdentity = null;
3536
+ constructor(deps) {
3537
+ this.deps = deps;
3524
3538
  }
3525
- /** A promise that resolves once `initialize()` has completed. */
3526
- get ready() {
3527
- return this._ready;
3539
+ /** Store `value` under `collection`/`id` (encrypted at rest by default). */
3540
+ async set(collection, id, value, opts) {
3541
+ const encrypt = opts?.encrypt !== false;
3542
+ const stored = encrypt ? await this.getCipher().encrypt(value) : value;
3543
+ await this.deps.http.post(this.kvPath(collection, id), { value: stored });
3528
3544
  }
3529
- /** Our local identifier. */
3530
- get id() {
3531
- return this.myId;
3545
+ /** Fetch the value at `collection`/`id`, or `null` if absent. */
3546
+ async get(collection, id) {
3547
+ const res = await this.deps.http.post(`${this.kvPath(collection, id)}/get`, {});
3548
+ return this.decode(res.value);
3549
+ }
3550
+ /** Delete `collection`/`id`. Returns whether the key existed. */
3551
+ async delete(collection, id) {
3552
+ const res = await this.deps.http.del(this.kvPath(collection, id), {});
3553
+ return Boolean(res.existed);
3554
+ }
3555
+ /** List the ids present in `collection`. */
3556
+ async list(collection) {
3557
+ const res = await this.deps.http.post(this.spacePath("/list"), {});
3558
+ const prefix = `${collection}/`;
3559
+ return (res.keys ?? [])
3560
+ .filter((k) => k.startsWith(prefix))
3561
+ .map((k) => k.slice(prefix.length));
3562
+ }
3563
+ /** Reserved — server-side query is deferred (see the overhaul plan). */
3564
+ async query(_collection, _filter) {
3565
+ throw new Error("client.kv.query is not implemented yet (deferred in the SDK overhaul).");
3532
3566
  }
3533
3567
  /**
3534
- * Generate this client's ECDH + ECDSA keypair (idempotent — safe to call
3535
- * multiple times across reconnects; only the first call generates).
3568
+ * Subscribe to realtime changes to this user's data across devices. Opens
3569
+ * the personal space's websocket and decrypts each change before handing
3570
+ * it to `handler`. Returns an unsubscribe function.
3571
+ *
3572
+ * Requires a `WebSocket` global (browsers; Node 22+ or a `ws` shim).
3536
3573
  */
3537
- async initialize() {
3538
- if (this._initialized)
3539
- return;
3540
- if (!this.keyStore.getKeyPair(this.myId)) {
3541
- await this.keyStore.generateOwnKeyPair(this.myId);
3542
- }
3543
- this._initialized = true;
3544
- this._resolveReady();
3545
- }
3546
- /**
3547
- * Build the keyExchange frame this client should send to peers when it
3548
- * comes online. Application is responsible for actually transmitting it
3549
- * (e.g. ws.send(JSON.stringify(frame))).
3550
- */
3551
- async getOwnKeyExchange() {
3552
- await this._ready;
3553
- const ownEcdh = this.keyStore.getKeyPair(this.myId);
3554
- const ownEcdsa = this.keyStore.getAuthKeyPair(this.myId);
3555
- if (!ownEcdh || !ownEcdsa) {
3556
- throw new Error("EncryptedSession.getOwnKeyExchange: keys not initialized");
3574
+ on(event, handler) {
3575
+ if (event !== "change")
3576
+ throw new Error(`client.kv: unknown event "${event}"`);
3577
+ if (typeof globalThis.WebSocket !== "function") {
3578
+ throw new Error("client.kv.on: no `WebSocket` global available in this runtime.");
3557
3579
  }
3558
- return {
3559
- keyExchange: {
3560
- type: "handshake",
3561
- userId: this.myId,
3562
- ecdhPublicKey: await exportJwkBase64(ownEcdh.publicKey),
3563
- ecdsaPublicKey: await exportJwkBase64(ownEcdsa.publicKey),
3564
- },
3580
+ const token = this.deps.session.token;
3581
+ if (!token)
3582
+ throw new Error("client.kv.on: not signed in.");
3583
+ const commitment = this.commitment();
3584
+ const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
3585
+ // Ride a WSTransport rather than a bare socket: it keeps the (otherwise
3586
+ // idle) personal-space feed warm with heartbeat pings, auto-reconnects
3587
+ // if it drops, and namespaces its events by `id` so this feed never
3588
+ // crosses wires with chat/space transports on the shared EventCore bus.
3589
+ const transport = new WSTransport({ url, id: `personal:${commitment}` });
3590
+ const onMessage = (e) => {
3591
+ void this.dispatchChange(e.detail, handler);
3592
+ };
3593
+ transport.on(EventCoreEvents.MESSAGE, onMessage);
3594
+ // Fire-and-forget: the realtime feed is best-effort, and the transport
3595
+ // queues nothing it needs before CONNECTED (server→client only).
3596
+ void transport.connect();
3597
+ return () => {
3598
+ transport.off(EventCoreEvents.MESSAGE, onMessage);
3599
+ transport.disconnect();
3565
3600
  };
3566
3601
  }
3567
- /**
3568
- * Encrypt a plaintext to every peer we currently have a ratchet for, and
3569
- * return one cipherMessage frame per recipient. The application sends
3570
- * each frame over its transport.
3571
- *
3572
- * If there are no peers yet (or all peers are still mid-handshake), the
3573
- * returned array may be empty — the caller should typically render the
3574
- * outgoing message locally in that case (since there's no one to send
3575
- * it to yet).
3576
- */
3577
- async encrypt(plaintext) {
3578
- await this._ready;
3579
- const frames = [];
3580
- for (const peerId of this.ratchets.keys()) {
3581
- const ratchet = this.ratchets.get(peerId);
3582
- const sessionId = this._sessionIdFor(peerId);
3583
- const cipherMessage = await ratchet.encrypt(plaintext,
3584
- /* newDhKey */ false, this.myId, peerId, sessionId, "specific");
3585
- frames.push({ cipherMessage });
3586
- }
3587
- return frames;
3588
- }
3589
- /**
3590
- * Hand an inbound frame to the session. Returns a typed result the
3591
- * application acts on.
3592
- *
3593
- * - `kind: 'plaintext'` — decrypted; render `result.text`
3594
- * - `kind: 'handshake'` — a peer's keys are now stored, and if
3595
- * `result.outbound` is set, send it
3596
- * - `kind: 'ignored'` — not for us, malformed, duplicate, etc.
3597
- *
3598
- * Calling code should always handle `'ignored'` (no UI side effect).
3599
- */
3600
- async receive(frame) {
3601
- await this._ready;
3602
- // -- handshake frame -----------------------------------------------------
3603
- if (isKeyExchangeFrame(frame)) {
3604
- const kx = frame.keyExchange;
3605
- const peerId = kx.userId;
3606
- if (peerId === this.myId) {
3607
- return { kind: "ignored", reason: "own keyExchange echo" };
3608
- }
3609
- const isNew = !this.ratchets.has(peerId);
3610
- // (Re-)store the peer's pubkeys + (re-)build the ratchet if needed.
3611
- const peerEcdh = await importJwkBase64(kx.ecdhPublicKey, "ECDH");
3612
- const peerEcdsa = await importJwkBase64(kx.ecdsaPublicKey, "ECDSA");
3613
- await this.keyStore.storeRemotePublicKeys(peerId, peerEcdh, peerEcdsa);
3614
- if (isNew) {
3615
- const isClient = this.myId < peerId;
3616
- const ratchet = new DoubleRatchet(this.myId, peerId, "specific", isClient);
3617
- await ratchet.initializeSession(isClient);
3618
- this.ratchets.set(peerId, ratchet);
3619
- }
3620
- // Reciprocate exactly once per peer per session lifetime.
3621
- let outbound = null;
3622
- if (isNew && !this.sentHandshakeTo.has(peerId)) {
3623
- outbound = await this.getOwnKeyExchange();
3624
- this.sentHandshakeTo.add(peerId);
3625
- }
3626
- return { kind: "handshake", peerId, outbound };
3602
+ // -------------------------------------------------------------------------
3603
+ // Internals
3604
+ // -------------------------------------------------------------------------
3605
+ async dispatchChange(raw, handler) {
3606
+ let frame;
3607
+ try {
3608
+ frame = typeof raw === "string" ? JSON.parse(raw) : raw;
3627
3609
  }
3628
- // -- ciphertext frame ----------------------------------------------------
3629
- if (isCipherFrame(frame)) {
3630
- const cipherMessage = frame.cipherMessage;
3631
- const header = cipherMessage?.header;
3632
- if (!header) {
3633
- return { kind: "ignored", reason: "cipherMessage has no header" };
3634
- }
3635
- if (header.senderId === this.myId) {
3636
- return { kind: "ignored", reason: "own outbound echo" };
3637
- }
3638
- if (header.recipientId && header.recipientId !== this.myId) {
3639
- return { kind: "ignored", reason: "addressed to a different peer" };
3640
- }
3641
- const ratchet = this.ratchets.get(header.senderId);
3642
- if (!ratchet) {
3643
- return {
3644
- kind: "ignored",
3645
- reason: `no ratchet with sender ${header.senderId} (handshake hasn't completed)`,
3646
- };
3647
- }
3648
- const isClient = this.myId < header.senderId;
3649
- let text;
3650
- try {
3651
- text = await ratchet.decrypt(cipherMessage, isClient);
3652
- }
3653
- catch (err) {
3654
- // DoubleRatchet's wallclock-based replay-protection check throws
3655
- // "CipherMessage too old, possible replay attack" for any frame
3656
- // older than 5 minutes. Real replays still fail downstream because
3657
- // the message key has been consumed; the wallclock check is a
3658
- // defense-in-depth heuristic, not a security boundary. Treating it
3659
- // as `ignored` lets the channel survive legacy backlog replays and
3660
- // CF WS-idle-timeout reconnect cycles without spamming the
3661
- // channel's ERROR stream.
3662
- const msg = err instanceof Error ? err.message : String(err);
3663
- if (msg.includes("too old") || msg.includes("replay attack")) {
3664
- return { kind: "ignored", reason: `stale cipherMessage rejected: ${msg}` };
3665
- }
3666
- throw err;
3667
- }
3668
- return { kind: "plaintext", from: header.senderId, text, cipherMessage };
3610
+ catch {
3611
+ return;
3669
3612
  }
3670
- return { kind: "ignored", reason: "unknown frame type" };
3613
+ if (!frame || frame._t !== "change" || typeof frame.key !== "string")
3614
+ return;
3615
+ const slash = frame.key.indexOf("/");
3616
+ if (slash < 0)
3617
+ return;
3618
+ const collection = frame.key.slice(0, slash);
3619
+ const id = frame.key.slice(slash + 1);
3620
+ const data = frame.op === "set" ? await this.decode(frame.value) : null;
3621
+ handler({ collection, id, type: frame.op, data });
3671
3622
  }
3672
- // -- introspection ---------------------------------------------------------
3673
- /** List of peer ids we have an established ratchet for. */
3674
- peers() {
3675
- return Array.from(this.ratchets.keys());
3623
+ /** Decode a stored value: decrypt envelopes, pass plaintext through. */
3624
+ async decode(value) {
3625
+ if (value == null)
3626
+ return null;
3627
+ if (StorageCipher.isEnvelope(value))
3628
+ return this.getCipher().decrypt(value);
3629
+ return value;
3676
3630
  }
3677
- /** Has the handshake completed for this peer? */
3678
- hasRatchetFor(peerId) {
3679
- return this.ratchets.has(peerId);
3631
+ commitment() {
3632
+ const c = this.deps.session.commitment;
3633
+ if (!c)
3634
+ throw new Error("client.kv: not signed in — call client.auth.zk.login() first.");
3635
+ return c;
3680
3636
  }
3681
- /**
3682
- * Tear down a peer's ratchet (e.g. when they leave the room). The peer's
3683
- * stored keys remain in `KeyStore` so a fresh handshake can be set up
3684
- * later without re-fetching them, but the ratchet state is dropped.
3685
- */
3686
- forgetPeer(peerId) {
3687
- this.ratchets.delete(peerId);
3688
- this.sentHandshakeTo.delete(peerId);
3637
+ spacePath(suffix) {
3638
+ return `/api/personal/${encodeURIComponent(this.commitment())}${suffix}`;
3689
3639
  }
3690
- // -- internals -------------------------------------------------------------
3691
- /** Deterministic per-pair session id — same string on both sides. */
3692
- _sessionIdFor(peerId) {
3693
- return [this.myId, peerId].sort().join(":");
3640
+ kvPath(collection, id) {
3641
+ const key = `${collection}/${id}`;
3642
+ return this.spacePath(`/kv/${encodeURIComponent(key)}`);
3643
+ }
3644
+ /** The at-rest cipher for the current identity (re-derived if it changes). */
3645
+ getCipher() {
3646
+ const identity = this.deps.session.requireIdentity();
3647
+ if (this.cipher && this.cipherIdentity === identity)
3648
+ return this.cipher;
3649
+ this.cipher = new StorageCipher(identity.secretHex, identity.saltHex);
3650
+ this.cipherIdentity = identity;
3651
+ return this.cipher;
3694
3652
  }
3695
- }
3696
- // ---------------------------------------------------------------------------
3697
- // Helpers — JWK <-> base64 string for key transport
3698
- // ---------------------------------------------------------------------------
3699
- async function exportJwkBase64(key) {
3700
- const jwk = await crypto.subtle.exportKey("jwk", key);
3701
- return btoa(JSON.stringify(jwk));
3702
- }
3703
- async function importJwkBase64(b64, type) {
3704
- const jwk = JSON.parse(atob(b64));
3705
- const algorithm = type === "ECDH"
3706
- ? { name: "ECDH", namedCurve: "P-384" }
3707
- : { name: "ECDSA", namedCurve: "P-384" };
3708
- const usages = type === "ECDH" ? [] : ["verify"];
3709
- return await crypto.subtle.importKey("jwk", jwk, algorithm, true, usages);
3710
- }
3711
- // ---------------------------------------------------------------------------
3712
- // Type guards
3713
- // ---------------------------------------------------------------------------
3714
- function isKeyExchangeFrame(frame) {
3715
- const kx = frame.keyExchange;
3716
- if (!kx || typeof kx !== "object")
3717
- return false;
3718
- const k = kx;
3719
- return (typeof k.userId === "string" &&
3720
- typeof k.ecdhPublicKey === "string" &&
3721
- typeof k.ecdsaPublicKey === "string");
3722
- }
3723
- function isCipherFrame(frame) {
3724
- const cm = frame.cipherMessage;
3725
- if (!cm || typeof cm !== "object")
3726
- return false;
3727
- const m = cm;
3728
- return Boolean(m.header && m.ciphertext && m.nonce);
3729
3653
  }
3730
3654
 
3731
3655
  /**
3732
- * BroadcastChannel — a turnkey end-to-end encrypted channel for multi-peer
3733
- * "room"-style apps (e.g. a chat). Wires together:
3734
- *
3735
- * - `WSTransport` for socket lifecycle, reconnect, and offline frame queue
3736
- * - `EncryptedSession` for per-peer Double Ratchet state, handshake
3737
- * reciprocation, fan-out encryption, and recipient filtering
3738
- *
3739
- * The host app only deals with high-level events and method calls; it never
3740
- * touches `new WebSocket(...)` or builds cipherMessage frames by hand.
3741
- *
3742
- * Wire protocol (kept compatible with the existing chat protocol so this
3743
- * drops into the deployed Accelerator without server changes):
3656
+ * Wire types for the storage layer.
3744
3657
  *
3745
- * outbound: `{ keyExchange: {...} }` — produced by us on `announce()`
3746
- * `{ cipherMessage: {...} }` — produced by `send(text)`,
3747
- * one frame per peer
3748
- * arbitrary JSON — sent via `sendRaw(obj)`
3749
- *
3750
- * inbound: `{ keyExchange: {...} }` — routed into EncryptedSession;
3751
- * emits `peer_handshake`
3752
- * `{ cipherMessage: {...} }` — decrypted; emits `message`
3753
- * anything else — passes through as `raw_frame`
3754
- * (the app decides what to do)
3755
- *
3756
- * **Event scoping.** Unlike `Network`, BroadcastChannel uses a per-instance
3757
- * `EventTarget`, so two channels (e.g. two rooms in the same app) don't
3758
- * cross-contaminate each other's events.
3759
- *
3760
- * Note: this class shadows the `BroadcastChannel` Web API global (cross-tab
3761
- * messaging) only when imported by name. They're unrelated.
3762
- */
3763
- // ---------------------------------------------------------------------------
3764
- // Event names
3765
- // ---------------------------------------------------------------------------
3766
- /**
3767
- * Event names BroadcastChannel emits. Lifecycle events reuse the
3768
- * `EventCoreEvents` string values (`connected`, `disconnected`, etc.) so
3769
- * apps can use shared constants if they want. Channel-specific events are
3770
- * prefixed `channel:` to avoid colliding with other event systems in the
3771
- * same app.
3772
- */
3773
- const BroadcastChannelEvents = {
3774
- CONNECTED: EventCoreEvents.CONNECTED,
3775
- DISCONNECTED: EventCoreEvents.DISCONNECTED,
3776
- RECONNECTING: EventCoreEvents.RECONNECTING,
3777
- ERROR: EventCoreEvents.ERROR,
3778
- /** A peer's ratchet is now ready. `detail.peerId` is the peer's id. */
3779
- PEER_HANDSHAKE: "channel:peer_handshake",
3780
- /** Decrypted message. `detail` is `{ from, text, cipherMessage }`. */
3781
- MESSAGE: "channel:message",
3782
- /** Inbound frame BroadcastChannel didn't recognize. App handles it. */
3783
- RAW_FRAME: "channel:raw_frame",
3784
- };
3785
- // ---------------------------------------------------------------------------
3786
- // BroadcastChannel
3787
- // ---------------------------------------------------------------------------
3788
- class BroadcastChannel {
3789
- transport;
3790
- session;
3791
- myId;
3792
- autoAnnounce;
3793
- announced = false;
3794
- /** Per-instance EventTarget — events do NOT leak across BroadcastChannels. */
3795
- events = new EventTarget();
3796
- constructor(opts) {
3797
- if (!opts || !opts.myId) {
3798
- throw new Error("BroadcastChannel: `myId` is required");
3799
- }
3800
- this.myId = opts.myId;
3801
- this.autoAnnounce = opts.autoAnnounce ?? false;
3802
- this.transport = new WSTransport(opts);
3803
- this.session = new EncryptedSession({ myId: opts.myId });
3804
- // Pass-through lifecycle events from the transport. `EventCore.on` is
3805
- // static so it routes back through the shared singleton; we bridge it
3806
- // into our instance-scoped event target.
3807
- this.transport.on(EventCoreEvents.CONNECTED, () => {
3808
- // Reset the announce flag on every (re)connect. CF Workers WebSockets
3809
- // hit an idle timeout and force reconnects; without resetting, the
3810
- // SDK would short-circuit announce() and any peer who joined the
3811
- // room after our last announce would never see our pubkey.
3812
- this.announced = false;
3813
- this.emit(BroadcastChannelEvents.CONNECTED);
3814
- if (this.autoAnnounce) {
3815
- void this.announce();
3816
- }
3817
- });
3818
- this.transport.on(EventCoreEvents.DISCONNECTED, () => {
3819
- this.emit(BroadcastChannelEvents.DISCONNECTED);
3820
- });
3821
- this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
3822
- this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
3823
- });
3824
- this.transport.on(EventCoreEvents.ERROR, (e) => {
3825
- this.emit(BroadcastChannelEvents.ERROR, e.detail);
3826
- });
3827
- // Route inbound raw frames through our framing/decryption logic.
3828
- this.transport.on(EventCoreEvents.MESSAGE, (e) => {
3829
- void this.handleInboundFrame(e.detail);
3830
- });
3831
- }
3832
- // -- event API -------------------------------------------------------------
3833
- /** Subscribe to a channel event. Handler receives a CustomEvent. */
3834
- on(event, handler) {
3835
- this.events.addEventListener(event, handler);
3836
- }
3837
- /** Unsubscribe. */
3838
- off(event, handler) {
3839
- this.events.removeEventListener(event, handler);
3840
- }
3841
- emit(event, detail) {
3842
- this.events.dispatchEvent(new CustomEvent(event, { detail }));
3843
- }
3844
- // -- lifecycle -------------------------------------------------------------
3845
- /** Generate our keys (idempotent) and open the WebSocket. */
3846
- async connect() {
3847
- await this.session.initialize();
3848
- await this.transport.connect();
3849
- }
3850
- /** Close the WebSocket and stop reconnecting. */
3851
- disconnect() {
3852
- this.transport.disconnect();
3853
- }
3854
- /**
3855
- * Send our `keyExchange` frame to the room. Idempotent — only sends on the
3856
- * first call. Call this once your higher-level handshake (e.g. sending a
3857
- * `{name}` identification frame) has completed.
3858
- */
3859
- async announce() {
3860
- if (this.announced)
3861
- return;
3862
- this.announced = true;
3863
- const frame = await this.session.getOwnKeyExchange();
3864
- this.transport.send(JSON.stringify(frame));
3865
- }
3866
- /**
3867
- * Encrypt `plaintext` to every peer we have a ratchet with, and send each
3868
- * cipherMessage as its own WS frame. Returns the number of peers we sent to
3869
- * (0 if no peers are handshaken yet — in which case the caller should
3870
- * typically render the message locally for the sender and try again later).
3871
- */
3872
- async send(plaintext) {
3873
- const frames = await this.session.encrypt(plaintext);
3874
- for (const frame of frames) {
3875
- this.transport.send(JSON.stringify(frame));
3876
- }
3877
- return frames.length;
3878
- }
3879
- /**
3880
- * Send an arbitrary JSON-serializable frame as-is. The app uses this for
3881
- * any frame that isn't channel-managed (e.g. a chat's `{name}`, `{file}`,
3882
- * or unencrypted `{message}`).
3883
- */
3884
- sendRaw(frame) {
3885
- this.transport.send(JSON.stringify(frame));
3886
- }
3887
- // -- introspection ---------------------------------------------------------
3888
- /** List of peer ids we have an established ratchet for. */
3889
- peers() {
3890
- return this.session.peers();
3891
- }
3892
- /** Drop a peer's ratchet (e.g. when they leave the room). */
3893
- forgetPeer(peerId) {
3894
- this.session.forgetPeer(peerId);
3895
- }
3896
- isConnected() {
3897
- return this.transport.isConnected();
3898
- }
3899
- // -- internals -------------------------------------------------------------
3900
- async handleInboundFrame(raw) {
3901
- let parsed;
3902
- try {
3903
- parsed = JSON.parse(raw);
3904
- }
3905
- catch (err) {
3906
- this.emit(BroadcastChannelEvents.ERROR, err);
3907
- return;
3908
- }
3909
- if (!parsed || typeof parsed !== "object") {
3910
- this.emit(BroadcastChannelEvents.RAW_FRAME, parsed);
3911
- return;
3912
- }
3913
- const obj = parsed;
3914
- // Channel-managed: keyExchange & cipherMessage. Anything else is RAW_FRAME.
3915
- if ("keyExchange" in obj || "cipherMessage" in obj) {
3916
- try {
3917
- const result = await this.session.receive(obj);
3918
- if (result.kind === "handshake") {
3919
- if (result.outbound) {
3920
- this.transport.send(JSON.stringify(result.outbound));
3921
- }
3922
- this.emit(BroadcastChannelEvents.PEER_HANDSHAKE, { peerId: result.peerId });
3923
- }
3924
- else if (result.kind === "plaintext") {
3925
- this.emit(BroadcastChannelEvents.MESSAGE, {
3926
- from: result.from,
3927
- text: result.text,
3928
- cipherMessage: result.cipherMessage,
3929
- });
3930
- }
3931
- // `ignored` is intentionally swallowed; subscribe to ERROR for bad frames.
3932
- }
3933
- catch (err) {
3934
- this.emit(BroadcastChannelEvents.ERROR, err);
3935
- }
3936
- return;
3937
- }
3938
- this.emit(BroadcastChannelEvents.RAW_FRAME, obj);
3939
- }
3940
- }
3941
-
3942
- /**
3943
- * Wire types for the storage layer.
3944
- *
3945
- * The `FileManifest` is the single piece of metadata the system needs to
3946
- * reassemble a file: per-chunk encryption material, RS parameters, and the
3947
- * SHA-256 content hashes of every shard that belongs to the chunk. With the
3948
- * manifest in hand, a reader can pull each shard from the open content-
3949
- * addressed shard store, decode, decrypt, and concatenate to recover the
3950
- * original bytes.
3658
+ * The `FileManifest` is the single piece of metadata the system needs to
3659
+ * reassemble a file: per-chunk encryption material, RS parameters, and the
3660
+ * SHA-256 content hashes of every shard that belongs to the chunk. With the
3661
+ * manifest in hand, a reader can pull each shard from the open content-
3662
+ * addressed shard store, decode, decrypt, and concatenate to recover the
3663
+ * original bytes.
3951
3664
  *
3952
3665
  * The manifest itself lives inside a `SharedSpaceDO` (Phase B) which gates
3953
3666
  * access by ZK proof + ACL. Shards are stored in the open content-addressed
@@ -4720,33 +4433,723 @@ class FileStorage {
4720
4433
  throw new Error(`FileStorage: chunk ${manifest.id} unrecoverable — ${deadIndexes.length} shards missing, ` +
4721
4434
  `only ${manifest.parityShards} tolerated`);
4722
4435
  }
4723
- const ciphertextPadded = this.codec.decode(shards, manifest.parityShards, deadIndexes, manifest.ciphertextSize);
4724
- return await this.cipher.decryptChunk(ciphertextPadded, manifest.cipher);
4436
+ const ciphertextPadded = this.codec.decode(shards, manifest.parityShards, deadIndexes, manifest.ciphertextSize);
4437
+ return await this.cipher.decryptChunk(ciphertextPadded, manifest.cipher);
4438
+ }
4439
+ /** Run `tasks` with at most `this.concurrency` in flight. */
4440
+ async runWithConcurrency(tasks) {
4441
+ let cursor = 0;
4442
+ const concurrency = this.concurrency;
4443
+ async function nextWorker() {
4444
+ while (cursor < tasks.length) {
4445
+ const idx = cursor++;
4446
+ await tasks[idx]();
4447
+ }
4448
+ }
4449
+ const workers = new Array(Math.min(concurrency, tasks.length))
4450
+ .fill(null)
4451
+ .map(() => nextWorker());
4452
+ await Promise.all(workers);
4453
+ }
4454
+ }
4455
+ /** Normalize `data` to a flat `Uint8Array`. */
4456
+ async function toUint8Array(data) {
4457
+ if (data instanceof Uint8Array)
4458
+ return data;
4459
+ // Blob covers File too (File extends Blob in every runtime we target).
4460
+ const ab = await data.arrayBuffer();
4461
+ return new Uint8Array(ab);
4462
+ }
4463
+
4464
+ /**
4465
+ * HTTP client for the gated file-manifest store on a SharedSpace
4466
+ * (`/api/spaces/:spaceId/files`).
4467
+ *
4468
+ * A "file" in a space is its **manifest** (per-chunk keys + the SHA-256 of each
4469
+ * global shard). The shard bytes live in the open `/api/shards` store; the
4470
+ * manifest is the capability. This client only moves manifests in/out of the
4471
+ * space's gated store.
4472
+ *
4473
+ * Authorization is **session-based**, consistent with the rest of the
4474
+ * SharedSpace (keyring, websocket): the SDK's credential-stamping `fetch`
4475
+ * (from {@link HttpClient}) attaches the app key + session token, the platform
4476
+ * validates the session and injects a trusted `X-Muhkoo-User-Context`, and the
4477
+ * DO authorizes on that + channel membership. There is no per-op Groth16 proof.
4478
+ *
4479
+ * Wire protocol (server: `SharedSpaceDO`):
4480
+ * POST /api/spaces/:spaceId/files body: { manifest } → { ok, fileId }
4481
+ * GET /api/spaces/:spaceId/files → { files: FileStat[] }
4482
+ * GET /api/spaces/:spaceId/files/:fileId → { manifest }
4483
+ * DELETE /api/spaces/:spaceId/files/:fileId → { ok, existed }
4484
+ */
4485
+ class SharedSpaceClient {
4486
+ baseUrl;
4487
+ fetchFn;
4488
+ constructor(opts) {
4489
+ if (!opts?.baseUrl)
4490
+ throw new Error("SharedSpaceClient: `baseUrl` is required");
4491
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
4492
+ const f = opts.fetch ?? globalThis.fetch;
4493
+ if (typeof f !== "function") {
4494
+ throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
4495
+ }
4496
+ this.fetchFn = f.bind(globalThis);
4497
+ }
4498
+ /** Write a file manifest into the space. Requires space membership. */
4499
+ async writeFileManifest(spaceId, manifest) {
4500
+ const url = `${this.filesBase(spaceId)}`;
4501
+ const res = await this.fetchFn(url, this.jsonRequest("POST", { manifest }));
4502
+ await parseOrThrow$1(res, `writeFileManifest(${manifest.id})`);
4503
+ }
4504
+ /** Read a file manifest by id. Requires space membership. */
4505
+ async readFileManifest(spaceId, fileId) {
4506
+ const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
4507
+ const res = await this.fetchFn(url, this.jsonRequest("GET"));
4508
+ const data = await parseOrThrow$1(res, `readFileManifest(${fileId})`);
4509
+ return data.manifest;
4510
+ }
4511
+ /** Delete a file manifest by id. Requires space membership. */
4512
+ async deleteFileManifest(spaceId, fileId) {
4513
+ const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
4514
+ const res = await this.fetchFn(url, this.jsonRequest("DELETE"));
4515
+ const data = await parseOrThrow$1(res, `deleteFileManifest(${fileId})`);
4516
+ return data.existed;
4517
+ }
4518
+ /** List file summaries in the space. Requires space membership. */
4519
+ async listFiles(spaceId) {
4520
+ const url = `${this.filesBase(spaceId)}`;
4521
+ const res = await this.fetchFn(url, this.jsonRequest("GET"));
4522
+ const data = await parseOrThrow$1(res, "listFiles");
4523
+ return Array.isArray(data.files) ? data.files : [];
4524
+ }
4525
+ // -------------------------------------------------------------------------
4526
+ filesBase(spaceId) {
4527
+ return `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
4528
+ }
4529
+ jsonRequest(method, body) {
4530
+ const init = { method };
4531
+ if (body !== undefined) {
4532
+ init.headers = { "Content-Type": "application/json" };
4533
+ init.body = JSON.stringify(body);
4534
+ }
4535
+ return init;
4536
+ }
4537
+ }
4538
+ async function parseOrThrow$1(response, op) {
4539
+ let parsed = null;
4540
+ try {
4541
+ parsed = await response.json();
4542
+ }
4543
+ catch {
4544
+ // non-JSON body
4545
+ }
4546
+ if (!response.ok) {
4547
+ const msg = parsed && typeof parsed === "object" && "error" in parsed
4548
+ ? String(parsed.error)
4549
+ : `${response.status} ${response.statusText}`;
4550
+ throw new Error(`SharedSpaceClient.${op}: ${msg}`);
4551
+ }
4552
+ return parsed;
4553
+ }
4554
+
4555
+ /**
4556
+ * `client.storage` — file storage. Hides the `FileStorage` / `ShardClient` /
4557
+ * `SharedSpaceClient` primitives behind the unified client.
4558
+ *
4559
+ * const { stat, manifest } = await client.storage.writeFile({ spaceId, data, metadata })
4560
+ * const { data } = await client.storage.readFile(spaceId, fileId)
4561
+ * const files = await client.storage.listFiles() // my shared files
4562
+ * const files = await client.storage.listFiles({ spaceId }) // a space's files
4563
+ * await client.storage.deleteFile(spaceId, fileId)
4564
+ *
4565
+ * Model:
4566
+ * - **Files are written to a SharedSpace** (never the personal space). A file
4567
+ * is its manifest (per-chunk keys + global shard hashes); the manifest lives
4568
+ * in the space's gated, metered `/files` store, the shard ciphertext in the
4569
+ * global open `/api/shards` store.
4570
+ * - **The manifest is the capability** — anyone holding it reads the file from
4571
+ * the global shards, anywhere ({@link readByManifest}). Share a file by
4572
+ * sharing its manifest.
4573
+ * - **Personal discovery mirror** — on a successful write the SDK also records
4574
+ * the manifest in the user's `client.kv` under a reserved collection, so
4575
+ * {@link listFiles} (no `spaceId`) can show "files I've shared" without
4576
+ * enumerating spaces. The mirror is unmetered and is NOT a write path.
4577
+ *
4578
+ * For small structured values use `client.kv` instead.
4579
+ */
4580
+ /** Reserved `client.kv` collection holding the personal manifest mirror. */
4581
+ const MIRROR_COLLECTION = "__files__";
4582
+ class StorageNamespace {
4583
+ deps;
4584
+ fileStorage = null;
4585
+ constructor(deps) {
4586
+ this.deps = deps;
4587
+ }
4588
+ /**
4589
+ * Write a file to a SharedSpace: shard ciphertext → the global store, the
4590
+ * manifest → the space's gated store, and a mirror copy → the caller's
4591
+ * personal index. Returns the `stat` and the `manifest` (share the manifest
4592
+ * to let others read the file).
4593
+ */
4594
+ async writeFile(input) {
4595
+ if (!input?.spaceId) {
4596
+ throw new Error("client.storage.writeFile: `spaceId` is required (files live in a SharedSpace).");
4597
+ }
4598
+ const fs = this.fs();
4599
+ const { manifest, stat } = await fs.writeFileToShards({
4600
+ data: input.data,
4601
+ metadata: input.metadata,
4602
+ });
4603
+ // Authoritative, metered write: the manifest into the space's gated store.
4604
+ await this.space().writeFileManifest(input.spaceId, manifest);
4605
+ // Best-effort personal discovery mirror (unmetered). A failure here
4606
+ // leaves the file intact in the space — log and continue.
4607
+ try {
4608
+ await this.deps.kv.set(MIRROR_COLLECTION, manifest.id, {
4609
+ spaceId: input.spaceId,
4610
+ manifest,
4611
+ });
4612
+ }
4613
+ catch (err) {
4614
+ console.warn("client.storage.writeFile: discovery mirror failed (file still written):", err);
4615
+ }
4616
+ return { stat, manifest };
4617
+ }
4618
+ /** Read a file from its space (resolves the manifest, then the shards). */
4619
+ async readFile(spaceId, fileId) {
4620
+ return this.fs().readFile(spaceId, fileId);
4621
+ }
4622
+ /**
4623
+ * Read a file straight from a manifest — the capability path. No space
4624
+ * membership needed; this is how a shared file is opened.
4625
+ */
4626
+ async readByManifest(manifest) {
4627
+ return this.fs().readFileFromShards(manifest);
4628
+ }
4629
+ /**
4630
+ * List files. With no argument, returns the caller's own shared files from
4631
+ * the personal mirror; with `{ spaceId }`, lists that space's files.
4632
+ */
4633
+ async listFiles(opts) {
4634
+ if (opts?.spaceId)
4635
+ return this.space().listFiles(opts.spaceId);
4636
+ const ids = await this.deps.kv.list(MIRROR_COLLECTION);
4637
+ const entries = await Promise.all(ids.map((id) => this.deps.kv.get(MIRROR_COLLECTION, id)));
4638
+ return entries
4639
+ .filter((e) => Boolean(e?.manifest))
4640
+ .map((e) => manifestToStat(e.manifest));
4641
+ }
4642
+ /** Delete a file from its space and remove the personal mirror entry. */
4643
+ async deleteFile(spaceId, fileId) {
4644
+ const existed = await this.fs().deleteFile(spaceId, fileId);
4645
+ try {
4646
+ await this.deps.kv.delete(MIRROR_COLLECTION, fileId);
4647
+ }
4648
+ catch {
4649
+ // mirror cleanup is best-effort
4650
+ }
4651
+ return existed;
4652
+ }
4653
+ /** Read a manifest verbatim (e.g. to share a file). */
4654
+ async getManifest(spaceId, fileId) {
4655
+ return this.fs().readManifest(spaceId, fileId);
4656
+ }
4657
+ // -------------------------------------------------------------------------
4658
+ // Internals — lazily built so an unauthenticated client is cheap to create.
4659
+ // -------------------------------------------------------------------------
4660
+ fs() {
4661
+ if (!this.fileStorage) {
4662
+ this.fileStorage = new FileStorage({
4663
+ shards: this.shards(),
4664
+ space: this.space(),
4665
+ rsWasmModule: this.deps.rsWasmModule,
4666
+ });
4667
+ }
4668
+ return this.fileStorage;
4669
+ }
4670
+ _shards = null;
4671
+ shards() {
4672
+ // Global, content-addressed shard store. The credential-stamping fetch
4673
+ // attaches the app key so PUTs are attributed + quota-gated; GETs are open.
4674
+ if (!this._shards) {
4675
+ this._shards = new ShardClient({
4676
+ baseUrl: this.deps.baseUrl,
4677
+ pathPrefix: "/api/shards",
4678
+ fetch: this.deps.http.fetch,
4679
+ });
4680
+ }
4681
+ return this._shards;
4682
+ }
4683
+ _space = null;
4684
+ space() {
4685
+ if (!this._space) {
4686
+ this._space = new SharedSpaceClient({
4687
+ baseUrl: this.deps.baseUrl,
4688
+ fetch: this.deps.http.fetch,
4689
+ });
4690
+ }
4691
+ return this._space;
4692
+ }
4693
+ }
4694
+
4695
+ /**
4696
+ * An end-to-end-encrypted communication session for one local identity
4697
+ * (`myId`) talking to an arbitrary set of peers. Wraps `KeyStore` +
4698
+ * per-pair `DoubleRatchet` instances and owns the protocol bookkeeping
4699
+ * (handshake reciprocation, dedup, role assignment, broadcast fan-out,
4700
+ * recipient filtering) so application code doesn't have to.
4701
+ *
4702
+ * Transport-agnostic by design: the Session never touches a WebSocket
4703
+ * (or HTTP, or anything else). Application code:
4704
+ * - calls `session.encrypt(text)` and gets back an array of frames it
4705
+ * ships over whatever transport it has;
4706
+ * - calls `session.receive(frame)` with each inbound frame and acts on
4707
+ * the typed result (plaintext, handshake-done, ignored).
4708
+ *
4709
+ * Frames are plain JSON objects with the same shape the existing chat
4710
+ * protocol uses (`{ keyExchange: {...} }` and `{ cipherMessage: {...} }`),
4711
+ * so this can drop into a chat app without changing the wire format.
4712
+ */
4713
+ // ---------------------------------------------------------------------------
4714
+ // EncryptedSession
4715
+ // ---------------------------------------------------------------------------
4716
+ class EncryptedSession {
4717
+ myId;
4718
+ keyStore;
4719
+ /** One DoubleRatchet per peer pair. */
4720
+ ratchets = new Map();
4721
+ /** Peers we've already sent our keyExchange to in this Session. */
4722
+ sentHandshakeTo = new Set();
4723
+ /** Resolved when own keys are generated and ready to use. */
4724
+ _ready;
4725
+ _resolveReady;
4726
+ _initialized = false;
4727
+ constructor(opts) {
4728
+ if (!opts || !opts.myId) {
4729
+ throw new Error("EncryptedSession: `myId` is required");
4730
+ }
4731
+ this.myId = opts.myId;
4732
+ this.keyStore = KeyStore.getInstance();
4733
+ this._ready = new Promise((resolve) => {
4734
+ this._resolveReady = resolve;
4735
+ });
4736
+ }
4737
+ /** A promise that resolves once `initialize()` has completed. */
4738
+ get ready() {
4739
+ return this._ready;
4740
+ }
4741
+ /** Our local identifier. */
4742
+ get id() {
4743
+ return this.myId;
4744
+ }
4745
+ /**
4746
+ * Generate this client's ECDH + ECDSA keypair (idempotent — safe to call
4747
+ * multiple times across reconnects; only the first call generates).
4748
+ */
4749
+ async initialize() {
4750
+ if (this._initialized)
4751
+ return;
4752
+ if (!this.keyStore.getKeyPair(this.myId)) {
4753
+ await this.keyStore.generateOwnKeyPair(this.myId);
4754
+ }
4755
+ this._initialized = true;
4756
+ this._resolveReady();
4757
+ }
4758
+ /**
4759
+ * Build the keyExchange frame this client should send to peers when it
4760
+ * comes online. Application is responsible for actually transmitting it
4761
+ * (e.g. ws.send(JSON.stringify(frame))).
4762
+ */
4763
+ async getOwnKeyExchange() {
4764
+ await this._ready;
4765
+ const ownEcdh = this.keyStore.getKeyPair(this.myId);
4766
+ const ownEcdsa = this.keyStore.getAuthKeyPair(this.myId);
4767
+ if (!ownEcdh || !ownEcdsa) {
4768
+ throw new Error("EncryptedSession.getOwnKeyExchange: keys not initialized");
4769
+ }
4770
+ return {
4771
+ keyExchange: {
4772
+ type: "handshake",
4773
+ userId: this.myId,
4774
+ ecdhPublicKey: await exportJwkBase64(ownEcdh.publicKey),
4775
+ ecdsaPublicKey: await exportJwkBase64(ownEcdsa.publicKey),
4776
+ },
4777
+ };
4778
+ }
4779
+ /**
4780
+ * Encrypt a plaintext to every peer we currently have a ratchet for, and
4781
+ * return one cipherMessage frame per recipient. The application sends
4782
+ * each frame over its transport.
4783
+ *
4784
+ * If there are no peers yet (or all peers are still mid-handshake), the
4785
+ * returned array may be empty — the caller should typically render the
4786
+ * outgoing message locally in that case (since there's no one to send
4787
+ * it to yet).
4788
+ */
4789
+ async encrypt(plaintext) {
4790
+ await this._ready;
4791
+ const frames = [];
4792
+ for (const peerId of this.ratchets.keys()) {
4793
+ const ratchet = this.ratchets.get(peerId);
4794
+ const sessionId = this._sessionIdFor(peerId);
4795
+ const cipherMessage = await ratchet.encrypt(plaintext,
4796
+ /* newDhKey */ false, this.myId, peerId, sessionId, "specific");
4797
+ frames.push({ cipherMessage });
4798
+ }
4799
+ return frames;
4800
+ }
4801
+ /**
4802
+ * Hand an inbound frame to the session. Returns a typed result the
4803
+ * application acts on.
4804
+ *
4805
+ * - `kind: 'plaintext'` — decrypted; render `result.text`
4806
+ * - `kind: 'handshake'` — a peer's keys are now stored, and if
4807
+ * `result.outbound` is set, send it
4808
+ * - `kind: 'ignored'` — not for us, malformed, duplicate, etc.
4809
+ *
4810
+ * Calling code should always handle `'ignored'` (no UI side effect).
4811
+ */
4812
+ async receive(frame) {
4813
+ await this._ready;
4814
+ // -- handshake frame -----------------------------------------------------
4815
+ if (isKeyExchangeFrame(frame)) {
4816
+ const kx = frame.keyExchange;
4817
+ const peerId = kx.userId;
4818
+ if (peerId === this.myId) {
4819
+ return { kind: "ignored", reason: "own keyExchange echo" };
4820
+ }
4821
+ const isNew = !this.ratchets.has(peerId);
4822
+ // (Re-)store the peer's pubkeys + (re-)build the ratchet if needed.
4823
+ const peerEcdh = await importJwkBase64(kx.ecdhPublicKey, "ECDH");
4824
+ const peerEcdsa = await importJwkBase64(kx.ecdsaPublicKey, "ECDSA");
4825
+ await this.keyStore.storeRemotePublicKeys(peerId, peerEcdh, peerEcdsa);
4826
+ if (isNew) {
4827
+ const isClient = this.myId < peerId;
4828
+ const ratchet = new DoubleRatchet(this.myId, peerId, "specific", isClient);
4829
+ await ratchet.initializeSession(isClient);
4830
+ this.ratchets.set(peerId, ratchet);
4831
+ }
4832
+ // Reciprocate exactly once per peer per session lifetime.
4833
+ let outbound = null;
4834
+ if (isNew && !this.sentHandshakeTo.has(peerId)) {
4835
+ outbound = await this.getOwnKeyExchange();
4836
+ this.sentHandshakeTo.add(peerId);
4837
+ }
4838
+ return { kind: "handshake", peerId, outbound };
4839
+ }
4840
+ // -- ciphertext frame ----------------------------------------------------
4841
+ if (isCipherFrame(frame)) {
4842
+ const cipherMessage = frame.cipherMessage;
4843
+ const header = cipherMessage?.header;
4844
+ if (!header) {
4845
+ return { kind: "ignored", reason: "cipherMessage has no header" };
4846
+ }
4847
+ if (header.senderId === this.myId) {
4848
+ return { kind: "ignored", reason: "own outbound echo" };
4849
+ }
4850
+ if (header.recipientId && header.recipientId !== this.myId) {
4851
+ return { kind: "ignored", reason: "addressed to a different peer" };
4852
+ }
4853
+ const ratchet = this.ratchets.get(header.senderId);
4854
+ if (!ratchet) {
4855
+ return {
4856
+ kind: "ignored",
4857
+ reason: `no ratchet with sender ${header.senderId} (handshake hasn't completed)`,
4858
+ };
4859
+ }
4860
+ const isClient = this.myId < header.senderId;
4861
+ let text;
4862
+ try {
4863
+ text = await ratchet.decrypt(cipherMessage, isClient);
4864
+ }
4865
+ catch (err) {
4866
+ // DoubleRatchet's wallclock-based replay-protection check throws
4867
+ // "CipherMessage too old, possible replay attack" for any frame
4868
+ // older than 5 minutes. Real replays still fail downstream because
4869
+ // the message key has been consumed; the wallclock check is a
4870
+ // defense-in-depth heuristic, not a security boundary. Treating it
4871
+ // as `ignored` lets the channel survive legacy backlog replays and
4872
+ // CF WS-idle-timeout reconnect cycles without spamming the
4873
+ // channel's ERROR stream.
4874
+ const msg = err instanceof Error ? err.message : String(err);
4875
+ if (msg.includes("too old") || msg.includes("replay attack")) {
4876
+ return { kind: "ignored", reason: `stale cipherMessage rejected: ${msg}` };
4877
+ }
4878
+ throw err;
4879
+ }
4880
+ return { kind: "plaintext", from: header.senderId, text, cipherMessage };
4881
+ }
4882
+ return { kind: "ignored", reason: "unknown frame type" };
4883
+ }
4884
+ // -- introspection ---------------------------------------------------------
4885
+ /** List of peer ids we have an established ratchet for. */
4886
+ peers() {
4887
+ return Array.from(this.ratchets.keys());
4888
+ }
4889
+ /** Has the handshake completed for this peer? */
4890
+ hasRatchetFor(peerId) {
4891
+ return this.ratchets.has(peerId);
4892
+ }
4893
+ /**
4894
+ * Tear down a peer's ratchet (e.g. when they leave the room). The peer's
4895
+ * stored keys remain in `KeyStore` so a fresh handshake can be set up
4896
+ * later without re-fetching them, but the ratchet state is dropped.
4897
+ */
4898
+ forgetPeer(peerId) {
4899
+ this.ratchets.delete(peerId);
4900
+ this.sentHandshakeTo.delete(peerId);
4901
+ }
4902
+ // -- internals -------------------------------------------------------------
4903
+ /** Deterministic per-pair session id — same string on both sides. */
4904
+ _sessionIdFor(peerId) {
4905
+ return [this.myId, peerId].sort().join(":");
4906
+ }
4907
+ }
4908
+ // ---------------------------------------------------------------------------
4909
+ // Helpers — JWK <-> base64 string for key transport
4910
+ // ---------------------------------------------------------------------------
4911
+ async function exportJwkBase64(key) {
4912
+ const jwk = await crypto.subtle.exportKey("jwk", key);
4913
+ return btoa(JSON.stringify(jwk));
4914
+ }
4915
+ async function importJwkBase64(b64, type) {
4916
+ const jwk = JSON.parse(atob(b64));
4917
+ const algorithm = type === "ECDH"
4918
+ ? { name: "ECDH", namedCurve: "P-384" }
4919
+ : { name: "ECDSA", namedCurve: "P-384" };
4920
+ const usages = type === "ECDH" ? [] : ["verify"];
4921
+ return await crypto.subtle.importKey("jwk", jwk, algorithm, true, usages);
4922
+ }
4923
+ // ---------------------------------------------------------------------------
4924
+ // Type guards
4925
+ // ---------------------------------------------------------------------------
4926
+ function isKeyExchangeFrame(frame) {
4927
+ const kx = frame.keyExchange;
4928
+ if (!kx || typeof kx !== "object")
4929
+ return false;
4930
+ const k = kx;
4931
+ return (typeof k.userId === "string" &&
4932
+ typeof k.ecdhPublicKey === "string" &&
4933
+ typeof k.ecdsaPublicKey === "string");
4934
+ }
4935
+ function isCipherFrame(frame) {
4936
+ const cm = frame.cipherMessage;
4937
+ if (!cm || typeof cm !== "object")
4938
+ return false;
4939
+ const m = cm;
4940
+ return Boolean(m.header && m.ciphertext && m.nonce);
4941
+ }
4942
+
4943
+ /**
4944
+ * BroadcastChannel — a turnkey end-to-end encrypted channel for multi-peer
4945
+ * "room"-style apps (e.g. a chat). Wires together:
4946
+ *
4947
+ * - `WSTransport` for socket lifecycle, reconnect, and offline frame queue
4948
+ * - `EncryptedSession` for per-peer Double Ratchet state, handshake
4949
+ * reciprocation, fan-out encryption, and recipient filtering
4950
+ *
4951
+ * The host app only deals with high-level events and method calls; it never
4952
+ * touches `new WebSocket(...)` or builds cipherMessage frames by hand.
4953
+ *
4954
+ * Wire protocol (kept compatible with the existing chat protocol so this
4955
+ * drops into the deployed Accelerator without server changes):
4956
+ *
4957
+ * outbound: `{ keyExchange: {...} }` — produced by us on `announce()`
4958
+ * `{ cipherMessage: {...} }` — produced by `send(text)`,
4959
+ * one frame per peer
4960
+ * arbitrary JSON — sent via `sendRaw(obj)`
4961
+ *
4962
+ * inbound: `{ keyExchange: {...} }` — routed into EncryptedSession;
4963
+ * emits `peer_handshake`
4964
+ * `{ cipherMessage: {...} }` — decrypted; emits `message`
4965
+ * anything else — passes through as `raw_frame`
4966
+ * (the app decides what to do)
4967
+ *
4968
+ * **Event scoping.** Unlike `Network`, BroadcastChannel uses a per-instance
4969
+ * `EventTarget`, so two channels (e.g. two rooms in the same app) don't
4970
+ * cross-contaminate each other's events.
4971
+ *
4972
+ * Note: this class shadows the `BroadcastChannel` Web API global (cross-tab
4973
+ * messaging) only when imported by name. They're unrelated.
4974
+ */
4975
+ // ---------------------------------------------------------------------------
4976
+ // Event names
4977
+ // ---------------------------------------------------------------------------
4978
+ /**
4979
+ * Event names BroadcastChannel emits. Lifecycle events reuse the
4980
+ * `EventCoreEvents` string values (`connected`, `disconnected`, etc.) so
4981
+ * apps can use shared constants if they want. Channel-specific events are
4982
+ * prefixed `channel:` to avoid colliding with other event systems in the
4983
+ * same app.
4984
+ */
4985
+ const BroadcastChannelEvents = {
4986
+ CONNECTED: EventCoreEvents.CONNECTED,
4987
+ DISCONNECTED: EventCoreEvents.DISCONNECTED,
4988
+ RECONNECTING: EventCoreEvents.RECONNECTING,
4989
+ ERROR: EventCoreEvents.ERROR,
4990
+ /** A peer's ratchet is now ready. `detail.peerId` is the peer's id. */
4991
+ PEER_HANDSHAKE: "channel:peer_handshake",
4992
+ /** Decrypted message. `detail` is `{ from, text, cipherMessage }`. */
4993
+ MESSAGE: "channel:message",
4994
+ /** Inbound frame BroadcastChannel didn't recognize. App handles it. */
4995
+ RAW_FRAME: "channel:raw_frame",
4996
+ };
4997
+ // ---------------------------------------------------------------------------
4998
+ // BroadcastChannel
4999
+ // ---------------------------------------------------------------------------
5000
+ class BroadcastChannel {
5001
+ transport;
5002
+ session;
5003
+ myId;
5004
+ autoAnnounce;
5005
+ announced = false;
5006
+ /** Per-instance EventTarget — events do NOT leak across BroadcastChannels. */
5007
+ events = new EventTarget();
5008
+ constructor(opts) {
5009
+ if (!opts || !opts.myId) {
5010
+ throw new Error("BroadcastChannel: `myId` is required");
5011
+ }
5012
+ this.myId = opts.myId;
5013
+ this.autoAnnounce = opts.autoAnnounce ?? false;
5014
+ this.transport = new WSTransport(opts);
5015
+ this.session = new EncryptedSession({ myId: opts.myId });
5016
+ // Pass-through lifecycle events from the transport. `EventCore.on` is
5017
+ // static so it routes back through the shared singleton; we bridge it
5018
+ // into our instance-scoped event target.
5019
+ this.transport.on(EventCoreEvents.CONNECTED, () => {
5020
+ // Reset the announce flag on every (re)connect. CF Workers WebSockets
5021
+ // hit an idle timeout and force reconnects; without resetting, the
5022
+ // SDK would short-circuit announce() and any peer who joined the
5023
+ // room after our last announce would never see our pubkey.
5024
+ this.announced = false;
5025
+ this.emit(BroadcastChannelEvents.CONNECTED);
5026
+ if (this.autoAnnounce) {
5027
+ void this.announce();
5028
+ }
5029
+ });
5030
+ this.transport.on(EventCoreEvents.DISCONNECTED, () => {
5031
+ this.emit(BroadcastChannelEvents.DISCONNECTED);
5032
+ });
5033
+ this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
5034
+ this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
5035
+ });
5036
+ this.transport.on(EventCoreEvents.ERROR, (e) => {
5037
+ this.emit(BroadcastChannelEvents.ERROR, e.detail);
5038
+ });
5039
+ // Route inbound raw frames through our framing/decryption logic.
5040
+ this.transport.on(EventCoreEvents.MESSAGE, (e) => {
5041
+ void this.handleInboundFrame(e.detail);
5042
+ });
5043
+ }
5044
+ // -- event API -------------------------------------------------------------
5045
+ /** Subscribe to a channel event. Handler receives a CustomEvent. */
5046
+ on(event, handler) {
5047
+ this.events.addEventListener(event, handler);
5048
+ }
5049
+ /** Unsubscribe. */
5050
+ off(event, handler) {
5051
+ this.events.removeEventListener(event, handler);
5052
+ }
5053
+ emit(event, detail) {
5054
+ this.events.dispatchEvent(new CustomEvent(event, { detail }));
5055
+ }
5056
+ // -- lifecycle -------------------------------------------------------------
5057
+ /** Generate our keys (idempotent) and open the WebSocket. */
5058
+ async connect() {
5059
+ await this.session.initialize();
5060
+ await this.transport.connect();
5061
+ }
5062
+ /** Close the WebSocket and stop reconnecting. */
5063
+ disconnect() {
5064
+ this.transport.disconnect();
5065
+ }
5066
+ /**
5067
+ * Send our `keyExchange` frame to the room. Idempotent — only sends on the
5068
+ * first call. Call this once your higher-level handshake (e.g. sending a
5069
+ * `{name}` identification frame) has completed.
5070
+ */
5071
+ async announce() {
5072
+ if (this.announced)
5073
+ return;
5074
+ this.announced = true;
5075
+ const frame = await this.session.getOwnKeyExchange();
5076
+ this.transport.send(JSON.stringify(frame));
5077
+ }
5078
+ /**
5079
+ * Encrypt `plaintext` to every peer we have a ratchet with, and send each
5080
+ * cipherMessage as its own WS frame. Returns the number of peers we sent to
5081
+ * (0 if no peers are handshaken yet — in which case the caller should
5082
+ * typically render the message locally for the sender and try again later).
5083
+ */
5084
+ async send(plaintext) {
5085
+ const frames = await this.session.encrypt(plaintext);
5086
+ for (const frame of frames) {
5087
+ this.transport.send(JSON.stringify(frame));
5088
+ }
5089
+ return frames.length;
4725
5090
  }
4726
- /** Run `tasks` with at most `this.concurrency` in flight. */
4727
- async runWithConcurrency(tasks) {
4728
- let cursor = 0;
4729
- const concurrency = this.concurrency;
4730
- async function nextWorker() {
4731
- while (cursor < tasks.length) {
4732
- const idx = cursor++;
4733
- await tasks[idx]();
5091
+ /**
5092
+ * Send an arbitrary JSON-serializable frame as-is. The app uses this for
5093
+ * any frame that isn't channel-managed (e.g. a chat's `{name}`, `{file}`,
5094
+ * or unencrypted `{message}`).
5095
+ */
5096
+ sendRaw(frame) {
5097
+ this.transport.send(JSON.stringify(frame));
5098
+ }
5099
+ // -- introspection ---------------------------------------------------------
5100
+ /** List of peer ids we have an established ratchet for. */
5101
+ peers() {
5102
+ return this.session.peers();
5103
+ }
5104
+ /** Drop a peer's ratchet (e.g. when they leave the room). */
5105
+ forgetPeer(peerId) {
5106
+ this.session.forgetPeer(peerId);
5107
+ }
5108
+ isConnected() {
5109
+ return this.transport.isConnected();
5110
+ }
5111
+ // -- internals -------------------------------------------------------------
5112
+ async handleInboundFrame(raw) {
5113
+ let parsed;
5114
+ try {
5115
+ parsed = JSON.parse(raw);
5116
+ }
5117
+ catch (err) {
5118
+ this.emit(BroadcastChannelEvents.ERROR, err);
5119
+ return;
5120
+ }
5121
+ if (!parsed || typeof parsed !== "object") {
5122
+ this.emit(BroadcastChannelEvents.RAW_FRAME, parsed);
5123
+ return;
5124
+ }
5125
+ const obj = parsed;
5126
+ // Channel-managed: keyExchange & cipherMessage. Anything else is RAW_FRAME.
5127
+ if ("keyExchange" in obj || "cipherMessage" in obj) {
5128
+ try {
5129
+ const result = await this.session.receive(obj);
5130
+ if (result.kind === "handshake") {
5131
+ if (result.outbound) {
5132
+ this.transport.send(JSON.stringify(result.outbound));
5133
+ }
5134
+ this.emit(BroadcastChannelEvents.PEER_HANDSHAKE, { peerId: result.peerId });
5135
+ }
5136
+ else if (result.kind === "plaintext") {
5137
+ this.emit(BroadcastChannelEvents.MESSAGE, {
5138
+ from: result.from,
5139
+ text: result.text,
5140
+ cipherMessage: result.cipherMessage,
5141
+ });
5142
+ }
5143
+ // `ignored` is intentionally swallowed; subscribe to ERROR for bad frames.
4734
5144
  }
5145
+ catch (err) {
5146
+ this.emit(BroadcastChannelEvents.ERROR, err);
5147
+ }
5148
+ return;
4735
5149
  }
4736
- const workers = new Array(Math.min(concurrency, tasks.length))
4737
- .fill(null)
4738
- .map(() => nextWorker());
4739
- await Promise.all(workers);
5150
+ this.emit(BroadcastChannelEvents.RAW_FRAME, obj);
4740
5151
  }
4741
5152
  }
4742
- /** Normalize `data` to a flat `Uint8Array`. */
4743
- async function toUint8Array(data) {
4744
- if (data instanceof Uint8Array)
4745
- return data;
4746
- // Blob covers File too (File extends Blob in every runtime we target).
4747
- const ab = await data.arrayBuffer();
4748
- return new Uint8Array(ab);
4749
- }
4750
5153
 
4751
5154
  /**
4752
5155
  * SpaceCipher — pure crypto for the fan-out group-encryption layer.
@@ -5056,23 +5459,7 @@ class Space {
5056
5459
  * persisted `spaceMessage`. Requires a keyring.
5057
5460
  */
5058
5461
  async sendMessage(payload, opts = {}) {
5059
- const cipher = this.requireCipher(opts.contentType);
5060
- const message = new Message(payload);
5061
- const headers = await cipher.seal(message.serialize());
5062
- const source = this.deps.myId();
5063
- const subject = opts.channel ?? "default";
5064
- // Sign the message with our identity ECDSA key so receivers can verify
5065
- // authorship end-to-end (independent of the relay's `source` stamp).
5066
- const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
5067
- if (authPriv) {
5068
- const canonical = canonicalMessage({
5069
- source, target: this.name, subject,
5070
- epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
5071
- });
5072
- headers.sig = await signSpaceMessage(canonical, authPriv);
5073
- }
5074
- const packet = new Packet({ subject, source, target: this.name, headers });
5075
- this.sendRaw({ spaceMessage: packet.serialize() });
5462
+ this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
5076
5463
  }
5077
5464
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
5078
5465
  onMessage(handler) {
@@ -5080,6 +5467,56 @@ class Space {
5080
5467
  this.events.addEventListener("message", listener);
5081
5468
  return () => this.events.removeEventListener("message", listener);
5082
5469
  }
5470
+ /**
5471
+ * Edit a persisted message IN PLACE, addressed by its server
5472
+ * {@link SpaceMessageEvent.handle}. Re-seals `payload` and asks the server
5473
+ * to replace the stored entry; the server authorizes against the original
5474
+ * author. Receivers get an {@link onMessageEdited} event carrying the new
5475
+ * content at the same handle. This is a generic persisted-item edit — the
5476
+ * server has no notion of what the payload means.
5477
+ */
5478
+ async editMessage(handle, payload, opts = {}) {
5479
+ const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
5480
+ this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
5481
+ }
5482
+ /**
5483
+ * Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
5484
+ * — the ciphertext is removed from storage. Server authorizes against the
5485
+ * original author. Receivers get an {@link onMessageDeleted} event.
5486
+ */
5487
+ async deleteMessage(handle) {
5488
+ this.sendRaw({ deleteSpaceMessage: { ts: handle } });
5489
+ }
5490
+ /** Subscribe to in-place edits of persisted messages (new content at the same handle). */
5491
+ onMessageEdited(handler) {
5492
+ const listener = (e) => handler(e.detail);
5493
+ this.events.addEventListener("message-edited", listener);
5494
+ return () => this.events.removeEventListener("message-edited", listener);
5495
+ }
5496
+ /** Subscribe to deletions of persisted messages. */
5497
+ onMessageDeleted(handler) {
5498
+ const listener = (e) => handler(e.detail);
5499
+ this.events.addEventListener("message-deleted", listener);
5500
+ return () => this.events.removeEventListener("message-deleted", listener);
5501
+ }
5502
+ /**
5503
+ * Broadcast an ephemeral, never-persisted signal to the room over the
5504
+ * generic `pub` relay. `subject` is an app-defined routing key; `data` is
5505
+ * opaque. The transport stays domain-agnostic — app concepts like typing
5506
+ * indicators, presence, or live cursors are built on top of this. No-op if
5507
+ * the space isn't connected (ephemeral signals aren't worth queuing).
5508
+ */
5509
+ sendEphemeral(subject, data) {
5510
+ if (!this.isConnected())
5511
+ return;
5512
+ this.sendRaw({ pub: { subject, data } });
5513
+ }
5514
+ /** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
5515
+ onEphemeral(handler) {
5516
+ const listener = (e) => handler(e.detail);
5517
+ this.events.addEventListener("ephemeral", listener);
5518
+ return () => this.events.removeEventListener("ephemeral", listener);
5519
+ }
5083
5520
  /** Subscribe to inbound join requests (for a key-holder to admit). */
5084
5521
  onJoinRequest(handler) {
5085
5522
  const listener = (e) => handler(e.detail);
@@ -5169,9 +5606,13 @@ class Space {
5169
5606
  return this.fileStorage().readFileFromShards(manifest);
5170
5607
  }
5171
5608
  fileStorage() {
5609
+ // Global, content-addressed shard store — same store `client.storage`
5610
+ // uses, so a file shared into a channel resolves from anywhere by
5611
+ // manifest. (Shards are encrypted ciphertext; the manifest is the
5612
+ // capability.)
5172
5613
  const shards = new ShardClient({
5173
5614
  baseUrl: this.deps.httpBaseUrl,
5174
- pathPrefix: `/api/spaces/${encodeURIComponent(this.name)}/shards`,
5615
+ pathPrefix: "/api/shards",
5175
5616
  fetch: this.deps.fetch,
5176
5617
  });
5177
5618
  return new FileStorage({ shards });
@@ -5191,11 +5632,39 @@ class Space {
5191
5632
  if (!f || typeof f !== "object")
5192
5633
  return;
5193
5634
  if (typeof f.spaceMessage === "string" && this.cipher) {
5194
- const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true);
5635
+ const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
5195
5636
  if (decoded)
5196
5637
  this.emitEvent("message", decoded);
5197
5638
  return;
5198
5639
  }
5640
+ if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
5641
+ // Server-authoritative in-place edit: new sealed content at the same
5642
+ // handle. Decode and surface it; the app replaces by `handle`.
5643
+ const e = f.editSpaceMessage;
5644
+ if (typeof e.spaceMessage === "string") {
5645
+ const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
5646
+ if (decoded)
5647
+ this.emitEvent("message-edited", decoded);
5648
+ }
5649
+ return;
5650
+ }
5651
+ if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
5652
+ const d = f.deleteSpaceMessage;
5653
+ this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
5654
+ return;
5655
+ }
5656
+ if (f.pub && typeof f.pub === "object") {
5657
+ // Generic ephemeral relay frame. The server stamps the authenticated
5658
+ // sender as `name`; `pub` carries the app's {subject, data}. We
5659
+ // surface the authenticated `name` as `from` (not any client-set
5660
+ // value inside `pub`), so app signals can't spoof their sender.
5661
+ const p = f.pub;
5662
+ const from = typeof f.name === "string" ? f.name : "";
5663
+ if (from && typeof p.subject === "string") {
5664
+ this.emitEvent("ephemeral", { from, subject: p.subject, data: p.data });
5665
+ }
5666
+ return;
5667
+ }
5199
5668
  if (f.joinRequest && typeof f.joinRequest === "object") {
5200
5669
  const req = f.joinRequest;
5201
5670
  this.emitEvent("join-request", req);
@@ -5212,19 +5681,46 @@ class Space {
5212
5681
  void this.deps.keyring.pullKeys().catch(() => { });
5213
5682
  }
5214
5683
  }
5684
+ /**
5685
+ * Seal `payload` once with the current group key + sign it, returning the
5686
+ * serialized `Packet` string. Shared by send (a new message) and edit
5687
+ * (replacement content for an existing handle). Requires a keyring.
5688
+ */
5689
+ async sealMessage(payload, channel, contentType) {
5690
+ const cipher = this.requireCipher(contentType);
5691
+ const message = new Message(payload);
5692
+ const headers = await cipher.seal(message.serialize());
5693
+ const source = this.deps.myId();
5694
+ const subject = channel ?? "default";
5695
+ // Sign with our identity ECDSA key so receivers can verify authorship
5696
+ // end-to-end (independent of the relay's `source` stamp).
5697
+ const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
5698
+ if (authPriv) {
5699
+ const canonical = canonicalMessage({
5700
+ source, target: this.name, subject,
5701
+ epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
5702
+ });
5703
+ headers.sig = await signSpaceMessage(canonical, authPriv);
5704
+ }
5705
+ return new Packet({ subject, source, target: this.name, headers }).serialize();
5706
+ }
5215
5707
  /**
5216
5708
  * Decode one persisted/broadcast frame into a SpaceMessageEvent.
5217
5709
  * `isRawFrame` true means `input` is the already-extracted `spaceMessage`
5218
- * packet string; false means it's the full `{spaceMessage, name, ...}` frame.
5710
+ * packet string (and `serverTs` carries the message's storage handle from
5711
+ * the enclosing frame); false means `input` is the full
5712
+ * `{spaceMessage, name, timestamp}` frame and the handle is read from it.
5219
5713
  */
5220
- async decodeFrame(input, cipher, isRawFrame = false) {
5714
+ async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
5221
5715
  try {
5222
5716
  let packetJson = input;
5717
+ let handle = serverTs;
5223
5718
  if (!isRawFrame) {
5224
5719
  const outer = JSON.parse(input);
5225
5720
  if (typeof outer.spaceMessage !== "string")
5226
5721
  return null;
5227
5722
  packetJson = outer.spaceMessage;
5723
+ handle = Number(outer.timestamp ?? 0);
5228
5724
  }
5229
5725
  const packet = Packet.deserialize(packetJson);
5230
5726
  if (!cipher.handles(packet.headers))
@@ -5245,6 +5741,7 @@ class Space {
5245
5741
  channel: packet.subject,
5246
5742
  epoch: Number(packet.headers?.epoch ?? 0),
5247
5743
  contentType: packet.headers?.contentType,
5744
+ handle,
5248
5745
  message,
5249
5746
  };
5250
5747
  }
@@ -5995,7 +6492,9 @@ class Client {
5995
6492
  baseUrl;
5996
6493
  /** Authentication — `client.auth.zk.login(...)`, etc. */
5997
6494
  auth;
5998
- /** Per-user persistent storage — `client.storage.set(...)`, etc. */
6495
+ /** Per-user key/value storage — `client.kv.set(...)`, etc. */
6496
+ kv;
6497
+ /** File storage — `client.storage.writeFile(...)`, etc. */
5999
6498
  storage;
6000
6499
  /** Realtime messaging — `client.message.subscribe(...)`, etc. */
6001
6500
  message;
@@ -6003,6 +6502,10 @@ class Client {
6003
6502
  space;
6004
6503
  session;
6005
6504
  http;
6505
+ /** Subscribers notified when a session expires and can't be recovered. */
6506
+ sessionExpiredHandlers = new Set();
6507
+ /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
6508
+ recoverInFlight = null;
6006
6509
  constructor(options = {}) {
6007
6510
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
6008
6511
  globalThis.appLogger.setLevel(options.logLevel);
@@ -6014,19 +6517,24 @@ class Client {
6014
6517
  apiKey: options.apiKey,
6015
6518
  getSessionToken: () => this.session.token,
6016
6519
  fetch: options.fetch,
6520
+ // Self-heal stale sessions: on a 401, try a silent re-auth (only
6521
+ // works while unlocked). If it can't, `recoverSession` fires the
6522
+ // session-expired event so the app can redirect to login.
6523
+ onUnauthorized: () => this.recoverSession(),
6017
6524
  });
6018
6525
  const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
6019
6526
  const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
6020
6527
  const wsBaseUrl = toWsBase(this.baseUrl);
6021
6528
  this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
6022
- this.storage = new StorageNamespace({ http: this.http, session: this.session, wsBaseUrl });
6529
+ this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
6530
+ this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
6023
6531
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
6024
6532
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
6025
- // by StorageNamespace), so a returning member hydrates without a fresh
6026
- // keyring round-trip. The server only ever sees the ciphertext.
6533
+ // by client.kv), so a returning member hydrates without a fresh keyring
6534
+ // round-trip. The server only ever sees the ciphertext.
6027
6535
  const spaceKeyCache = {
6028
- loadKeys: (spaceId) => this.storage.get("space-keys", spaceId),
6029
- saveKeys: (spaceId, keys) => this.storage.set("space-keys", spaceId, keys),
6536
+ loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
6537
+ saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
6030
6538
  };
6031
6539
  this.space = new SpaceNamespace({
6032
6540
  http: this.http,
@@ -6043,6 +6551,48 @@ class Client {
6043
6551
  get isAuthenticated() {
6044
6552
  return this.session.isAuthenticated;
6045
6553
  }
6554
+ /**
6555
+ * Subscribe to session expiry that couldn't be silently recovered (the
6556
+ * user's identity wasn't in memory to re-prove — typically after a reload
6557
+ * where only the token was persisted, and that token has since gone stale).
6558
+ * This is the signal to send the user back to the login screen.
6559
+ *
6560
+ * Returns an unsubscribe function.
6561
+ *
6562
+ * const off = client.onSessionExpired(() => router.push('/login'));
6563
+ */
6564
+ onSessionExpired(handler) {
6565
+ this.sessionExpiredHandlers.add(handler);
6566
+ return () => this.sessionExpiredHandlers.delete(handler);
6567
+ }
6568
+ /**
6569
+ * Attempt a silent re-auth of the current user. Concurrent callers share a
6570
+ * single in-flight attempt (a stale token usually 401s several requests at
6571
+ * once — we re-auth once, not once per request). On failure the
6572
+ * session-expired event fires so the app can react. Exposed so apps can
6573
+ * also drive recovery proactively (e.g. on `visibilitychange`).
6574
+ */
6575
+ recoverSession() {
6576
+ if (this.recoverInFlight)
6577
+ return this.recoverInFlight;
6578
+ this.recoverInFlight = this.auth.zk
6579
+ .recover()
6580
+ .then((ok) => {
6581
+ if (!ok) {
6582
+ for (const handler of this.sessionExpiredHandlers) {
6583
+ try {
6584
+ handler();
6585
+ }
6586
+ catch { /* a bad listener mustn't break recovery */ }
6587
+ }
6588
+ }
6589
+ return ok;
6590
+ })
6591
+ .finally(() => {
6592
+ this.recoverInFlight = null;
6593
+ });
6594
+ return this.recoverInFlight;
6595
+ }
6046
6596
  }
6047
6597
 
6048
6598
  const appLogger$1 = new Logger("connect", 'ERROR');
@@ -6351,7 +6901,7 @@ function alloc(u32, size) {
6351
6901
  * a convenient way to passphrase-encrypt a payload before putting it.
6352
6902
  */
6353
6903
  let _snarkjs = null;
6354
- async function loadSnarkjs$1() {
6904
+ async function loadSnarkjs() {
6355
6905
  if (_snarkjs)
6356
6906
  return _snarkjs;
6357
6907
  _snarkjs = (await import('snarkjs'));
@@ -6365,21 +6915,21 @@ async function loadSnarkjs$1() {
6365
6915
  * Must stay in sync with the `nonce` public-signal width used by both
6366
6916
  * accelerator's `verifyZkAuthProof` and the `preimagePoK` circuit.
6367
6917
  */
6368
- const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
6918
+ const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
6369
6919
  /**
6370
6920
  * Reduce a hex nonce into a BN254 field-element decimal string, matching what
6371
6921
  * the Circom circuit expects as the `nonce` public signal.
6372
6922
  */
6373
- function nonceHexToFieldString$1(nonceHex) {
6923
+ function nonceHexToFieldString(nonceHex) {
6374
6924
  const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
6375
- return (BigInt(hex) % FIELD_SIZE$1).toString();
6925
+ return (BigInt(hex) % FIELD_SIZE).toString();
6376
6926
  }
6377
6927
  /**
6378
6928
  * Read the response body as JSON, falling back to text for the error message
6379
6929
  * when the body isn't JSON. Centralized so every method handles failures the
6380
6930
  * same way.
6381
6931
  */
6382
- async function parseOrThrow$1(response, label) {
6932
+ async function parseOrThrow(response, label) {
6383
6933
  if (!response.ok) {
6384
6934
  let detail = "";
6385
6935
  try {
@@ -6435,7 +6985,7 @@ class PersonalSpaceClient {
6435
6985
  const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}`;
6436
6986
  const body = { challengeId, proof, publicSignals, value };
6437
6987
  const response = await this.fetch(url, "POST", body);
6438
- await parseOrThrow$1(response, `put(${key})`);
6988
+ await parseOrThrow(response, `put(${key})`);
6439
6989
  }
6440
6990
  /** Fetch the value stored under `key`. Returns `null` if absent. */
6441
6991
  async get(key) {
@@ -6443,7 +6993,7 @@ class PersonalSpaceClient {
6443
6993
  const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}/get`;
6444
6994
  const body = { challengeId, proof, publicSignals };
6445
6995
  const response = await this.fetch(url, "POST", body);
6446
- const data = await parseOrThrow$1(response, `get(${key})`);
6996
+ const data = await parseOrThrow(response, `get(${key})`);
6447
6997
  return data.value ?? null;
6448
6998
  }
6449
6999
  /** Remove `key` from the user's space. Returns whether the key existed. */
@@ -6452,7 +7002,7 @@ class PersonalSpaceClient {
6452
7002
  const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/kv/${encodeURIComponent(key)}`;
6453
7003
  const body = { challengeId, proof, publicSignals };
6454
7004
  const response = await this.fetch(url, "DELETE", body);
6455
- const data = await parseOrThrow$1(response, `delete(${key})`);
7005
+ const data = await parseOrThrow(response, `delete(${key})`);
6456
7006
  return data.existed;
6457
7007
  }
6458
7008
  /** List every key currently stored in the user's space. */
@@ -6461,7 +7011,7 @@ class PersonalSpaceClient {
6461
7011
  const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/list`;
6462
7012
  const body = { challengeId, proof, publicSignals };
6463
7013
  const response = await this.fetch(url, "POST", body);
6464
- const data = await parseOrThrow$1(response, "list()");
7014
+ const data = await parseOrThrow(response, "list()");
6465
7015
  return Array.isArray(data.keys) ? data.keys : [];
6466
7016
  }
6467
7017
  // -------------------------------------------------------------------------
@@ -6474,7 +7024,7 @@ class PersonalSpaceClient {
6474
7024
  */
6475
7025
  async proveFreshChallenge() {
6476
7026
  const challenge = await this.requestChallenge();
6477
- const nonceField = nonceHexToFieldString$1(challenge.nonce);
7027
+ const nonceField = nonceHexToFieldString(challenge.nonce);
6478
7028
  // Inputs to the `preimagePoK` circuit. Public signals are
6479
7029
  // `[commitment, nonce, ecdsaPubHash]`; the rest are private witnesses.
6480
7030
  const input = {
@@ -6485,7 +7035,7 @@ class PersonalSpaceClient {
6485
7035
  salt: this.salt,
6486
7036
  ecdsaPub: this.ecdsaPub,
6487
7037
  };
6488
- const snarkjs = await loadSnarkjs$1();
7038
+ const snarkjs = await loadSnarkjs();
6489
7039
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
6490
7040
  return {
6491
7041
  challengeId: challenge.challengeId,
@@ -6497,7 +7047,7 @@ class PersonalSpaceClient {
6497
7047
  async requestChallenge() {
6498
7048
  const url = `${this.baseUrl}/api/personal/${encodeURIComponent(this.commitment)}/challenge`;
6499
7049
  const response = await this.fetch(url, "POST", {});
6500
- return await parseOrThrow$1(response, "challenge");
7050
+ return await parseOrThrow(response, "challenge");
6501
7051
  }
6502
7052
  /**
6503
7053
  * Thin wrapper over `globalThis.fetch` so all methods share a single set of
@@ -6518,251 +7068,6 @@ class PersonalSpaceClient {
6518
7068
  }
6519
7069
  }
6520
7070
 
6521
- /**
6522
- * HTTP client for the multi-user `SharedSpaceDO` (Phase B).
6523
- *
6524
- * Mirrors the proven shape of `PersonalSpaceClient` — every gated call:
6525
- * 1. POSTs `/challenge` to get a fresh nonce keyed to the caller's commitment
6526
- * 2. Generates a Groth16 proof for the `preimagePoK` circuit with the nonce
6527
- * reduced into a BN254 field element as the public signal
6528
- * 3. POSTs the operation with `{ challengeId, proof, publicSignals, ... }`
6529
- *
6530
- * What's different from `PersonalSpaceClient`:
6531
- * - Addressed by space ID, not user commitment. Multiple users can be
6532
- * members of the same space.
6533
- * - The space carries a participants list + ACL (shared types from
6534
- * `src/types/permissions.ts`). The server checks that the proving
6535
- * commitment is a participant with the requested permission before each
6536
- * mutation.
6537
- * - Stores file manifests (not generic KV blobs). The shape is constrained
6538
- * by `FileManifest` from `../types`.
6539
- *
6540
- * Wire protocol (this client implements the client side; the server side is
6541
- * the next slice of work in accelerator):
6542
- *
6543
- * POST /api/spaces create a new space (gated by caller proof)
6544
- * body: { challengeId, proof, publicSignals, initialAcl? }
6545
- * returns: { spaceId }
6546
- *
6547
- * POST /api/spaces/:spaceId/challenge issue a nonce for the caller
6548
- * body: { commitment }
6549
- * returns: { challengeId, nonce }
6550
- *
6551
- * POST /api/spaces/:spaceId/files write a file manifest
6552
- * body: { challengeId, proof, publicSignals, manifest }
6553
- * returns: { ok: true, fileId }
6554
- *
6555
- * POST /api/spaces/:spaceId/files/:fileId/get read a file manifest
6556
- * body: { challengeId, proof, publicSignals }
6557
- * returns: { manifest }
6558
- *
6559
- * DELETE /api/spaces/:spaceId/files/:fileId delete a file manifest
6560
- * body: { challengeId, proof, publicSignals }
6561
- * returns: { ok: true, existed: boolean }
6562
- *
6563
- * POST /api/spaces/:spaceId/files/list list manifests in space
6564
- * body: { challengeId, proof, publicSignals }
6565
- * returns: { files: FileStat[] }
6566
- *
6567
- * POST /api/spaces/:spaceId/participants add a participant
6568
- * body: { challengeId, proof, publicSignals, participant: Participant }
6569
- * returns: { ok: true }
6570
- *
6571
- * DELETE /api/spaces/:spaceId/participants/:commitment remove a participant
6572
- * body: { challengeId, proof, publicSignals }
6573
- * returns: { ok: true, existed: boolean }
6574
- *
6575
- * POST /api/spaces/:spaceId/acl set / replace an ACL entry
6576
- * body: { challengeId, proof, publicSignals, entry: ACLEntry }
6577
- * returns: { ok: true }
6578
- */
6579
- let snarkjsModule = null;
6580
- async function loadSnarkjs() {
6581
- if (snarkjsModule)
6582
- return snarkjsModule;
6583
- snarkjsModule = (await import('snarkjs'));
6584
- return snarkjsModule;
6585
- }
6586
- // BN254 scalar field modulus — same value used everywhere else in the ZK path.
6587
- const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
6588
- class SharedSpaceClient {
6589
- baseUrl;
6590
- commitment;
6591
- secret;
6592
- salt;
6593
- ecdsaPub;
6594
- ecdsaPubHash;
6595
- wasmUrl;
6596
- zkeyUrl;
6597
- fetchFn;
6598
- constructor(opts) {
6599
- if (!opts?.baseUrl)
6600
- throw new Error("SharedSpaceClient: `baseUrl` is required");
6601
- if (!opts.commitment)
6602
- throw new Error("SharedSpaceClient: `commitment` is required");
6603
- if (!opts.secret)
6604
- throw new Error("SharedSpaceClient: `secret` is required");
6605
- if (!opts.salt)
6606
- throw new Error("SharedSpaceClient: `salt` is required");
6607
- if (!opts.ecdsaPub)
6608
- throw new Error("SharedSpaceClient: `ecdsaPub` is required");
6609
- if (!opts.ecdsaPubHash)
6610
- throw new Error("SharedSpaceClient: `ecdsaPubHash` is required");
6611
- if (!opts.circuits?.wasmUrl || !opts.circuits?.zkeyUrl) {
6612
- throw new Error("SharedSpaceClient: `circuits.wasmUrl` and `circuits.zkeyUrl` are required");
6613
- }
6614
- this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
6615
- this.commitment = opts.commitment;
6616
- this.secret = opts.secret;
6617
- this.salt = opts.salt;
6618
- this.ecdsaPub = opts.ecdsaPub;
6619
- this.ecdsaPubHash = opts.ecdsaPubHash;
6620
- this.wasmUrl = opts.circuits.wasmUrl;
6621
- this.zkeyUrl = opts.circuits.zkeyUrl;
6622
- const f = opts.fetch ?? globalThis.fetch;
6623
- if (typeof f !== "function") {
6624
- throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
6625
- }
6626
- this.fetchFn = f.bind(globalThis);
6627
- }
6628
- /**
6629
- * Create a brand new space. The caller becomes the owner; `initialAcl`
6630
- * adds any additional ACL entries up front (e.g. inviting a co-owner at
6631
- * creation time).
6632
- */
6633
- async createSpace(initialAcl) {
6634
- // Creating a space still requires the caller to prove they hold the
6635
- // commitment they're claiming to own — that's what the proof
6636
- // accomplishes here. The server issues a challenge against a global
6637
- // "newspace" pseudo-id for the bootstrap step.
6638
- const proof = await this.proveFreshChallenge("__new__");
6639
- const body = { ...proof };
6640
- if (initialAcl)
6641
- body.initialAcl = initialAcl;
6642
- const url = `${this.baseUrl}/api/spaces`;
6643
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
6644
- const data = await parseOrThrow(response, "createSpace");
6645
- return { spaceId: data.spaceId };
6646
- }
6647
- /** Write a file manifest into the space. Requires `write` permission. */
6648
- async writeFileManifest(spaceId, manifest) {
6649
- const proof = await this.proveFreshChallenge(spaceId);
6650
- const body = { ...proof, manifest };
6651
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
6652
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
6653
- await parseOrThrow(response, `writeFileManifest(${manifest.id})`);
6654
- }
6655
- /** Read a file manifest by id. Requires `read` permission. */
6656
- async readFileManifest(spaceId, fileId) {
6657
- const proof = await this.proveFreshChallenge(spaceId);
6658
- const body = { ...proof };
6659
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}/get`;
6660
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
6661
- const data = await parseOrThrow(response, `readFileManifest(${fileId})`);
6662
- return data.manifest;
6663
- }
6664
- /** Delete a file manifest by id. Requires `delete` permission. */
6665
- async deleteFileManifest(spaceId, fileId) {
6666
- const proof = await this.proveFreshChallenge(spaceId);
6667
- const body = { ...proof };
6668
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}`;
6669
- const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
6670
- const data = await parseOrThrow(response, `deleteFileManifest(${fileId})`);
6671
- return data.existed;
6672
- }
6673
- /** List file summaries in the space. Requires `read` permission. */
6674
- async listFiles(spaceId) {
6675
- const proof = await this.proveFreshChallenge(spaceId);
6676
- const body = { ...proof };
6677
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/list`;
6678
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
6679
- const data = await parseOrThrow(response, "listFiles");
6680
- return Array.isArray(data.files) ? data.files : [];
6681
- }
6682
- /** Add a participant. Requires `invite` or `admin`. */
6683
- async addParticipant(spaceId, participant) {
6684
- const proof = await this.proveFreshChallenge(spaceId);
6685
- const body = { ...proof, participant };
6686
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants`;
6687
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
6688
- await parseOrThrow(response, "addParticipant");
6689
- }
6690
- /** Remove a participant by their public-key identity. Requires `admin`. */
6691
- async removeParticipant(spaceId, participantCommitment) {
6692
- const proof = await this.proveFreshChallenge(spaceId);
6693
- const body = { ...proof };
6694
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants/${encodeURIComponent(participantCommitment)}`;
6695
- const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
6696
- const data = await parseOrThrow(response, "removeParticipant");
6697
- return data.existed;
6698
- }
6699
- /** Set / replace an ACL entry. Requires `admin`. */
6700
- async setACLEntry(spaceId, entry) {
6701
- const proof = await this.proveFreshChallenge(spaceId);
6702
- const body = { ...proof, entry };
6703
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/acl`;
6704
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
6705
- await parseOrThrow(response, "setACLEntry");
6706
- }
6707
- // -------------------------------------------------------------------------
6708
- // Internals
6709
- // -------------------------------------------------------------------------
6710
- /** Request a challenge for `spaceId` and generate the matching proof. */
6711
- async proveFreshChallenge(spaceId) {
6712
- const challenge = await this.requestChallenge(spaceId);
6713
- const nonceField = nonceHexToFieldString(challenge.nonce);
6714
- const input = {
6715
- commitment: this.commitment,
6716
- nonce: nonceField,
6717
- ecdsaPubHash: this.ecdsaPubHash,
6718
- secret: this.secret,
6719
- salt: this.salt,
6720
- ecdsaPub: this.ecdsaPub,
6721
- };
6722
- const snarkjs = await loadSnarkjs();
6723
- const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
6724
- return {
6725
- challengeId: challenge.challengeId,
6726
- proof: proof,
6727
- publicSignals: publicSignals,
6728
- };
6729
- }
6730
- /**
6731
- * Hit `/challenge`. The body includes the caller's commitment so the
6732
- * server can scope the issued nonce to this principal.
6733
- */
6734
- async requestChallenge(spaceId) {
6735
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/challenge`;
6736
- const response = await this.fetchFn(url, this.jsonRequest("POST", { commitment: this.commitment }));
6737
- return await parseOrThrow(response, "challenge");
6738
- }
6739
- jsonRequest(method, body) {
6740
- return {
6741
- method,
6742
- headers: { "content-type": "application/json" },
6743
- body: JSON.stringify(body ?? {}),
6744
- };
6745
- }
6746
- }
6747
- function nonceHexToFieldString(nonceHex) {
6748
- const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
6749
- return (BigInt(hex) % FIELD_SIZE).toString();
6750
- }
6751
- async function parseOrThrow(response, label) {
6752
- if (!response.ok) {
6753
- let detail = "";
6754
- try {
6755
- const text = await response.text();
6756
- detail = text ? `: ${text}` : "";
6757
- }
6758
- catch {
6759
- // ignore — we still throw with the status
6760
- }
6761
- throw new Error(`SharedSpaceClient ${label} failed (${response.status})${detail}`);
6762
- }
6763
- return (await response.json());
6764
- }
6765
-
6766
7071
  /**
6767
7072
  * PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
6768
7073
  *
@@ -6826,5 +7131,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
6826
7131
  default: loadRsWasm
6827
7132
  });
6828
7133
 
6829
- export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
7134
+ export { AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, DEFAULT_BASE_URL, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, HashKnowledge, HttpClient, HttpError, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvNamespace, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, compilePrograms, concatBytes, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deserialize, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateId, getIPAddress, getId, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, isNumber, log, manifestToStat, poseidonHash, quickVerify, randomBytes, serialize, shardHash, signMessage, toBase64, toBase64Url, toField, toHex, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
6830
7135
  //# sourceMappingURL=index.js.map