@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.
@@ -2383,10 +2383,29 @@ function isCipherFrame(frame) {
2383
2383
  * - Emit lifecycle events (CONNECTED / DISCONNECTED / RECONNECTING / ERROR)
2384
2384
  * plus raw inbound MESSAGE events
2385
2385
  */
2386
+ /** Monotonic source of unique transport ids when a caller doesn't supply one. */
2387
+ let __wsInstanceSeq = 0;
2386
2388
  class WSTransport extends EventCore {
2387
- emit = EventCore.emit;
2388
- on = EventCore.on;
2389
- off = EventCore.off;
2389
+ /**
2390
+ * Per-instance event scope. `EventCore` is a process-global static emitter,
2391
+ * so without namespacing every WSTransport would hear every other one's
2392
+ * frames. We prefix each event with this transport's `id` and expose
2393
+ * `on`/`off`/`emit` that scope transparently — callers still use the plain
2394
+ * `EventCoreEvents` enum and only ever see their own connection's events.
2395
+ */
2396
+ id;
2397
+ emit = (event, data) => {
2398
+ EventCore.emit(this.scopedEvent(event), data);
2399
+ };
2400
+ on = (event, handler) => {
2401
+ EventCore.on(this.scopedEvent(event), handler);
2402
+ };
2403
+ off = (event, handler) => {
2404
+ EventCore.off(this.scopedEvent(event), handler);
2405
+ };
2406
+ scopedEvent(event) {
2407
+ return `${this.id}:${event}`;
2408
+ }
2390
2409
  url;
2391
2410
  socket = null;
2392
2411
  _isConnected = false;
@@ -2400,16 +2419,28 @@ class WSTransport extends EventCore {
2400
2419
  // Outbound queue (raw frame strings — already serialized by caller)
2401
2420
  outboundQueue = [];
2402
2421
  maxQueueSize;
2422
+ // Heartbeat / liveness state
2423
+ heartbeatInterval;
2424
+ heartbeatTimeout;
2425
+ pingFrame;
2426
+ pongFrame;
2427
+ heartbeatTimer = null;
2428
+ pongTimer = null;
2403
2429
  constructor(opts) {
2404
2430
  super();
2405
2431
  if (!opts || !opts.url) {
2406
2432
  throw new Error("WSTransport: `url` is required");
2407
2433
  }
2408
2434
  this.url = opts.url;
2435
+ this.id = opts.id ?? `ws${++__wsInstanceSeq}`;
2409
2436
  this.autoReconnect = opts.autoReconnect ?? true;
2410
2437
  this.reconnectDelay = opts.reconnectDelay ?? 3000;
2411
2438
  this.maxReconnectAttempts = opts.maxReconnectAttempts ?? 5;
2412
2439
  this.maxQueueSize = opts.maxQueueSize ?? 100;
2440
+ this.heartbeatInterval = opts.heartbeatInterval ?? 30000;
2441
+ this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
2442
+ this.pingFrame = opts.pingFrame ?? "ping";
2443
+ this.pongFrame = opts.pongFrame ?? "pong";
2413
2444
  }
2414
2445
  /** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
2415
2446
  async connect() {
@@ -2427,9 +2458,19 @@ class WSTransport extends EventCore {
2427
2458
  this.reconnectAttempts = 0;
2428
2459
  this.emit(EventCoreEvents.CONNECTED, undefined);
2429
2460
  this.flushQueue();
2461
+ this.startHeartbeat();
2430
2462
  resolve();
2431
2463
  };
2432
2464
  socket.onmessage = (event) => {
2465
+ // Heartbeat ack — swallow it (don't surface as a MESSAGE) and clear
2466
+ // the liveness deadline. The layer above never sees keep-alive frames.
2467
+ if (event.data === this.pongFrame) {
2468
+ if (this.pongTimer) {
2469
+ clearTimeout(this.pongTimer);
2470
+ this.pongTimer = null;
2471
+ }
2472
+ return;
2473
+ }
2433
2474
  // Raw inbound frame — layer above is responsible for parsing.
2434
2475
  this.emit(EventCoreEvents.MESSAGE, event.data);
2435
2476
  };
@@ -2444,6 +2485,7 @@ class WSTransport extends EventCore {
2444
2485
  const wasConnected = this._isConnected;
2445
2486
  this._isConnected = false;
2446
2487
  this._isConnecting = false;
2488
+ this.stopHeartbeat();
2447
2489
  this.emit(EventCoreEvents.DISCONNECTED, undefined);
2448
2490
  if (this.autoReconnect && wasConnected) {
2449
2491
  this.scheduleReconnect();
@@ -2462,6 +2504,7 @@ class WSTransport extends EventCore {
2462
2504
  clearTimeout(this.reconnectTimeout);
2463
2505
  this.reconnectTimeout = null;
2464
2506
  }
2507
+ this.stopHeartbeat();
2465
2508
  this.autoReconnect = false;
2466
2509
  if (this.socket) {
2467
2510
  try {
@@ -2492,6 +2535,78 @@ class WSTransport extends EventCore {
2492
2535
  queuedFrames() { return this.outboundQueue.length; }
2493
2536
  reconnectAttemptCount() { return this.reconnectAttempts; }
2494
2537
  // -- internals -------------------------------------------------------------
2538
+ /** Begin the keep-alive loop (no-op when disabled). */
2539
+ startHeartbeat() {
2540
+ if (this.heartbeatInterval <= 0)
2541
+ return;
2542
+ this.stopHeartbeat();
2543
+ this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
2544
+ }
2545
+ /** Stop the keep-alive loop and clear any pending liveness deadline. */
2546
+ stopHeartbeat() {
2547
+ if (this.heartbeatTimer) {
2548
+ clearInterval(this.heartbeatTimer);
2549
+ this.heartbeatTimer = null;
2550
+ }
2551
+ if (this.pongTimer) {
2552
+ clearTimeout(this.pongTimer);
2553
+ this.pongTimer = null;
2554
+ }
2555
+ }
2556
+ /**
2557
+ * Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
2558
+ * `heartbeatTimeout`, the connection is considered dead and we force a
2559
+ * reconnect — this is what catches a silently-dropped TCP connection that
2560
+ * never fired `onclose` (the failure mode that "loses connection with the
2561
+ * Accelerator" on long sessions).
2562
+ */
2563
+ sendHeartbeat() {
2564
+ if (!this._isConnected || !this.socket)
2565
+ return;
2566
+ try {
2567
+ this.socket.send(this.pingFrame);
2568
+ }
2569
+ catch {
2570
+ this.forceReconnect();
2571
+ return;
2572
+ }
2573
+ // A still-pending deadline means the previous pong never came back.
2574
+ if (this.pongTimer)
2575
+ return;
2576
+ this.pongTimer = setTimeout(() => {
2577
+ this.pongTimer = null;
2578
+ this.forceReconnect();
2579
+ }, this.heartbeatTimeout);
2580
+ }
2581
+ /**
2582
+ * Tear down a connection we believe is dead and kick off reconnect, without
2583
+ * waiting on the OS/browser to eventually fire `onclose` (which a half-open
2584
+ * socket may never do). We detach the handlers first so the normal close path
2585
+ * can't double-fire the reconnect.
2586
+ */
2587
+ forceReconnect() {
2588
+ this.stopHeartbeat();
2589
+ const sock = this.socket;
2590
+ const wasConnected = this._isConnected;
2591
+ this.socket = null;
2592
+ this._isConnected = false;
2593
+ this._isConnecting = false;
2594
+ if (sock) {
2595
+ sock.onopen = null;
2596
+ sock.onmessage = null;
2597
+ sock.onerror = null;
2598
+ sock.onclose = null;
2599
+ try {
2600
+ sock.close();
2601
+ }
2602
+ catch { /* already gone */ }
2603
+ }
2604
+ this.emit(EventCoreEvents.ERROR, new Error("WSTransport: heartbeat timeout"));
2605
+ this.emit(EventCoreEvents.DISCONNECTED, undefined);
2606
+ if (this.autoReconnect && wasConnected) {
2607
+ this.scheduleReconnect();
2608
+ }
2609
+ }
2495
2610
  scheduleReconnect() {
2496
2611
  if (this.maxReconnectAttempts > 0 &&
2497
2612
  this.reconnectAttempts >= this.maxReconnectAttempts) {
@@ -3040,7 +3155,7 @@ function alloc(u32, size) {
3040
3155
  * a convenient way to passphrase-encrypt a payload before putting it.
3041
3156
  */
3042
3157
  let _snarkjs$1 = null;
3043
- async function loadSnarkjs$2() {
3158
+ async function loadSnarkjs$1() {
3044
3159
  if (_snarkjs$1)
3045
3160
  return _snarkjs$1;
3046
3161
  _snarkjs$1 = (await import('snarkjs'));
@@ -3054,14 +3169,14 @@ async function loadSnarkjs$2() {
3054
3169
  * Must stay in sync with the `nonce` public-signal width used by both
3055
3170
  * accelerator's `verifyZkAuthProof` and the `preimagePoK` circuit.
3056
3171
  */
3057
- const FIELD_SIZE$2 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
3172
+ const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
3058
3173
  /**
3059
3174
  * Reduce a hex nonce into a BN254 field-element decimal string, matching what
3060
3175
  * the Circom circuit expects as the `nonce` public signal.
3061
3176
  */
3062
- function nonceHexToFieldString$1(nonceHex) {
3177
+ function nonceHexToFieldString(nonceHex) {
3063
3178
  const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
3064
- return (BigInt(hex) % FIELD_SIZE$2).toString();
3179
+ return (BigInt(hex) % FIELD_SIZE$1).toString();
3065
3180
  }
3066
3181
  /**
3067
3182
  * Read the response body as JSON, falling back to text for the error message
@@ -3163,7 +3278,7 @@ class PersonalSpaceClient {
3163
3278
  */
3164
3279
  async proveFreshChallenge() {
3165
3280
  const challenge = await this.requestChallenge();
3166
- const nonceField = nonceHexToFieldString$1(challenge.nonce);
3281
+ const nonceField = nonceHexToFieldString(challenge.nonce);
3167
3282
  // Inputs to the `preimagePoK` circuit. Public signals are
3168
3283
  // `[commitment, nonce, ecdsaPubHash]`; the rest are private witnesses.
3169
3284
  const input = {
@@ -3174,7 +3289,7 @@ class PersonalSpaceClient {
3174
3289
  salt: this.salt,
3175
3290
  ecdsaPub: this.ecdsaPub,
3176
3291
  };
3177
- const snarkjs = await loadSnarkjs$2();
3292
+ const snarkjs = await loadSnarkjs$1();
3178
3293
  const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
3179
3294
  return {
3180
3295
  challengeId: challenge.challengeId,
@@ -4017,248 +4132,94 @@ async function toUint8Array(data) {
4017
4132
  }
4018
4133
 
4019
4134
  /**
4020
- * HTTP client for the multi-user `SharedSpaceDO` (Phase B).
4021
- *
4022
- * Mirrors the proven shape of `PersonalSpaceClient` — every gated call:
4023
- * 1. POSTs `/challenge` to get a fresh nonce keyed to the caller's commitment
4024
- * 2. Generates a Groth16 proof for the `preimagePoK` circuit with the nonce
4025
- * reduced into a BN254 field element as the public signal
4026
- * 3. POSTs the operation with `{ challengeId, proof, publicSignals, ... }`
4027
- *
4028
- * What's different from `PersonalSpaceClient`:
4029
- * - Addressed by space ID, not user commitment. Multiple users can be
4030
- * members of the same space.
4031
- * - The space carries a participants list + ACL (shared types from
4032
- * `src/types/permissions.ts`). The server checks that the proving
4033
- * commitment is a participant with the requested permission before each
4034
- * mutation.
4035
- * - Stores file manifests (not generic KV blobs). The shape is constrained
4036
- * by `FileManifest` from `../types`.
4037
- *
4038
- * Wire protocol (this client implements the client side; the server side is
4039
- * the next slice of work in accelerator):
4040
- *
4041
- * POST /api/spaces create a new space (gated by caller proof)
4042
- * body: { challengeId, proof, publicSignals, initialAcl? }
4043
- * returns: { spaceId }
4044
- *
4045
- * POST /api/spaces/:spaceId/challenge issue a nonce for the caller
4046
- * body: { commitment }
4047
- * returns: { challengeId, nonce }
4048
- *
4049
- * POST /api/spaces/:spaceId/files write a file manifest
4050
- * body: { challengeId, proof, publicSignals, manifest }
4051
- * returns: { ok: true, fileId }
4052
- *
4053
- * POST /api/spaces/:spaceId/files/:fileId/get read a file manifest
4054
- * body: { challengeId, proof, publicSignals }
4055
- * returns: { manifest }
4056
- *
4057
- * DELETE /api/spaces/:spaceId/files/:fileId delete a file manifest
4058
- * body: { challengeId, proof, publicSignals }
4059
- * returns: { ok: true, existed: boolean }
4060
- *
4061
- * POST /api/spaces/:spaceId/files/list list manifests in space
4062
- * body: { challengeId, proof, publicSignals }
4063
- * returns: { files: FileStat[] }
4064
- *
4065
- * POST /api/spaces/:spaceId/participants add a participant
4066
- * body: { challengeId, proof, publicSignals, participant: Participant }
4067
- * returns: { ok: true }
4068
- *
4069
- * DELETE /api/spaces/:spaceId/participants/:commitment remove a participant
4070
- * body: { challengeId, proof, publicSignals }
4071
- * returns: { ok: true, existed: boolean }
4072
- *
4073
- * POST /api/spaces/:spaceId/acl set / replace an ACL entry
4074
- * body: { challengeId, proof, publicSignals, entry: ACLEntry }
4075
- * returns: { ok: true }
4135
+ * HTTP client for the gated file-manifest store on a SharedSpace
4136
+ * (`/api/spaces/:spaceId/files`).
4137
+ *
4138
+ * A "file" in a space is its **manifest** (per-chunk keys + the SHA-256 of each
4139
+ * global shard). The shard bytes live in the open `/api/shards` store; the
4140
+ * manifest is the capability. This client only moves manifests in/out of the
4141
+ * space's gated store.
4142
+ *
4143
+ * Authorization is **session-based**, consistent with the rest of the
4144
+ * SharedSpace (keyring, websocket): the SDK's credential-stamping `fetch`
4145
+ * (from {@link HttpClient}) attaches the app key + session token, the platform
4146
+ * validates the session and injects a trusted `X-Muhkoo-User-Context`, and the
4147
+ * DO authorizes on that + channel membership. There is no per-op Groth16 proof.
4148
+ *
4149
+ * Wire protocol (server: `SharedSpaceDO`):
4150
+ * POST /api/spaces/:spaceId/files body: { manifest } → { ok, fileId }
4151
+ * GET /api/spaces/:spaceId/files → { files: FileStat[] }
4152
+ * GET /api/spaces/:spaceId/files/:fileId → { manifest }
4153
+ * DELETE /api/spaces/:spaceId/files/:fileId → { ok, existed }
4076
4154
  */
4077
- let snarkjsModule = null;
4078
- async function loadSnarkjs$1() {
4079
- if (snarkjsModule)
4080
- return snarkjsModule;
4081
- snarkjsModule = (await import('snarkjs'));
4082
- return snarkjsModule;
4083
- }
4084
- // BN254 scalar field modulus — same value used everywhere else in the ZK path.
4085
- const FIELD_SIZE$1 = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
4086
4155
  class SharedSpaceClient {
4087
4156
  baseUrl;
4088
- commitment;
4089
- secret;
4090
- salt;
4091
- ecdsaPub;
4092
- ecdsaPubHash;
4093
- wasmUrl;
4094
- zkeyUrl;
4095
4157
  fetchFn;
4096
4158
  constructor(opts) {
4097
4159
  if (!opts?.baseUrl)
4098
4160
  throw new Error("SharedSpaceClient: `baseUrl` is required");
4099
- if (!opts.commitment)
4100
- throw new Error("SharedSpaceClient: `commitment` is required");
4101
- if (!opts.secret)
4102
- throw new Error("SharedSpaceClient: `secret` is required");
4103
- if (!opts.salt)
4104
- throw new Error("SharedSpaceClient: `salt` is required");
4105
- if (!opts.ecdsaPub)
4106
- throw new Error("SharedSpaceClient: `ecdsaPub` is required");
4107
- if (!opts.ecdsaPubHash)
4108
- throw new Error("SharedSpaceClient: `ecdsaPubHash` is required");
4109
- if (!opts.circuits?.wasmUrl || !opts.circuits?.zkeyUrl) {
4110
- throw new Error("SharedSpaceClient: `circuits.wasmUrl` and `circuits.zkeyUrl` are required");
4111
- }
4112
4161
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
4113
- this.commitment = opts.commitment;
4114
- this.secret = opts.secret;
4115
- this.salt = opts.salt;
4116
- this.ecdsaPub = opts.ecdsaPub;
4117
- this.ecdsaPubHash = opts.ecdsaPubHash;
4118
- this.wasmUrl = opts.circuits.wasmUrl;
4119
- this.zkeyUrl = opts.circuits.zkeyUrl;
4120
4162
  const f = opts.fetch ?? globalThis.fetch;
4121
4163
  if (typeof f !== "function") {
4122
4164
  throw new Error("SharedSpaceClient: `globalThis.fetch` is unavailable; pass an explicit fetch.");
4123
4165
  }
4124
4166
  this.fetchFn = f.bind(globalThis);
4125
4167
  }
4126
- /**
4127
- * Create a brand new space. The caller becomes the owner; `initialAcl`
4128
- * adds any additional ACL entries up front (e.g. inviting a co-owner at
4129
- * creation time).
4130
- */
4131
- async createSpace(initialAcl) {
4132
- // Creating a space still requires the caller to prove they hold the
4133
- // commitment they're claiming to own — that's what the proof
4134
- // accomplishes here. The server issues a challenge against a global
4135
- // "newspace" pseudo-id for the bootstrap step.
4136
- const proof = await this.proveFreshChallenge("__new__");
4137
- const body = { ...proof };
4138
- if (initialAcl)
4139
- body.initialAcl = initialAcl;
4140
- const url = `${this.baseUrl}/api/spaces`;
4141
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
4142
- const data = await parseOrThrow(response, "createSpace");
4143
- return { spaceId: data.spaceId };
4144
- }
4145
- /** Write a file manifest into the space. Requires `write` permission. */
4168
+ /** Write a file manifest into the space. Requires space membership. */
4146
4169
  async writeFileManifest(spaceId, manifest) {
4147
- const proof = await this.proveFreshChallenge(spaceId);
4148
- const body = { ...proof, manifest };
4149
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
4150
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
4151
- await parseOrThrow(response, `writeFileManifest(${manifest.id})`);
4170
+ const url = `${this.filesBase(spaceId)}`;
4171
+ const res = await this.fetchFn(url, this.jsonRequest("POST", { manifest }));
4172
+ await parseOrThrow(res, `writeFileManifest(${manifest.id})`);
4152
4173
  }
4153
- /** Read a file manifest by id. Requires `read` permission. */
4174
+ /** Read a file manifest by id. Requires space membership. */
4154
4175
  async readFileManifest(spaceId, fileId) {
4155
- const proof = await this.proveFreshChallenge(spaceId);
4156
- const body = { ...proof };
4157
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}/get`;
4158
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
4159
- const data = await parseOrThrow(response, `readFileManifest(${fileId})`);
4176
+ const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
4177
+ const res = await this.fetchFn(url, this.jsonRequest("GET"));
4178
+ const data = await parseOrThrow(res, `readFileManifest(${fileId})`);
4160
4179
  return data.manifest;
4161
4180
  }
4162
- /** Delete a file manifest by id. Requires `delete` permission. */
4181
+ /** Delete a file manifest by id. Requires space membership. */
4163
4182
  async deleteFileManifest(spaceId, fileId) {
4164
- const proof = await this.proveFreshChallenge(spaceId);
4165
- const body = { ...proof };
4166
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/${encodeURIComponent(fileId)}`;
4167
- const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
4168
- const data = await parseOrThrow(response, `deleteFileManifest(${fileId})`);
4183
+ const url = `${this.filesBase(spaceId)}/${encodeURIComponent(fileId)}`;
4184
+ const res = await this.fetchFn(url, this.jsonRequest("DELETE"));
4185
+ const data = await parseOrThrow(res, `deleteFileManifest(${fileId})`);
4169
4186
  return data.existed;
4170
4187
  }
4171
- /** List file summaries in the space. Requires `read` permission. */
4188
+ /** List file summaries in the space. Requires space membership. */
4172
4189
  async listFiles(spaceId) {
4173
- const proof = await this.proveFreshChallenge(spaceId);
4174
- const body = { ...proof };
4175
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files/list`;
4176
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
4177
- const data = await parseOrThrow(response, "listFiles");
4190
+ const url = `${this.filesBase(spaceId)}`;
4191
+ const res = await this.fetchFn(url, this.jsonRequest("GET"));
4192
+ const data = await parseOrThrow(res, "listFiles");
4178
4193
  return Array.isArray(data.files) ? data.files : [];
4179
4194
  }
4180
- /** Add a participant. Requires `invite` or `admin`. */
4181
- async addParticipant(spaceId, participant) {
4182
- const proof = await this.proveFreshChallenge(spaceId);
4183
- const body = { ...proof, participant };
4184
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants`;
4185
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
4186
- await parseOrThrow(response, "addParticipant");
4187
- }
4188
- /** Remove a participant by their public-key identity. Requires `admin`. */
4189
- async removeParticipant(spaceId, participantCommitment) {
4190
- const proof = await this.proveFreshChallenge(spaceId);
4191
- const body = { ...proof };
4192
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/participants/${encodeURIComponent(participantCommitment)}`;
4193
- const response = await this.fetchFn(url, this.jsonRequest("DELETE", body));
4194
- const data = await parseOrThrow(response, "removeParticipant");
4195
- return data.existed;
4196
- }
4197
- /** Set / replace an ACL entry. Requires `admin`. */
4198
- async setACLEntry(spaceId, entry) {
4199
- const proof = await this.proveFreshChallenge(spaceId);
4200
- const body = { ...proof, entry };
4201
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/acl`;
4202
- const response = await this.fetchFn(url, this.jsonRequest("POST", body));
4203
- await parseOrThrow(response, "setACLEntry");
4204
- }
4205
- // -------------------------------------------------------------------------
4206
- // Internals
4207
4195
  // -------------------------------------------------------------------------
4208
- /** Request a challenge for `spaceId` and generate the matching proof. */
4209
- async proveFreshChallenge(spaceId) {
4210
- const challenge = await this.requestChallenge(spaceId);
4211
- const nonceField = nonceHexToFieldString(challenge.nonce);
4212
- const input = {
4213
- commitment: this.commitment,
4214
- nonce: nonceField,
4215
- ecdsaPubHash: this.ecdsaPubHash,
4216
- secret: this.secret,
4217
- salt: this.salt,
4218
- ecdsaPub: this.ecdsaPub,
4219
- };
4220
- const snarkjs = await loadSnarkjs$1();
4221
- const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, this.wasmUrl, this.zkeyUrl);
4222
- return {
4223
- challengeId: challenge.challengeId,
4224
- proof: proof,
4225
- publicSignals: publicSignals,
4226
- };
4227
- }
4228
- /**
4229
- * Hit `/challenge`. The body includes the caller's commitment so the
4230
- * server can scope the issued nonce to this principal.
4231
- */
4232
- async requestChallenge(spaceId) {
4233
- const url = `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/challenge`;
4234
- const response = await this.fetchFn(url, this.jsonRequest("POST", { commitment: this.commitment }));
4235
- return await parseOrThrow(response, "challenge");
4196
+ filesBase(spaceId) {
4197
+ return `${this.baseUrl}/api/spaces/${encodeURIComponent(spaceId)}/files`;
4236
4198
  }
4237
4199
  jsonRequest(method, body) {
4238
- return {
4239
- method,
4240
- headers: { "content-type": "application/json" },
4241
- body: JSON.stringify(body ?? {}),
4242
- };
4200
+ const init = { method };
4201
+ if (body !== undefined) {
4202
+ init.headers = { "Content-Type": "application/json" };
4203
+ init.body = JSON.stringify(body);
4204
+ }
4205
+ return init;
4243
4206
  }
4244
4207
  }
4245
- function nonceHexToFieldString(nonceHex) {
4246
- const hex = nonceHex.startsWith("0x") ? nonceHex : "0x" + nonceHex;
4247
- return (BigInt(hex) % FIELD_SIZE$1).toString();
4248
- }
4249
- async function parseOrThrow(response, label) {
4208
+ async function parseOrThrow(response, op) {
4209
+ let parsed = null;
4210
+ try {
4211
+ parsed = await response.json();
4212
+ }
4213
+ catch {
4214
+ // non-JSON body
4215
+ }
4250
4216
  if (!response.ok) {
4251
- let detail = "";
4252
- try {
4253
- const text = await response.text();
4254
- detail = text ? `: ${text}` : "";
4255
- }
4256
- catch {
4257
- // ignore — we still throw with the status
4258
- }
4259
- throw new Error(`SharedSpaceClient ${label} failed (${response.status})${detail}`);
4217
+ const msg = parsed && typeof parsed === "object" && "error" in parsed
4218
+ ? String(parsed.error)
4219
+ : `${response.status} ${response.statusText}`;
4220
+ throw new Error(`SharedSpaceClient.${op}: ${msg}`);
4260
4221
  }
4261
- return (await response.json());
4222
+ return parsed;
4262
4223
  }
4263
4224
 
4264
4225
  /**
@@ -4692,6 +4653,7 @@ class HttpClient {
4692
4653
  baseUrl;
4693
4654
  apiKey;
4694
4655
  getSessionToken;
4656
+ onUnauthorized;
4695
4657
  fetchFn;
4696
4658
  constructor(opts) {
4697
4659
  if (!opts?.baseUrl)
@@ -4699,6 +4661,7 @@ class HttpClient {
4699
4661
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
4700
4662
  this.apiKey = opts.apiKey ?? null;
4701
4663
  this.getSessionToken = opts.getSessionToken ?? (() => null);
4664
+ this.onUnauthorized = opts.onUnauthorized ?? null;
4702
4665
  const f = opts.fetch ?? globalThis.fetch;
4703
4666
  if (typeof f !== "function") {
4704
4667
  throw new Error("HttpClient: `globalThis.fetch` is unavailable; pass an explicit `fetch`.");
@@ -4731,7 +4694,7 @@ class HttpClient {
4731
4694
  del(path, body) {
4732
4695
  return this.request("DELETE", path, body);
4733
4696
  }
4734
- async request(method, path, body) {
4697
+ async request(method, path, body, allowRecovery = true) {
4735
4698
  const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
4736
4699
  const init = { method };
4737
4700
  if (body !== undefined) {
@@ -4739,6 +4702,15 @@ class HttpClient {
4739
4702
  init.body = JSON.stringify(body);
4740
4703
  }
4741
4704
  const res = await this.fetch(url, init);
4705
+ // Stale session: try to silently re-auth and replay the request once.
4706
+ // `allowRecovery` is cleared on the retry so a still-401 response (e.g.
4707
+ // recovery minted a token the endpoint still rejects) can't loop.
4708
+ if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
4709
+ const recovered = await this.onUnauthorized();
4710
+ if (recovered) {
4711
+ return this.request(method, path, body, false);
4712
+ }
4713
+ }
4742
4714
  let parsed = null;
4743
4715
  try {
4744
4716
  parsed = await res.json();
@@ -4968,6 +4940,44 @@ class ZkAuth {
4968
4940
  */
4969
4941
  async login(username, password, opts = {}) {
4970
4942
  const identity = await deriveIdentity(username, password);
4943
+ return this.proveAndStore(username, identity, opts);
4944
+ }
4945
+ /**
4946
+ * Silently re-authenticate the current user without prompting for a
4947
+ * password. Only possible while the client is **unlocked** — i.e. the
4948
+ * derived {@link ZkIdentity} (secret + keypairs) is still in memory from a
4949
+ * prior `login()`/`unlock()`. Re-runs the full challenge→proof→authenticate
4950
+ * dance with that in-memory identity and swaps in the fresh token.
4951
+ *
4952
+ * Returns `true` if a new session was minted, `false` if recovery isn't
4953
+ * possible (no active user, or locked — the identity was never derived or
4954
+ * was lost on reload). A `false` result is the app's cue to send the user
4955
+ * back to the login screen.
4956
+ *
4957
+ * Used automatically by the {@link Client} when a token-gated request comes
4958
+ * back `401` (stale/expired token), so transient session expiry self-heals
4959
+ * instead of surfacing errors mid-session.
4960
+ */
4961
+ async recover() {
4962
+ const username = this.deps.session.username;
4963
+ const identity = this.deps.session.identity;
4964
+ if (!username || !identity)
4965
+ return false;
4966
+ try {
4967
+ await this.proveAndStore(username, identity, {});
4968
+ return true;
4969
+ }
4970
+ catch {
4971
+ return false;
4972
+ }
4973
+ }
4974
+ /**
4975
+ * Shared sign-in core: prove knowledge of `identity` against a fresh
4976
+ * challenge and persist the resulting session + identity. Used by both
4977
+ * `login()` (identity derived from a password) and `recover()` (identity
4978
+ * already in memory).
4979
+ */
4980
+ async proveAndStore(username, identity, opts) {
4971
4981
  const ecdsaPubHex = await exportPublicKeyHex(identity.ecdsaKeyPair.publicKey);
4972
4982
  const challenge = await this.deps.auth.getChallenge(username);
4973
4983
  const { proof, publicSignals, commitment } = await generateAuthProof({
@@ -5125,14 +5135,14 @@ class StorageCipher {
5125
5135
  }
5126
5136
 
5127
5137
  /**
5128
- * `client.storage` — per-user persistent storage backed by the accelerator's
5129
- * personal space.
5138
+ * `client.kv` — per-user key/value storage backed by the accelerator's personal
5139
+ * space.
5130
5140
  *
5131
- * await client.storage.set('todos', id, { title, completed })
5132
- * const todo = await client.storage.get('todos', id)
5133
- * const ids = await client.storage.list('todos')
5134
- * await client.storage.delete('todos', id)
5135
- * const off = client.storage.on('change', e => …) // realtime, cross-device
5141
+ * await client.kv.set('todos', id, { title, completed })
5142
+ * const todo = await client.kv.get('todos', id)
5143
+ * const ids = await client.kv.list('todos')
5144
+ * await client.kv.delete('todos', id)
5145
+ * const off = client.kv.on('change', e => …) // realtime, cross-device
5136
5146
  *
5137
5147
  * A *collection* is a key namespace within the user's space; the stored key is
5138
5148
  * `${collection}/${id}`. Operations are authorized by the user's session token
@@ -5140,12 +5150,14 @@ class StorageCipher {
5140
5150
  *
5141
5151
  * Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
5142
5152
  * key derived from the ZK identity); pass `{ encrypt: false }` to store
5143
- * plaintext. The realtime feed rides the personal space's own websocket, so no
5144
- * separate transport is involved.
5153
+ * plaintext. The realtime feed rides the personal space's websocket over a
5154
+ * {@link WSTransport} (heartbeat keep-alive + auto-reconnect, event-namespaced
5155
+ * so it doesn't cross wires with chat/space transports).
5145
5156
  *
5146
- * `query()` is intentionally deferred — see the SDK overhaul plan.
5157
+ * For **files** (blobs), use `client.storage` instead — `client.kv` is for
5158
+ * small structured values. `query()` is intentionally deferred.
5147
5159
  */
5148
- class StorageNamespace {
5160
+ class KvNamespace {
5149
5161
  deps;
5150
5162
  cipher = null;
5151
5163
  cipherIdentity = null;
@@ -5178,7 +5190,7 @@ class StorageNamespace {
5178
5190
  }
5179
5191
  /** Reserved — server-side query is deferred (see the overhaul plan). */
5180
5192
  async query(_collection, _filter) {
5181
- throw new Error("client.storage.query is not implemented yet (deferred in the SDK overhaul).");
5193
+ throw new Error("client.kv.query is not implemented yet (deferred in the SDK overhaul).");
5182
5194
  }
5183
5195
  /**
5184
5196
  * Subscribe to realtime changes to this user's data across devices. Opens
@@ -5189,24 +5201,30 @@ class StorageNamespace {
5189
5201
  */
5190
5202
  on(event, handler) {
5191
5203
  if (event !== "change")
5192
- throw new Error(`client.storage: unknown event "${event}"`);
5193
- const WS = globalThis.WebSocket;
5194
- if (typeof WS !== "function") {
5195
- throw new Error("client.storage.on: no `WebSocket` global available in this runtime.");
5204
+ throw new Error(`client.kv: unknown event "${event}"`);
5205
+ if (typeof globalThis.WebSocket !== "function") {
5206
+ throw new Error("client.kv.on: no `WebSocket` global available in this runtime.");
5196
5207
  }
5197
5208
  const token = this.deps.session.token;
5198
5209
  if (!token)
5199
- throw new Error("client.storage.on: not signed in.");
5200
- const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(this.commitment())}/websocket?session=${encodeURIComponent(token)}`;
5201
- const socket = new WS(url);
5202
- socket.addEventListener("message", (ev) => {
5203
- void this.dispatchChange(ev.data, handler);
5204
- });
5210
+ throw new Error("client.kv.on: not signed in.");
5211
+ const commitment = this.commitment();
5212
+ const url = `${this.deps.wsBaseUrl}/api/personal/${encodeURIComponent(commitment)}/websocket?session=${encodeURIComponent(token)}`;
5213
+ // Ride a WSTransport rather than a bare socket: it keeps the (otherwise
5214
+ // idle) personal-space feed warm with heartbeat pings, auto-reconnects
5215
+ // if it drops, and namespaces its events by `id` so this feed never
5216
+ // crosses wires with chat/space transports on the shared EventCore bus.
5217
+ const transport = new WSTransport({ url, id: `personal:${commitment}` });
5218
+ const onMessage = (e) => {
5219
+ void this.dispatchChange(e.detail, handler);
5220
+ };
5221
+ transport.on(EventCoreEvents.MESSAGE, onMessage);
5222
+ // Fire-and-forget: the realtime feed is best-effort, and the transport
5223
+ // queues nothing it needs before CONNECTED (server→client only).
5224
+ void transport.connect();
5205
5225
  return () => {
5206
- try {
5207
- socket.close();
5208
- }
5209
- catch { /* already closed */ }
5226
+ transport.off(EventCoreEvents.MESSAGE, onMessage);
5227
+ transport.disconnect();
5210
5228
  };
5211
5229
  }
5212
5230
  // -------------------------------------------------------------------------
@@ -5241,7 +5259,7 @@ class StorageNamespace {
5241
5259
  commitment() {
5242
5260
  const c = this.deps.session.commitment;
5243
5261
  if (!c)
5244
- throw new Error("client.storage: not signed in — call client.auth.zk.login() first.");
5262
+ throw new Error("client.kv: not signed in — call client.auth.zk.login() first.");
5245
5263
  return c;
5246
5264
  }
5247
5265
  spacePath(suffix) {
@@ -5262,6 +5280,146 @@ class StorageNamespace {
5262
5280
  }
5263
5281
  }
5264
5282
 
5283
+ /**
5284
+ * `client.storage` — file storage. Hides the `FileStorage` / `ShardClient` /
5285
+ * `SharedSpaceClient` primitives behind the unified client.
5286
+ *
5287
+ * const { stat, manifest } = await client.storage.writeFile({ spaceId, data, metadata })
5288
+ * const { data } = await client.storage.readFile(spaceId, fileId)
5289
+ * const files = await client.storage.listFiles() // my shared files
5290
+ * const files = await client.storage.listFiles({ spaceId }) // a space's files
5291
+ * await client.storage.deleteFile(spaceId, fileId)
5292
+ *
5293
+ * Model:
5294
+ * - **Files are written to a SharedSpace** (never the personal space). A file
5295
+ * is its manifest (per-chunk keys + global shard hashes); the manifest lives
5296
+ * in the space's gated, metered `/files` store, the shard ciphertext in the
5297
+ * global open `/api/shards` store.
5298
+ * - **The manifest is the capability** — anyone holding it reads the file from
5299
+ * the global shards, anywhere ({@link readByManifest}). Share a file by
5300
+ * sharing its manifest.
5301
+ * - **Personal discovery mirror** — on a successful write the SDK also records
5302
+ * the manifest in the user's `client.kv` under a reserved collection, so
5303
+ * {@link listFiles} (no `spaceId`) can show "files I've shared" without
5304
+ * enumerating spaces. The mirror is unmetered and is NOT a write path.
5305
+ *
5306
+ * For small structured values use `client.kv` instead.
5307
+ */
5308
+ /** Reserved `client.kv` collection holding the personal manifest mirror. */
5309
+ const MIRROR_COLLECTION = "__files__";
5310
+ class StorageNamespace {
5311
+ deps;
5312
+ fileStorage = null;
5313
+ constructor(deps) {
5314
+ this.deps = deps;
5315
+ }
5316
+ /**
5317
+ * Write a file to a SharedSpace: shard ciphertext → the global store, the
5318
+ * manifest → the space's gated store, and a mirror copy → the caller's
5319
+ * personal index. Returns the `stat` and the `manifest` (share the manifest
5320
+ * to let others read the file).
5321
+ */
5322
+ async writeFile(input) {
5323
+ if (!input?.spaceId) {
5324
+ throw new Error("client.storage.writeFile: `spaceId` is required (files live in a SharedSpace).");
5325
+ }
5326
+ const fs = this.fs();
5327
+ const { manifest, stat } = await fs.writeFileToShards({
5328
+ data: input.data,
5329
+ metadata: input.metadata,
5330
+ });
5331
+ // Authoritative, metered write: the manifest into the space's gated store.
5332
+ await this.space().writeFileManifest(input.spaceId, manifest);
5333
+ // Best-effort personal discovery mirror (unmetered). A failure here
5334
+ // leaves the file intact in the space — log and continue.
5335
+ try {
5336
+ await this.deps.kv.set(MIRROR_COLLECTION, manifest.id, {
5337
+ spaceId: input.spaceId,
5338
+ manifest,
5339
+ });
5340
+ }
5341
+ catch (err) {
5342
+ console.warn("client.storage.writeFile: discovery mirror failed (file still written):", err);
5343
+ }
5344
+ return { stat, manifest };
5345
+ }
5346
+ /** Read a file from its space (resolves the manifest, then the shards). */
5347
+ async readFile(spaceId, fileId) {
5348
+ return this.fs().readFile(spaceId, fileId);
5349
+ }
5350
+ /**
5351
+ * Read a file straight from a manifest — the capability path. No space
5352
+ * membership needed; this is how a shared file is opened.
5353
+ */
5354
+ async readByManifest(manifest) {
5355
+ return this.fs().readFileFromShards(manifest);
5356
+ }
5357
+ /**
5358
+ * List files. With no argument, returns the caller's own shared files from
5359
+ * the personal mirror; with `{ spaceId }`, lists that space's files.
5360
+ */
5361
+ async listFiles(opts) {
5362
+ if (opts?.spaceId)
5363
+ return this.space().listFiles(opts.spaceId);
5364
+ const ids = await this.deps.kv.list(MIRROR_COLLECTION);
5365
+ const entries = await Promise.all(ids.map((id) => this.deps.kv.get(MIRROR_COLLECTION, id)));
5366
+ return entries
5367
+ .filter((e) => Boolean(e?.manifest))
5368
+ .map((e) => manifestToStat(e.manifest));
5369
+ }
5370
+ /** Delete a file from its space and remove the personal mirror entry. */
5371
+ async deleteFile(spaceId, fileId) {
5372
+ const existed = await this.fs().deleteFile(spaceId, fileId);
5373
+ try {
5374
+ await this.deps.kv.delete(MIRROR_COLLECTION, fileId);
5375
+ }
5376
+ catch {
5377
+ // mirror cleanup is best-effort
5378
+ }
5379
+ return existed;
5380
+ }
5381
+ /** Read a manifest verbatim (e.g. to share a file). */
5382
+ async getManifest(spaceId, fileId) {
5383
+ return this.fs().readManifest(spaceId, fileId);
5384
+ }
5385
+ // -------------------------------------------------------------------------
5386
+ // Internals — lazily built so an unauthenticated client is cheap to create.
5387
+ // -------------------------------------------------------------------------
5388
+ fs() {
5389
+ if (!this.fileStorage) {
5390
+ this.fileStorage = new FileStorage({
5391
+ shards: this.shards(),
5392
+ space: this.space(),
5393
+ rsWasmModule: this.deps.rsWasmModule,
5394
+ });
5395
+ }
5396
+ return this.fileStorage;
5397
+ }
5398
+ _shards = null;
5399
+ shards() {
5400
+ // Global, content-addressed shard store. The credential-stamping fetch
5401
+ // attaches the app key so PUTs are attributed + quota-gated; GETs are open.
5402
+ if (!this._shards) {
5403
+ this._shards = new ShardClient({
5404
+ baseUrl: this.deps.baseUrl,
5405
+ pathPrefix: "/api/shards",
5406
+ fetch: this.deps.http.fetch,
5407
+ });
5408
+ }
5409
+ return this._shards;
5410
+ }
5411
+ _space = null;
5412
+ space() {
5413
+ if (!this._space) {
5414
+ this._space = new SharedSpaceClient({
5415
+ baseUrl: this.deps.baseUrl,
5416
+ fetch: this.deps.http.fetch,
5417
+ });
5418
+ }
5419
+ return this._space;
5420
+ }
5421
+ }
5422
+
5265
5423
  /**
5266
5424
  * SpaceCipher — pure crypto for the fan-out group-encryption layer.
5267
5425
  *
@@ -5570,23 +5728,7 @@ class Space {
5570
5728
  * persisted `spaceMessage`. Requires a keyring.
5571
5729
  */
5572
5730
  async sendMessage(payload, opts = {}) {
5573
- const cipher = this.requireCipher(opts.contentType);
5574
- const message = new Message(payload);
5575
- const headers = await cipher.seal(message.serialize());
5576
- const source = this.deps.myId();
5577
- const subject = opts.channel ?? "default";
5578
- // Sign the message with our identity ECDSA key so receivers can verify
5579
- // authorship end-to-end (independent of the relay's `source` stamp).
5580
- const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
5581
- if (authPriv) {
5582
- const canonical = canonicalMessage({
5583
- source, target: this.name, subject,
5584
- epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
5585
- });
5586
- headers.sig = await signSpaceMessage(canonical, authPriv);
5587
- }
5588
- const packet = new Packet({ subject, source, target: this.name, headers });
5589
- this.sendRaw({ spaceMessage: packet.serialize() });
5731
+ this.sendRaw({ spaceMessage: await this.sealMessage(payload, opts.channel, opts.contentType) });
5590
5732
  }
5591
5733
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
5592
5734
  onMessage(handler) {
@@ -5594,6 +5736,56 @@ class Space {
5594
5736
  this.events.addEventListener("message", listener);
5595
5737
  return () => this.events.removeEventListener("message", listener);
5596
5738
  }
5739
+ /**
5740
+ * Edit a persisted message IN PLACE, addressed by its server
5741
+ * {@link SpaceMessageEvent.handle}. Re-seals `payload` and asks the server
5742
+ * to replace the stored entry; the server authorizes against the original
5743
+ * author. Receivers get an {@link onMessageEdited} event carrying the new
5744
+ * content at the same handle. This is a generic persisted-item edit — the
5745
+ * server has no notion of what the payload means.
5746
+ */
5747
+ async editMessage(handle, payload, opts = {}) {
5748
+ const spaceMessage = await this.sealMessage(payload, opts.channel, opts.contentType);
5749
+ this.sendRaw({ editSpaceMessage: { ts: handle, spaceMessage } });
5750
+ }
5751
+ /**
5752
+ * Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
5753
+ * — the ciphertext is removed from storage. Server authorizes against the
5754
+ * original author. Receivers get an {@link onMessageDeleted} event.
5755
+ */
5756
+ async deleteMessage(handle) {
5757
+ this.sendRaw({ deleteSpaceMessage: { ts: handle } });
5758
+ }
5759
+ /** Subscribe to in-place edits of persisted messages (new content at the same handle). */
5760
+ onMessageEdited(handler) {
5761
+ const listener = (e) => handler(e.detail);
5762
+ this.events.addEventListener("message-edited", listener);
5763
+ return () => this.events.removeEventListener("message-edited", listener);
5764
+ }
5765
+ /** Subscribe to deletions of persisted messages. */
5766
+ onMessageDeleted(handler) {
5767
+ const listener = (e) => handler(e.detail);
5768
+ this.events.addEventListener("message-deleted", listener);
5769
+ return () => this.events.removeEventListener("message-deleted", listener);
5770
+ }
5771
+ /**
5772
+ * Broadcast an ephemeral, never-persisted signal to the room over the
5773
+ * generic `pub` relay. `subject` is an app-defined routing key; `data` is
5774
+ * opaque. The transport stays domain-agnostic — app concepts like typing
5775
+ * indicators, presence, or live cursors are built on top of this. No-op if
5776
+ * the space isn't connected (ephemeral signals aren't worth queuing).
5777
+ */
5778
+ sendEphemeral(subject, data) {
5779
+ if (!this.isConnected())
5780
+ return;
5781
+ this.sendRaw({ pub: { subject, data } });
5782
+ }
5783
+ /** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
5784
+ onEphemeral(handler) {
5785
+ const listener = (e) => handler(e.detail);
5786
+ this.events.addEventListener("ephemeral", listener);
5787
+ return () => this.events.removeEventListener("ephemeral", listener);
5788
+ }
5597
5789
  /** Subscribe to inbound join requests (for a key-holder to admit). */
5598
5790
  onJoinRequest(handler) {
5599
5791
  const listener = (e) => handler(e.detail);
@@ -5683,9 +5875,13 @@ class Space {
5683
5875
  return this.fileStorage().readFileFromShards(manifest);
5684
5876
  }
5685
5877
  fileStorage() {
5878
+ // Global, content-addressed shard store — same store `client.storage`
5879
+ // uses, so a file shared into a channel resolves from anywhere by
5880
+ // manifest. (Shards are encrypted ciphertext; the manifest is the
5881
+ // capability.)
5686
5882
  const shards = new ShardClient({
5687
5883
  baseUrl: this.deps.httpBaseUrl,
5688
- pathPrefix: `/api/spaces/${encodeURIComponent(this.name)}/shards`,
5884
+ pathPrefix: "/api/shards",
5689
5885
  fetch: this.deps.fetch,
5690
5886
  });
5691
5887
  return new FileStorage({ shards });
@@ -5705,11 +5901,39 @@ class Space {
5705
5901
  if (!f || typeof f !== "object")
5706
5902
  return;
5707
5903
  if (typeof f.spaceMessage === "string" && this.cipher) {
5708
- const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true);
5904
+ const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, Number(f.timestamp ?? 0));
5709
5905
  if (decoded)
5710
5906
  this.emitEvent("message", decoded);
5711
5907
  return;
5712
5908
  }
5909
+ if (f.editSpaceMessage && typeof f.editSpaceMessage === "object" && this.cipher) {
5910
+ // Server-authoritative in-place edit: new sealed content at the same
5911
+ // handle. Decode and surface it; the app replaces by `handle`.
5912
+ const e = f.editSpaceMessage;
5913
+ if (typeof e.spaceMessage === "string") {
5914
+ const decoded = await this.decodeFrame(e.spaceMessage, this.cipher, /*raw*/ true, Number(e.ts ?? 0));
5915
+ if (decoded)
5916
+ this.emitEvent("message-edited", decoded);
5917
+ }
5918
+ return;
5919
+ }
5920
+ if (f.deleteSpaceMessage && typeof f.deleteSpaceMessage === "object") {
5921
+ const d = f.deleteSpaceMessage;
5922
+ this.emitEvent("message-deleted", { handle: Number(d.ts ?? 0) });
5923
+ return;
5924
+ }
5925
+ if (f.pub && typeof f.pub === "object") {
5926
+ // Generic ephemeral relay frame. The server stamps the authenticated
5927
+ // sender as `name`; `pub` carries the app's {subject, data}. We
5928
+ // surface the authenticated `name` as `from` (not any client-set
5929
+ // value inside `pub`), so app signals can't spoof their sender.
5930
+ const p = f.pub;
5931
+ const from = typeof f.name === "string" ? f.name : "";
5932
+ if (from && typeof p.subject === "string") {
5933
+ this.emitEvent("ephemeral", { from, subject: p.subject, data: p.data });
5934
+ }
5935
+ return;
5936
+ }
5713
5937
  if (f.joinRequest && typeof f.joinRequest === "object") {
5714
5938
  const req = f.joinRequest;
5715
5939
  this.emitEvent("join-request", req);
@@ -5726,19 +5950,46 @@ class Space {
5726
5950
  void this.deps.keyring.pullKeys().catch(() => { });
5727
5951
  }
5728
5952
  }
5953
+ /**
5954
+ * Seal `payload` once with the current group key + sign it, returning the
5955
+ * serialized `Packet` string. Shared by send (a new message) and edit
5956
+ * (replacement content for an existing handle). Requires a keyring.
5957
+ */
5958
+ async sealMessage(payload, channel, contentType) {
5959
+ const cipher = this.requireCipher(contentType);
5960
+ const message = new Message(payload);
5961
+ const headers = await cipher.seal(message.serialize());
5962
+ const source = this.deps.myId();
5963
+ const subject = channel ?? "default";
5964
+ // Sign with our identity ECDSA key so receivers can verify authorship
5965
+ // end-to-end (independent of the relay's `source` stamp).
5966
+ const authPriv = KeyStore.getInstance().getAuthKeyPair(source)?.privateKey;
5967
+ if (authPriv) {
5968
+ const canonical = canonicalMessage({
5969
+ source, target: this.name, subject,
5970
+ epoch: Number(headers.epoch), iv: String(headers.iv), ciphertext: String(headers.ciphertext),
5971
+ });
5972
+ headers.sig = await signSpaceMessage(canonical, authPriv);
5973
+ }
5974
+ return new Packet({ subject, source, target: this.name, headers }).serialize();
5975
+ }
5729
5976
  /**
5730
5977
  * Decode one persisted/broadcast frame into a SpaceMessageEvent.
5731
5978
  * `isRawFrame` true means `input` is the already-extracted `spaceMessage`
5732
- * packet string; false means it's the full `{spaceMessage, name, ...}` frame.
5979
+ * packet string (and `serverTs` carries the message's storage handle from
5980
+ * the enclosing frame); false means `input` is the full
5981
+ * `{spaceMessage, name, timestamp}` frame and the handle is read from it.
5733
5982
  */
5734
- async decodeFrame(input, cipher, isRawFrame = false) {
5983
+ async decodeFrame(input, cipher, isRawFrame = false, serverTs = 0) {
5735
5984
  try {
5736
5985
  let packetJson = input;
5986
+ let handle = serverTs;
5737
5987
  if (!isRawFrame) {
5738
5988
  const outer = JSON.parse(input);
5739
5989
  if (typeof outer.spaceMessage !== "string")
5740
5990
  return null;
5741
5991
  packetJson = outer.spaceMessage;
5992
+ handle = Number(outer.timestamp ?? 0);
5742
5993
  }
5743
5994
  const packet = Packet.deserialize(packetJson);
5744
5995
  if (!cipher.handles(packet.headers))
@@ -5759,6 +6010,7 @@ class Space {
5759
6010
  channel: packet.subject,
5760
6011
  epoch: Number(packet.headers?.epoch ?? 0),
5761
6012
  contentType: packet.headers?.contentType,
6013
+ handle,
5762
6014
  message,
5763
6015
  };
5764
6016
  }
@@ -6509,7 +6761,9 @@ class Client {
6509
6761
  baseUrl;
6510
6762
  /** Authentication — `client.auth.zk.login(...)`, etc. */
6511
6763
  auth;
6512
- /** Per-user persistent storage — `client.storage.set(...)`, etc. */
6764
+ /** Per-user key/value storage — `client.kv.set(...)`, etc. */
6765
+ kv;
6766
+ /** File storage — `client.storage.writeFile(...)`, etc. */
6513
6767
  storage;
6514
6768
  /** Realtime messaging — `client.message.subscribe(...)`, etc. */
6515
6769
  message;
@@ -6517,6 +6771,10 @@ class Client {
6517
6771
  space;
6518
6772
  session;
6519
6773
  http;
6774
+ /** Subscribers notified when a session expires and can't be recovered. */
6775
+ sessionExpiredHandlers = new Set();
6776
+ /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
6777
+ recoverInFlight = null;
6520
6778
  constructor(options = {}) {
6521
6779
  if (options.logLevel && typeof globalThis.appLogger?.setLevel === "function") {
6522
6780
  globalThis.appLogger.setLevel(options.logLevel);
@@ -6528,19 +6786,24 @@ class Client {
6528
6786
  apiKey: options.apiKey,
6529
6787
  getSessionToken: () => this.session.token,
6530
6788
  fetch: options.fetch,
6789
+ // Self-heal stale sessions: on a 401, try a silent re-auth (only
6790
+ // works while unlocked). If it can't, `recoverSession` fires the
6791
+ // session-expired event so the app can redirect to login.
6792
+ onUnauthorized: () => this.recoverSession(),
6531
6793
  });
6532
6794
  const circuits = options.circuits ?? defaultCircuitUrls(this.baseUrl);
6533
6795
  const authClient = new AuthClient({ baseUrl: this.baseUrl, fetch: this.http.fetch });
6534
6796
  const wsBaseUrl = toWsBase(this.baseUrl);
6535
6797
  this.auth = new AuthNamespace({ auth: authClient, circuits, session: this.session });
6536
- this.storage = new StorageNamespace({ http: this.http, session: this.session, wsBaseUrl });
6798
+ this.kv = new KvNamespace({ http: this.http, session: this.session, wsBaseUrl });
6799
+ this.storage = new StorageNamespace({ http: this.http, baseUrl: this.baseUrl, kv: this.kv });
6537
6800
  this.message = new MessageNamespace({ http: this.http, session: this.session, wsBaseUrl });
6538
6801
  // Group keys are cached in the user's PersonalSpace (encrypted at rest
6539
- // by StorageNamespace), so a returning member hydrates without a fresh
6540
- // keyring round-trip. The server only ever sees the ciphertext.
6802
+ // by client.kv), so a returning member hydrates without a fresh keyring
6803
+ // round-trip. The server only ever sees the ciphertext.
6541
6804
  const spaceKeyCache = {
6542
- loadKeys: (spaceId) => this.storage.get("space-keys", spaceId),
6543
- saveKeys: (spaceId, keys) => this.storage.set("space-keys", spaceId, keys),
6805
+ loadKeys: (spaceId) => this.kv.get("space-keys", spaceId),
6806
+ saveKeys: (spaceId, keys) => this.kv.set("space-keys", spaceId, keys),
6544
6807
  };
6545
6808
  this.space = new SpaceNamespace({
6546
6809
  http: this.http,
@@ -6557,6 +6820,48 @@ class Client {
6557
6820
  get isAuthenticated() {
6558
6821
  return this.session.isAuthenticated;
6559
6822
  }
6823
+ /**
6824
+ * Subscribe to session expiry that couldn't be silently recovered (the
6825
+ * user's identity wasn't in memory to re-prove — typically after a reload
6826
+ * where only the token was persisted, and that token has since gone stale).
6827
+ * This is the signal to send the user back to the login screen.
6828
+ *
6829
+ * Returns an unsubscribe function.
6830
+ *
6831
+ * const off = client.onSessionExpired(() => router.push('/login'));
6832
+ */
6833
+ onSessionExpired(handler) {
6834
+ this.sessionExpiredHandlers.add(handler);
6835
+ return () => this.sessionExpiredHandlers.delete(handler);
6836
+ }
6837
+ /**
6838
+ * Attempt a silent re-auth of the current user. Concurrent callers share a
6839
+ * single in-flight attempt (a stale token usually 401s several requests at
6840
+ * once — we re-auth once, not once per request). On failure the
6841
+ * session-expired event fires so the app can react. Exposed so apps can
6842
+ * also drive recovery proactively (e.g. on `visibilitychange`).
6843
+ */
6844
+ recoverSession() {
6845
+ if (this.recoverInFlight)
6846
+ return this.recoverInFlight;
6847
+ this.recoverInFlight = this.auth.zk
6848
+ .recover()
6849
+ .then((ok) => {
6850
+ if (!ok) {
6851
+ for (const handler of this.sessionExpiredHandlers) {
6852
+ try {
6853
+ handler();
6854
+ }
6855
+ catch { /* a bad listener mustn't break recovery */ }
6856
+ }
6857
+ }
6858
+ return ok;
6859
+ })
6860
+ .finally(() => {
6861
+ this.recoverInFlight = null;
6862
+ });
6863
+ return this.recoverInFlight;
6864
+ }
6560
6865
  }
6561
6866
 
6562
6867
  /**
@@ -6625,5 +6930,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
6625
6930
  default: loadRsWasm
6626
6931
  });
6627
6932
 
6628
- 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, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
6933
+ 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, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PersonalSpaceClient, Poseidon, PreimagePoK, ReedSolomonCodec, Space as Room, SerializeMessage, SessionState, ShardClient, SharedSpaceClient, Space, SpaceKeyring, SpaceNamespace, SpacePacketCipher, StorageNamespace, WSTransport, ZkAuth, buildCommitment, compilePrograms, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, generateAuthProof, getSubtle, initBn128Wasm, initRsWasm, initializeCircuits, manifestToStat, poseidonHash, quickVerify, randomBytes, shardHash, signMessage, toField, unwrapWithPassphrase, verify, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase };
6629
6934
  //# sourceMappingURL=index.js.map