@muhkoo/connect 0.2.0-alpha.1 → 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.
package/dist/connect.d.ts CHANGED
@@ -936,6 +936,14 @@ declare class EncryptedSession {
936
936
  interface WSTransportOptions {
937
937
  /** WebSocket URL, e.g. `ws://localhost:8787/foo`. */
938
938
  url: string;
939
+ /**
940
+ * Stable identifier for this connection (a channel / space / personal-space
941
+ * id). Lifecycle + MESSAGE events are namespaced by it on the shared
942
+ * `EventCore` bus so concurrent transports (chat room A, room B, the storage
943
+ * feed, …) never receive each other's frames. Optional — a unique id is
944
+ * generated when omitted; pass a semantic one for clearer debugging.
945
+ */
946
+ id?: string;
939
947
  /** Reconnect automatically after unexpected disconnect (default `true`). */
940
948
  autoReconnect?: boolean;
941
949
  /** Delay between reconnect attempts in ms (default `3000`). */
@@ -947,11 +955,41 @@ interface WSTransportOptions {
947
955
  * throws synchronously. Default `100`.
948
956
  */
949
957
  maxQueueSize?: number;
958
+ /**
959
+ * Heartbeat interval in ms. While connected, a `pingFrame` is sent every
960
+ * interval to keep the socket warm (long-lived WebSockets get dropped by CF
961
+ * / intermediaries when idle) and to detect a silently-dead connection.
962
+ * Default `30000`. Set `0` to disable the heartbeat entirely.
963
+ */
964
+ heartbeatInterval?: number;
965
+ /**
966
+ * How long (ms) to wait for a `pongFrame` after sending a ping before
967
+ * treating the connection as dead and forcing a reconnect. Should be well
968
+ * under `heartbeatInterval`. Default `10000`.
969
+ */
970
+ heartbeatTimeout?: number;
971
+ /** Outbound keep-alive frame. Default `"ping"`. */
972
+ pingFrame?: string;
973
+ /**
974
+ * Inbound keep-alive ack. Matching frames are swallowed (never surfaced as a
975
+ * `MESSAGE`) and reset the liveness timer. Must match the server's
976
+ * auto-response. Default `"pong"`.
977
+ */
978
+ pongFrame?: string;
950
979
  }
951
980
  declare class WSTransport extends EventCore {
952
- emit: typeof EventCore.emit;
953
- on: typeof EventCore.on;
954
- off: typeof EventCore.off;
981
+ /**
982
+ * Per-instance event scope. `EventCore` is a process-global static emitter,
983
+ * so without namespacing every WSTransport would hear every other one's
984
+ * frames. We prefix each event with this transport's `id` and expose
985
+ * `on`/`off`/`emit` that scope transparently — callers still use the plain
986
+ * `EventCoreEvents` enum and only ever see their own connection's events.
987
+ */
988
+ readonly id: string;
989
+ readonly emit: (event: EventCoreEvents, data: unknown) => void;
990
+ readonly on: (event: EventCoreEvents, handler: EventListener | CallableFunction) => void;
991
+ readonly off: (event: EventCoreEvents, handler: EventListener | CallableFunction) => void;
992
+ private scopedEvent;
955
993
  private url;
956
994
  private socket;
957
995
  private _isConnected;
@@ -963,6 +1001,12 @@ declare class WSTransport extends EventCore {
963
1001
  private reconnectTimeout;
964
1002
  private outboundQueue;
965
1003
  private maxQueueSize;
1004
+ private heartbeatInterval;
1005
+ private heartbeatTimeout;
1006
+ private pingFrame;
1007
+ private pongFrame;
1008
+ private heartbeatTimer;
1009
+ private pongTimer;
966
1010
  constructor(opts: WSTransportOptions);
967
1011
  /** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
968
1012
  connect(): Promise<void>;
@@ -977,6 +1021,25 @@ declare class WSTransport extends EventCore {
977
1021
  isConnecting(): boolean;
978
1022
  queuedFrames(): number;
979
1023
  reconnectAttemptCount(): number;
1024
+ /** Begin the keep-alive loop (no-op when disabled). */
1025
+ private startHeartbeat;
1026
+ /** Stop the keep-alive loop and clear any pending liveness deadline. */
1027
+ private stopHeartbeat;
1028
+ /**
1029
+ * Send one ping and arm a liveness deadline. If no `pongFrame` arrives before
1030
+ * `heartbeatTimeout`, the connection is considered dead and we force a
1031
+ * reconnect — this is what catches a silently-dropped TCP connection that
1032
+ * never fired `onclose` (the failure mode that "loses connection with the
1033
+ * Accelerator" on long sessions).
1034
+ */
1035
+ private sendHeartbeat;
1036
+ /**
1037
+ * Tear down a connection we believe is dead and kick off reconnect, without
1038
+ * waiting on the OS/browser to eventually fire `onclose` (which a half-open
1039
+ * socket may never do). We detach the handlers first so the normal close path
1040
+ * can't double-fire the reconnect.
1041
+ */
1042
+ private forceReconnect;
980
1043
  private scheduleReconnect;
981
1044
  private flushQueue;
982
1045
  }
@@ -1382,123 +1445,50 @@ declare class ShardClient {
1382
1445
  }
1383
1446
 
1384
1447
  /**
1385
- * HTTP client for the multi-user `SharedSpaceDO` (Phase B).
1386
- *
1387
- * Mirrors the proven shape of `PersonalSpaceClient` — every gated call:
1388
- * 1. POSTs `/challenge` to get a fresh nonce keyed to the caller's commitment
1389
- * 2. Generates a Groth16 proof for the `preimagePoK` circuit with the nonce
1390
- * reduced into a BN254 field element as the public signal
1391
- * 3. POSTs the operation with `{ challengeId, proof, publicSignals, ... }`
1392
- *
1393
- * What's different from `PersonalSpaceClient`:
1394
- * - Addressed by space ID, not user commitment. Multiple users can be
1395
- * members of the same space.
1396
- * - The space carries a participants list + ACL (shared types from
1397
- * `src/types/permissions.ts`). The server checks that the proving
1398
- * commitment is a participant with the requested permission before each
1399
- * mutation.
1400
- * - Stores file manifests (not generic KV blobs). The shape is constrained
1401
- * by `FileManifest` from `../types`.
1402
- *
1403
- * Wire protocol (this client implements the client side; the server side is
1404
- * the next slice of work in accelerator):
1405
- *
1406
- * POST /api/spaces create a new space (gated by caller proof)
1407
- * body: { challengeId, proof, publicSignals, initialAcl? }
1408
- * returns: { spaceId }
1448
+ * HTTP client for the gated file-manifest store on a SharedSpace
1449
+ * (`/api/spaces/:spaceId/files`).
1409
1450
  *
1410
- * POST /api/spaces/:spaceId/challenge issue a nonce for the caller
1411
- * body: { commitment }
1412
- * returns: { challengeId, nonce }
1451
+ * A "file" in a space is its **manifest** (per-chunk keys + the SHA-256 of each
1452
+ * global shard). The shard bytes live in the open `/api/shards` store; the
1453
+ * manifest is the capability. This client only moves manifests in/out of the
1454
+ * space's gated store.
1413
1455
  *
1414
- * POST /api/spaces/:spaceId/files write a file manifest
1415
- * body: { challengeId, proof, publicSignals, manifest }
1416
- * returns: { ok: true, fileId }
1456
+ * Authorization is **session-based**, consistent with the rest of the
1457
+ * SharedSpace (keyring, websocket): the SDK's credential-stamping `fetch`
1458
+ * (from {@link HttpClient}) attaches the app key + session token, the platform
1459
+ * validates the session and injects a trusted `X-Muhkoo-User-Context`, and the
1460
+ * DO authorizes on that + channel membership. There is no per-op Groth16 proof.
1417
1461
  *
1418
- * POST /api/spaces/:spaceId/files/:fileId/get read a file manifest
1419
- * body: { challengeId, proof, publicSignals }
1420
- * returns: { manifest }
1421
- *
1422
- * DELETE /api/spaces/:spaceId/files/:fileId delete a file manifest
1423
- * body: { challengeId, proof, publicSignals }
1424
- * returns: { ok: true, existed: boolean }
1425
- *
1426
- * POST /api/spaces/:spaceId/files/list list manifests in space
1427
- * body: { challengeId, proof, publicSignals }
1428
- * returns: { files: FileStat[] }
1429
- *
1430
- * POST /api/spaces/:spaceId/participants add a participant
1431
- * body: { challengeId, proof, publicSignals, participant: Participant }
1432
- * returns: { ok: true }
1433
- *
1434
- * DELETE /api/spaces/:spaceId/participants/:commitment remove a participant
1435
- * body: { challengeId, proof, publicSignals }
1436
- * returns: { ok: true, existed: boolean }
1437
- *
1438
- * POST /api/spaces/:spaceId/acl set / replace an ACL entry
1439
- * body: { challengeId, proof, publicSignals, entry: ACLEntry }
1440
- * returns: { ok: true }
1462
+ * Wire protocol (server: `SharedSpaceDO`):
1463
+ * POST /api/spaces/:spaceId/files body: { manifest } → { ok, fileId }
1464
+ * GET /api/spaces/:spaceId/files → { files: FileStat[] }
1465
+ * GET /api/spaces/:spaceId/files/:fileId → { manifest }
1466
+ * DELETE /api/spaces/:spaceId/files/:fileId → { ok, existed }
1441
1467
  */
1442
1468
 
1443
1469
  interface SharedSpaceClientOptions {
1444
- /** Base URL of the accelerator. */
1470
+ /** Base URL of the accelerator (e.g. `https://api.muhkoo.dev`). */
1445
1471
  baseUrl: string;
1446
- /** This caller's Poseidon commitment (decimal string). */
1447
- commitment: string;
1448
- /** Private witnesses for the `preimagePoK` circuit. */
1449
- secret: string;
1450
- salt: string;
1451
- ecdsaPub: string;
1452
- /** Public signal: Poseidon hash of `ecdsaPub`. */
1453
- ecdsaPubHash: string;
1454
- /** Compiled circuit assets — same files PersonalSpaceClient uses. */
1455
- circuits: {
1456
- wasmUrl: string;
1457
- zkeyUrl: string;
1458
- };
1459
- /** Optional custom fetch (defaults to `globalThis.fetch`). */
1472
+ /**
1473
+ * Credential-stamping fetch — pass {@link HttpClient.fetch} so requests
1474
+ * carry the app key + session token. Defaults to `globalThis.fetch`
1475
+ * (unauthenticated; only useful in tests).
1476
+ */
1460
1477
  fetch?: typeof fetch;
1461
1478
  }
1462
1479
  declare class SharedSpaceClient {
1463
1480
  private readonly baseUrl;
1464
- private readonly commitment;
1465
- private readonly secret;
1466
- private readonly salt;
1467
- private readonly ecdsaPub;
1468
- private readonly ecdsaPubHash;
1469
- private readonly wasmUrl;
1470
- private readonly zkeyUrl;
1471
1481
  private readonly fetchFn;
1472
1482
  constructor(opts: SharedSpaceClientOptions);
1473
- /**
1474
- * Create a brand new space. The caller becomes the owner; `initialAcl`
1475
- * adds any additional ACL entries up front (e.g. inviting a co-owner at
1476
- * creation time).
1477
- */
1478
- createSpace(initialAcl?: ACLEntry[]): Promise<{
1479
- spaceId: string;
1480
- }>;
1481
- /** Write a file manifest into the space. Requires `write` permission. */
1483
+ /** Write a file manifest into the space. Requires space membership. */
1482
1484
  writeFileManifest(spaceId: string, manifest: FileManifest): Promise<void>;
1483
- /** Read a file manifest by id. Requires `read` permission. */
1485
+ /** Read a file manifest by id. Requires space membership. */
1484
1486
  readFileManifest(spaceId: string, fileId: string): Promise<FileManifest>;
1485
- /** Delete a file manifest by id. Requires `delete` permission. */
1487
+ /** Delete a file manifest by id. Requires space membership. */
1486
1488
  deleteFileManifest(spaceId: string, fileId: string): Promise<boolean>;
1487
- /** List file summaries in the space. Requires `read` permission. */
1489
+ /** List file summaries in the space. Requires space membership. */
1488
1490
  listFiles(spaceId: string): Promise<FileStat[]>;
1489
- /** Add a participant. Requires `invite` or `admin`. */
1490
- addParticipant(spaceId: string, participant: Participant): Promise<void>;
1491
- /** Remove a participant by their public-key identity. Requires `admin`. */
1492
- removeParticipant(spaceId: string, participantCommitment: string): Promise<boolean>;
1493
- /** Set / replace an ACL entry. Requires `admin`. */
1494
- setACLEntry(spaceId: string, entry: ACLEntry): Promise<void>;
1495
- /** Request a challenge for `spaceId` and generate the matching proof. */
1496
- private proveFreshChallenge;
1497
- /**
1498
- * Hit `/challenge`. The body includes the caller's commitment so the
1499
- * server can scope the issued nonce to this principal.
1500
- */
1501
- private requestChallenge;
1491
+ private filesBase;
1502
1492
  private jsonRequest;
1503
1493
  }
1504
1494
 
@@ -2158,6 +2148,30 @@ declare class ZkAuth {
2158
2148
  * both the session and the identity are stored on the client.
2159
2149
  */
2160
2150
  login(username: string, password: string, opts?: LoginOptions): Promise<AuthUser>;
2151
+ /**
2152
+ * Silently re-authenticate the current user without prompting for a
2153
+ * password. Only possible while the client is **unlocked** — i.e. the
2154
+ * derived {@link ZkIdentity} (secret + keypairs) is still in memory from a
2155
+ * prior `login()`/`unlock()`. Re-runs the full challenge→proof→authenticate
2156
+ * dance with that in-memory identity and swaps in the fresh token.
2157
+ *
2158
+ * Returns `true` if a new session was minted, `false` if recovery isn't
2159
+ * possible (no active user, or locked — the identity was never derived or
2160
+ * was lost on reload). A `false` result is the app's cue to send the user
2161
+ * back to the login screen.
2162
+ *
2163
+ * Used automatically by the {@link Client} when a token-gated request comes
2164
+ * back `401` (stale/expired token), so transient session expiry self-heals
2165
+ * instead of surfacing errors mid-session.
2166
+ */
2167
+ recover(): Promise<boolean>;
2168
+ /**
2169
+ * Shared sign-in core: prove knowledge of `identity` against a fresh
2170
+ * challenge and persist the resulting session + identity. Used by both
2171
+ * `login()` (identity derived from a password) and `recover()` (identity
2172
+ * already in memory).
2173
+ */
2174
+ private proveAndStore;
2161
2175
  /**
2162
2176
  * Restore a persisted session on boot. Validates the stored token with the
2163
2177
  * accelerator; returns the user when valid, or `null` (and clears the
@@ -2231,6 +2245,18 @@ interface HttpClientOptions {
2231
2245
  getSessionToken?: () => string | null;
2232
2246
  /** Custom fetch — defaults to `globalThis.fetch`. */
2233
2247
  fetch?: typeof fetch;
2248
+ /**
2249
+ * Called when a token-gated request comes back `401` (stale/expired
2250
+ * session). Should attempt to silently re-establish a session and resolve
2251
+ * `true` if it succeeded (the request is then retried once with the fresh
2252
+ * token) or `false` if it couldn't (the `401` propagates as an
2253
+ * {@link HttpError}). Wired by {@link Client} to `auth.zk.recover()`.
2254
+ *
2255
+ * Only consulted by the JSON helpers (`get`/`post`/`del`); the raw
2256
+ * {@link fetch} passes 401s straight through so the auth wire calls that
2257
+ * power recovery itself can't recurse.
2258
+ */
2259
+ onUnauthorized?: () => Promise<boolean>;
2234
2260
  }
2235
2261
  /** Thrown for any non-2xx accelerator response. Carries the HTTP status. */
2236
2262
  declare class HttpError extends Error {
@@ -2242,6 +2268,7 @@ declare class HttpClient {
2242
2268
  readonly baseUrl: string;
2243
2269
  private readonly apiKey;
2244
2270
  private readonly getSessionToken;
2271
+ private readonly onUnauthorized;
2245
2272
  private readonly fetchFn;
2246
2273
  constructor(opts: HttpClientOptions);
2247
2274
  /**
@@ -2257,14 +2284,14 @@ declare class HttpClient {
2257
2284
  }
2258
2285
 
2259
2286
  /**
2260
- * `client.storage` — per-user persistent storage backed by the accelerator's
2261
- * personal space.
2287
+ * `client.kv` — per-user key/value storage backed by the accelerator's personal
2288
+ * space.
2262
2289
  *
2263
- * await client.storage.set('todos', id, { title, completed })
2264
- * const todo = await client.storage.get('todos', id)
2265
- * const ids = await client.storage.list('todos')
2266
- * await client.storage.delete('todos', id)
2267
- * const off = client.storage.on('change', e => …) // realtime, cross-device
2290
+ * await client.kv.set('todos', id, { title, completed })
2291
+ * const todo = await client.kv.get('todos', id)
2292
+ * const ids = await client.kv.list('todos')
2293
+ * await client.kv.delete('todos', id)
2294
+ * const off = client.kv.on('change', e => …) // realtime, cross-device
2268
2295
  *
2269
2296
  * A *collection* is a key namespace within the user's space; the stored key is
2270
2297
  * `${collection}/${id}`. Operations are authorized by the user's session token
@@ -2272,13 +2299,15 @@ declare class HttpClient {
2272
2299
  *
2273
2300
  * Values are encrypted at rest by default ({@link StorageCipher}, an AES-GCM
2274
2301
  * key derived from the ZK identity); pass `{ encrypt: false }` to store
2275
- * plaintext. The realtime feed rides the personal space's own websocket, so no
2276
- * separate transport is involved.
2302
+ * plaintext. The realtime feed rides the personal space's websocket over a
2303
+ * {@link WSTransport} (heartbeat keep-alive + auto-reconnect, event-namespaced
2304
+ * so it doesn't cross wires with chat/space transports).
2277
2305
  *
2278
- * `query()` is intentionally deferred — see the SDK overhaul plan.
2306
+ * For **files** (blobs), use `client.storage` instead — `client.kv` is for
2307
+ * small structured values. `query()` is intentionally deferred.
2279
2308
  */
2280
2309
 
2281
- interface StorageNamespaceDeps {
2310
+ interface KvNamespaceDeps {
2282
2311
  http: HttpClient;
2283
2312
  session: SessionState;
2284
2313
  /** WebSocket base (ws/wss) for the change feed; derived from baseUrl. */
@@ -2295,11 +2324,11 @@ interface StorageChangeEvent<T = unknown> {
2295
2324
  /** The decrypted value for `set`; `null` for `delete`. */
2296
2325
  data: T | null;
2297
2326
  }
2298
- declare class StorageNamespace {
2327
+ declare class KvNamespace {
2299
2328
  private readonly deps;
2300
2329
  private cipher;
2301
2330
  private cipherIdentity;
2302
- constructor(deps: StorageNamespaceDeps);
2331
+ constructor(deps: KvNamespaceDeps);
2303
2332
  /** Store `value` under `collection`/`id` (encrypted at rest by default). */
2304
2333
  set<T = unknown>(collection: string, id: string, value: T, opts?: SetOptions): Promise<void>;
2305
2334
  /** Fetch the value at `collection`/`id`, or `null` if absent. */
@@ -2328,6 +2357,100 @@ declare class StorageNamespace {
2328
2357
  private getCipher;
2329
2358
  }
2330
2359
 
2360
+ /**
2361
+ * `client.storage` — file storage. Hides the `FileStorage` / `ShardClient` /
2362
+ * `SharedSpaceClient` primitives behind the unified client.
2363
+ *
2364
+ * const { stat, manifest } = await client.storage.writeFile({ spaceId, data, metadata })
2365
+ * const { data } = await client.storage.readFile(spaceId, fileId)
2366
+ * const files = await client.storage.listFiles() // my shared files
2367
+ * const files = await client.storage.listFiles({ spaceId }) // a space's files
2368
+ * await client.storage.deleteFile(spaceId, fileId)
2369
+ *
2370
+ * Model:
2371
+ * - **Files are written to a SharedSpace** (never the personal space). A file
2372
+ * is its manifest (per-chunk keys + global shard hashes); the manifest lives
2373
+ * in the space's gated, metered `/files` store, the shard ciphertext in the
2374
+ * global open `/api/shards` store.
2375
+ * - **The manifest is the capability** — anyone holding it reads the file from
2376
+ * the global shards, anywhere ({@link readByManifest}). Share a file by
2377
+ * sharing its manifest.
2378
+ * - **Personal discovery mirror** — on a successful write the SDK also records
2379
+ * the manifest in the user's `client.kv` under a reserved collection, so
2380
+ * {@link listFiles} (no `spaceId`) can show "files I've shared" without
2381
+ * enumerating spaces. The mirror is unmetered and is NOT a write path.
2382
+ *
2383
+ * For small structured values use `client.kv` instead.
2384
+ */
2385
+
2386
+ interface FileNamespaceDeps {
2387
+ http: HttpClient;
2388
+ baseUrl: string;
2389
+ /** Used for the unmetered personal discovery mirror. */
2390
+ kv: KvNamespace;
2391
+ /**
2392
+ * Optional pre-compiled Reed–Solomon WASM module. Defaults to the bundled
2393
+ * loader; pass one where the bundled loader path isn't available (vitest,
2394
+ * deploy-time-precompiled environments). Forwarded to {@link FileStorage}.
2395
+ */
2396
+ rsWasmModule?: WebAssembly.Module;
2397
+ }
2398
+ interface WriteFileOptions {
2399
+ /** SharedSpace the file is written to (obtain via `client.space`). */
2400
+ spaceId: string;
2401
+ data: Uint8Array | Blob | File;
2402
+ metadata: {
2403
+ name: string;
2404
+ type: string;
2405
+ path?: string;
2406
+ lastModified?: number;
2407
+ };
2408
+ }
2409
+ declare class StorageNamespace {
2410
+ private readonly deps;
2411
+ private fileStorage;
2412
+ constructor(deps: FileNamespaceDeps);
2413
+ /**
2414
+ * Write a file to a SharedSpace: shard ciphertext → the global store, the
2415
+ * manifest → the space's gated store, and a mirror copy → the caller's
2416
+ * personal index. Returns the `stat` and the `manifest` (share the manifest
2417
+ * to let others read the file).
2418
+ */
2419
+ writeFile(input: WriteFileOptions): Promise<{
2420
+ stat: FileStat;
2421
+ manifest: FileManifest;
2422
+ }>;
2423
+ /** Read a file from its space (resolves the manifest, then the shards). */
2424
+ readFile(spaceId: string, fileId: string): Promise<{
2425
+ data: Uint8Array;
2426
+ stat: FileStat;
2427
+ }>;
2428
+ /**
2429
+ * Read a file straight from a manifest — the capability path. No space
2430
+ * membership needed; this is how a shared file is opened.
2431
+ */
2432
+ readByManifest(manifest: FileManifest): Promise<{
2433
+ data: Uint8Array;
2434
+ stat: FileStat;
2435
+ }>;
2436
+ /**
2437
+ * List files. With no argument, returns the caller's own shared files from
2438
+ * the personal mirror; with `{ spaceId }`, lists that space's files.
2439
+ */
2440
+ listFiles(opts?: {
2441
+ spaceId?: string;
2442
+ }): Promise<FileStat[]>;
2443
+ /** Delete a file from its space and remove the personal mirror entry. */
2444
+ deleteFile(spaceId: string, fileId: string): Promise<boolean>;
2445
+ /** Read a manifest verbatim (e.g. to share a file). */
2446
+ getManifest(spaceId: string, fileId: string): Promise<FileManifest>;
2447
+ private fs;
2448
+ private _shards;
2449
+ private shards;
2450
+ private _space;
2451
+ private space;
2452
+ }
2453
+
2331
2454
  /**
2332
2455
  * Shared types for the Space fan-out group-encryption layer.
2333
2456
  *
@@ -2385,6 +2508,14 @@ interface JoinRequest {
2385
2508
  desiredEpoch?: number;
2386
2509
  ts?: number;
2387
2510
  }
2511
+ /** A space member's published identity keys (the keyring directory). */
2512
+ interface RosterMember {
2513
+ memberId: string;
2514
+ /** Base64url-encoded JWK of the member's identity ECDH public key. */
2515
+ identityEcdhPub: string;
2516
+ /** Base64url-encoded JWK of the member's identity ECDSA public key. */
2517
+ identityEcdsaPub?: string;
2518
+ }
2388
2519
 
2389
2520
  /**
2390
2521
  * PacketCipher — the strategy `Network` uses to seal/open a packet's payload.
@@ -2504,15 +2635,14 @@ interface KeyringTransport {
2504
2635
  postWrappedKey(targetMemberId: string, wrapped: WrappedKey, fromMemberId: string): Promise<void>;
2505
2636
  /** Outstanding join requests (for an online key-holder to fulfill). */
2506
2637
  fetchPending(): Promise<JoinRequest[]>;
2507
- /** Current roster: member ids + their identity ECDH public keys. */
2508
- fetchRoster(): Promise<Array<{
2509
- memberId: string;
2510
- identityEcdhPub: string;
2511
- }>>;
2638
+ /** Current roster: member ids + their identity ECDH/ECDSA public keys. */
2639
+ fetchRoster(): Promise<RosterMember[]>;
2512
2640
  /** Advisory epoch bump; returns the agreed new epoch. */
2513
2641
  rotate(nextEpoch: number): Promise<{
2514
2642
  epoch: number;
2515
2643
  }>;
2644
+ /** Add `username` to a private channel's membership allowlist. */
2645
+ invite(username: string): Promise<void>;
2516
2646
  fetchMetadata(): Promise<SpaceMetadata | null>;
2517
2647
  }
2518
2648
  /** PersonalSpace-backed cache of this member's group keys (epoch → base64 key). */
@@ -2525,6 +2655,9 @@ interface SpaceKeyringDeps {
2525
2655
  memberId: string;
2526
2656
  /** This member's identity ECDH public key, base64url JWK (for join requests). */
2527
2657
  identityEcdhPub: string;
2658
+ /** This member's identity ECDSA public key, base64url JWK (published for
2659
+ * sender-signature verification). */
2660
+ identityEcdsaPub?: string;
2528
2661
  /** Resolves this member's identity ECDH private key (for unwrapping). */
2529
2662
  ownPrivateKey: () => CryptoKey | null;
2530
2663
  transport: KeyringTransport;
@@ -2536,7 +2669,13 @@ declare class SpaceKeyring implements EpochKeyProvider {
2536
2669
  private readonly keys;
2537
2670
  private epoch;
2538
2671
  private readonly policy;
2672
+ /** Cached member directory: memberId → imported ECDSA verify key. */
2673
+ private readonly memberEcdsa;
2539
2674
  constructor(deps: SpaceKeyringDeps);
2675
+ /** The cached ECDSA verify key for a member, if known. */
2676
+ ecdsaKeyFor(memberId: string): CryptoKey | undefined;
2677
+ /** Refresh the member directory (memberId → ECDSA key) from the roster. */
2678
+ refreshDirectory(): Promise<void>;
2540
2679
  currentEpoch(): number;
2541
2680
  keyForEpoch(epoch: number): Uint8Array | undefined;
2542
2681
  hasAnyKey(): boolean;
@@ -2546,7 +2685,11 @@ declare class SpaceKeyring implements EpochKeyProvider {
2546
2685
  private persist;
2547
2686
  /** Creator path: mint epoch-0 group key for a brand-new space. */
2548
2687
  bootstrapNew(): Promise<void>;
2549
- /** Post a join request so an online key-holder can wrap a key for us. */
2688
+ /**
2689
+ * Post a join request so an online key-holder can wrap a key for us. Also
2690
+ * publishes our identity keys in the member directory (so others can verify
2691
+ * our message signatures, and the keeper can wrap to us).
2692
+ */
2550
2693
  requestKey(desiredEpoch?: number): Promise<void>;
2551
2694
  /**
2552
2695
  * Pull and unwrap any blobs addressed to us. Returns the number of new
@@ -2572,6 +2715,8 @@ declare class SpaceKeyring implements EpochKeyProvider {
2572
2715
  memberId: string;
2573
2716
  identityEcdhPub: string;
2574
2717
  }>): Promise<number>;
2718
+ /** Add `username` to a private channel's membership allowlist (member-only). */
2719
+ invite(username: string): Promise<void>;
2575
2720
  }
2576
2721
 
2577
2722
  /**
@@ -2609,8 +2754,35 @@ interface SpaceMessageEvent {
2609
2754
  channel: string;
2610
2755
  epoch: number;
2611
2756
  contentType?: string;
2757
+ /**
2758
+ * Server storage handle — the monotonic server timestamp this message is
2759
+ * persisted under (`msg:<handle>`). Stable across history replay; pass it to
2760
+ * {@link Space.editMessage} / {@link Space.deleteMessage} to mutate the
2761
+ * persisted entry. Carried on edit events too (same handle as the original).
2762
+ */
2763
+ handle: number;
2612
2764
  message: Message;
2613
2765
  }
2766
+ /**
2767
+ * An inbound ephemeral signal — an application broadcast the server relays to
2768
+ * the room but never persists (no history). Rides the generic `pub` relay:
2769
+ * `subject` is an app-defined routing key, `data` is opaque app JSON, and
2770
+ * `from` is the server-authenticated sender. Typing indicators, presence pings,
2771
+ * live cursors, etc. are built on top of this in the app layer — the SDK and
2772
+ * server stay domain-agnostic.
2773
+ */
2774
+ interface EphemeralEvent {
2775
+ /** Server-stamped authenticated sender. */
2776
+ from: string;
2777
+ /** Application-defined routing key (e.g. "typing"). */
2778
+ subject: string;
2779
+ /** Opaque application payload. */
2780
+ data: unknown;
2781
+ }
2782
+ /** A server-authoritative deletion of the persisted message at `handle`. */
2783
+ interface MessageDeletedEvent {
2784
+ handle: number;
2785
+ }
2614
2786
  interface SpaceDeps {
2615
2787
  /** Transport room id. For fan-out spaces this is the encoded space pubkey. */
2616
2788
  name: string;
@@ -2693,6 +2865,38 @@ declare class Space {
2693
2865
  }): Promise<void>;
2694
2866
  /** Subscribe to decrypted fan-out messages. Returns an unsubscribe fn. */
2695
2867
  onMessage(handler: (e: SpaceMessageEvent) => void): () => void;
2868
+ /**
2869
+ * Edit a persisted message IN PLACE, addressed by its server
2870
+ * {@link SpaceMessageEvent.handle}. Re-seals `payload` and asks the server
2871
+ * to replace the stored entry; the server authorizes against the original
2872
+ * author. Receivers get an {@link onMessageEdited} event carrying the new
2873
+ * content at the same handle. This is a generic persisted-item edit — the
2874
+ * server has no notion of what the payload means.
2875
+ */
2876
+ editMessage(handle: number, payload: unknown, opts?: {
2877
+ channel?: string;
2878
+ contentType?: string;
2879
+ }): Promise<void>;
2880
+ /**
2881
+ * Hard-delete a persisted message by its server {@link SpaceMessageEvent.handle}
2882
+ * — the ciphertext is removed from storage. Server authorizes against the
2883
+ * original author. Receivers get an {@link onMessageDeleted} event.
2884
+ */
2885
+ deleteMessage(handle: number): Promise<void>;
2886
+ /** Subscribe to in-place edits of persisted messages (new content at the same handle). */
2887
+ onMessageEdited(handler: (e: SpaceMessageEvent) => void): () => void;
2888
+ /** Subscribe to deletions of persisted messages. */
2889
+ onMessageDeleted(handler: (e: MessageDeletedEvent) => void): () => void;
2890
+ /**
2891
+ * Broadcast an ephemeral, never-persisted signal to the room over the
2892
+ * generic `pub` relay. `subject` is an app-defined routing key; `data` is
2893
+ * opaque. The transport stays domain-agnostic — app concepts like typing
2894
+ * indicators, presence, or live cursors are built on top of this. No-op if
2895
+ * the space isn't connected (ephemeral signals aren't worth queuing).
2896
+ */
2897
+ sendEphemeral(subject: string, data: unknown): void;
2898
+ /** Subscribe to inbound ephemeral signals. `from` is the authenticated sender. */
2899
+ onEphemeral(handler: (e: EphemeralEvent) => void): () => void;
2696
2900
  /** Subscribe to inbound join requests (for a key-holder to admit). */
2697
2901
  onJoinRequest(handler: (req: JoinRequest) => void): () => void;
2698
2902
  /**
@@ -2718,6 +2922,8 @@ declare class Space {
2718
2922
  create(): Promise<void>;
2719
2923
  /** Wrap the group key for a newcomer and post it (key-holder action). */
2720
2924
  admit(memberId: string, identityEcdhPub: string): Promise<void>;
2925
+ /** Invite a user to this (private) channel's membership allowlist. */
2926
+ invite(username: string): Promise<void>;
2721
2927
  /** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
2722
2928
  rotate(roster?: Array<{
2723
2929
  memberId: string;
@@ -2735,12 +2941,27 @@ declare class Space {
2735
2941
  /** Wire the RAW_FRAME handler that drives fan-out events. */
2736
2942
  private wireFanout;
2737
2943
  private onRawFrame;
2944
+ /**
2945
+ * Seal `payload` once with the current group key + sign it, returning the
2946
+ * serialized `Packet` string. Shared by send (a new message) and edit
2947
+ * (replacement content for an existing handle). Requires a keyring.
2948
+ */
2949
+ private sealMessage;
2738
2950
  /**
2739
2951
  * Decode one persisted/broadcast frame into a SpaceMessageEvent.
2740
2952
  * `isRawFrame` true means `input` is the already-extracted `spaceMessage`
2741
- * packet string; false means it's the full `{spaceMessage, name, ...}` frame.
2953
+ * packet string (and `serverTs` carries the message's storage handle from
2954
+ * the enclosing frame); false means `input` is the full
2955
+ * `{spaceMessage, name, timestamp}` frame and the handle is read from it.
2742
2956
  */
2743
2957
  private decodeFrame;
2958
+ /**
2959
+ * True if `packet` carries a valid signature by `source`'s identity key.
2960
+ * On a directory cache miss, refresh once (a new member may have just
2961
+ * joined) before giving up. Without a keyring (legacy handle) there's
2962
+ * nothing to verify against → reject.
2963
+ */
2964
+ private verifySender;
2744
2965
  private emitEvent;
2745
2966
  private requireKeyring;
2746
2967
  private requireCipher;
@@ -2883,6 +3104,7 @@ declare class SpaceNamespace {
2883
3104
  */
2884
3105
  createSpace(opts?: {
2885
3106
  historyPolicy?: HistoryPolicy;
3107
+ private?: boolean;
2886
3108
  }): Promise<Space>;
2887
3109
  /** Join an existing space by id (its public key). Resolves once a key arrives. */
2888
3110
  joinSpace(spaceId: string, opts?: {
@@ -2904,6 +3126,7 @@ declare class SpaceNamespace {
2904
3126
  */
2905
3127
  createChannel(name: string, opts?: {
2906
3128
  historyPolicy?: HistoryPolicy;
3129
+ private?: boolean;
2907
3130
  }): Promise<Space>;
2908
3131
  /**
2909
3132
  * Join an existing channel by name. Throws {@link ChannelNotFoundError} if
@@ -2913,7 +3136,7 @@ declare class SpaceNamespace {
2913
3136
  timeoutMs?: number;
2914
3137
  }): Promise<Space>;
2915
3138
  private build;
2916
- /** Ensure the member has an identity ECDH keypair; return its public key. */
3139
+ /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
2917
3140
  private ensureIdentity;
2918
3141
  private fetchPolicy;
2919
3142
  private fetchTicket;
@@ -2957,7 +3180,9 @@ declare class Client {
2957
3180
  readonly baseUrl: string;
2958
3181
  /** Authentication — `client.auth.zk.login(...)`, etc. */
2959
3182
  readonly auth: AuthNamespace;
2960
- /** Per-user persistent storage — `client.storage.set(...)`, etc. */
3183
+ /** Per-user key/value storage — `client.kv.set(...)`, etc. */
3184
+ readonly kv: KvNamespace;
3185
+ /** File storage — `client.storage.writeFile(...)`, etc. */
2961
3186
  readonly storage: StorageNamespace;
2962
3187
  /** Realtime messaging — `client.message.subscribe(...)`, etc. */
2963
3188
  readonly message: MessageNamespace;
@@ -2965,11 +3190,34 @@ declare class Client {
2965
3190
  readonly space: SpaceNamespace;
2966
3191
  private readonly session;
2967
3192
  private readonly http;
3193
+ /** Subscribers notified when a session expires and can't be recovered. */
3194
+ private readonly sessionExpiredHandlers;
3195
+ /** Shared in-flight recovery so a burst of 401s triggers one re-auth. */
3196
+ private recoverInFlight;
2968
3197
  constructor(options?: ClientOptions);
2969
3198
  /** The currently signed-in user, or `null`. */
2970
3199
  get user(): AuthUser | null;
2971
3200
  /** Whether a token-bearing session is active. */
2972
3201
  get isAuthenticated(): boolean;
3202
+ /**
3203
+ * Subscribe to session expiry that couldn't be silently recovered (the
3204
+ * user's identity wasn't in memory to re-prove — typically after a reload
3205
+ * where only the token was persisted, and that token has since gone stale).
3206
+ * This is the signal to send the user back to the login screen.
3207
+ *
3208
+ * Returns an unsubscribe function.
3209
+ *
3210
+ * const off = client.onSessionExpired(() => router.push('/login'));
3211
+ */
3212
+ onSessionExpired(handler: () => void): () => void;
3213
+ /**
3214
+ * Attempt a silent re-auth of the current user. Concurrent callers share a
3215
+ * single in-flight attempt (a stale token usually 401s several requests at
3216
+ * once — we re-auth once, not once per request). On failure the
3217
+ * session-expired event fires so the app can react. Exposed so apps can
3218
+ * also drive recovery proactively (e.g. on `visibilitychange`).
3219
+ */
3220
+ recoverSession(): Promise<boolean>;
2973
3221
  }
2974
3222
 
2975
3223
  /**
@@ -3002,15 +3250,13 @@ declare class KeyringClient implements KeyringTransport {
3002
3250
  fetchBlobs(memberId: string): Promise<WrappedKey[]>;
3003
3251
  postWrappedKey(targetMemberId: string, wrapped: WrappedKey, fromMemberId: string): Promise<void>;
3004
3252
  fetchPending(): Promise<JoinRequest[]>;
3005
- fetchRoster(): Promise<Array<{
3006
- memberId: string;
3007
- identityEcdhPub: string;
3008
- }>>;
3253
+ fetchRoster(): Promise<RosterMember[]>;
3009
3254
  rotate(nextEpoch: number): Promise<{
3010
3255
  epoch: number;
3011
3256
  }>;
3257
+ invite(username: string): Promise<void>;
3012
3258
  fetchMetadata(): Promise<SpaceMetadata | null>;
3013
3259
  }
3014
3260
 
3015
- 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 };
3016
- export type { ACL, ACLEntry, AuthClientOptions, AuthKeyPair, AuthSuccess, AuthToken, AuthType, AuthUser, Bn128WasmInstance, BroadcastChannelEvent, BroadcastChannelOptions, ChannelLike, ChunkKeyMaterial, ChunkManifest, CipherFrame, CipherMessage, CipherMessageHeader, CircuitBufferConfig, CircuitUrls, ClientOptions, DehydratedKeypair, DehydratedKeys, DoubleRatchetCipherDeps, ECDHCurve, EncodeResult, EpochKeyProvider, FileManifest, FileStat, FileStorageOptions, Groth16Proof, HttpClientOptions, IncomingFrame, KeyExchangeFrame, KeyPair, Keypair, KeyringClientDeps, KeyringTransport, LoginOptions, MessageBody, MessageEvent, MessageHeaders, MessageNamespaceDeps, MessageOptions, MessageSubscription, MuhkooMessageEvent, OAuthProvider, PacketCipher, PacketHeaders, PacketOptions, Participant, Permission, PermissionCheckRequest, PermissionCheckResult, PersonalSpaceClientOptions, PublicKeyJWK, RatchetState, ReceiveResult, RegisterParams, Role, SpaceDeps as RoomDeps, SpaceFileMetadata as RoomFileMetadata, SealedHeaders, Session, SessionOptions, SessionStore, SetOptions, ShardClientOptions, SharedSpaceClientOptions, SnarkProof, SpaceChannelLike, SpaceDeps, SpaceFileMetadata, SpaceKeyCache, SpaceKeyringDeps, SpaceMessageEvent, SpaceNamespaceDeps, StorageChangeEvent, StorageNamespaceDeps, StoredSession, Subscription, UserIdentity, VerificationKey, WSMessage, WSMessageType, WSTransportOptions, WrappedPayload, WriteFileInput, WriteToShardsInput, ZkAuthDeps, ZkAuthenticateRequest, ZkChallengeResponse, ZkCompiled, ZkIdentity, ZkRegisterRequest, ZkRegisterResponse };
3261
+ 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 };
3262
+ export type { ACL, ACLEntry, AuthClientOptions, AuthKeyPair, AuthSuccess, AuthToken, AuthType, AuthUser, Bn128WasmInstance, BroadcastChannelEvent, BroadcastChannelOptions, ChannelLike, ChunkKeyMaterial, ChunkManifest, CipherFrame, CipherMessage, CipherMessageHeader, CircuitBufferConfig, CircuitUrls, ClientOptions, DehydratedKeypair, DehydratedKeys, DoubleRatchetCipherDeps, ECDHCurve, EncodeResult, EphemeralEvent, EpochKeyProvider, FileManifest, FileNamespaceDeps, FileStat, FileStorageOptions, Groth16Proof, HttpClientOptions, IncomingFrame, KeyExchangeFrame, KeyPair, Keypair, KeyringClientDeps, KeyringTransport, KvNamespaceDeps, LoginOptions, MessageBody, MessageDeletedEvent, MessageEvent, MessageHeaders, MessageNamespaceDeps, MessageOptions, MessageSubscription, MuhkooMessageEvent, OAuthProvider, PacketCipher, PacketHeaders, PacketOptions, Participant, Permission, PermissionCheckRequest, PermissionCheckResult, PersonalSpaceClientOptions, PublicKeyJWK, RatchetState, ReceiveResult, RegisterParams, Role, SpaceDeps as RoomDeps, SpaceFileMetadata as RoomFileMetadata, SealedHeaders, Session, SessionOptions, SessionStore, SetOptions, ShardClientOptions, SharedSpaceClientOptions, SnarkProof, SpaceChannelLike, SpaceDeps, SpaceFileMetadata, SpaceKeyCache, SpaceKeyringDeps, SpaceMessageEvent, SpaceNamespaceDeps, StorageChangeEvent, StoredSession, Subscription, UserIdentity, VerificationKey, WSMessage, WSMessageType, WSTransportOptions, WrappedPayload, WriteFileInput, WriteFileOptions, WriteToShardsInput, ZkAuthDeps, ZkAuthenticateRequest, ZkChallengeResponse, ZkCompiled, ZkIdentity, ZkRegisterRequest, ZkRegisterResponse };