@muhkoo/connect 0.14.0-alpha.9 → 0.15.0-alpha.1

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.
@@ -1,10 +1,10 @@
1
1
  import { prove, verify as verify$1 } from '@zk-kit/groth16';
2
+ import { ristretto255_oprf } from '@noble/curves/ed25519.js';
2
3
  import { p256 } from '@noble/curves/nist.js';
3
4
  import { buildPoseidon } from 'circomlibjs';
4
5
  import { scrypt } from '@noble/hashes/scrypt.js';
5
6
  import { entropyToMnemonic, validateMnemonic, mnemonicToEntropy } from '@scure/bip39';
6
7
  import { wordlist } from '@scure/bip39/wordlists/english.js';
7
- import { ristretto255_oprf } from '@noble/curves/ed25519.js';
8
8
 
9
9
  const _isTty = (() => {
10
10
  try {
@@ -316,7 +316,7 @@ function fromBase64Url(b64url) {
316
316
  return fromBase64$2(padded);
317
317
  }
318
318
  /** Concatenate any number of byte arrays into a single Uint8Array. */
319
- function concatBytes(...arrays) {
319
+ function concatBytes$1(...arrays) {
320
320
  let total = 0;
321
321
  for (const a of arrays)
322
322
  total += a.byteLength;
@@ -1243,6 +1243,8 @@ class KeyStore {
1243
1243
  static instance;
1244
1244
  _keys = new Map();
1245
1245
  _authKeys = new Map();
1246
+ /** The exact pairs marked vault-backed, so a pair written over them later is never taken for them. */
1247
+ _vaultBacked = new Map();
1246
1248
  constructor() { }
1247
1249
  static getInstance() {
1248
1250
  if (!KeyStore.instance) {
@@ -1280,7 +1282,28 @@ class KeyStore {
1280
1282
  const dehydrated = await this.dehydrateKeyPair(id);
1281
1283
  appLogger.debug(`Stored remote public keys for ${id}: ecdhPub=${dehydrated.ecdhPub.slice(0, 16)}...`);
1282
1284
  }
1285
+ /** Generate an own pair without touching the store; {@link replaceOwnKeyPair} puts it in place. */
1286
+ async generateKeyPair() {
1287
+ const ecdh = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
1288
+ const ecdsa = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']);
1289
+ return {
1290
+ ecdh: { privateKey: ecdh.privateKey, publicKey: ecdh.publicKey },
1291
+ ecdsa: { privateKey: ecdsa.privateKey, publicKey: ecdsa.publicKey },
1292
+ };
1293
+ }
1283
1294
  async hydrateKeyPair(id, dehydrated) {
1295
+ const { ecdh, ecdsa } = await this.importKeyPair(dehydrated);
1296
+ const existing = this._keys.get(id);
1297
+ if (!existing || !existing.privateKey) {
1298
+ this._keys.set(id, ecdh);
1299
+ }
1300
+ const existingAuth = this._authKeys.get(id);
1301
+ if (!existingAuth || !existingAuth.privateKey) {
1302
+ this._authKeys.set(id, ecdsa);
1303
+ }
1304
+ }
1305
+ /** Import a dehydrated pair without touching the store; {@link replaceOwnKeyPair} puts it in place. */
1306
+ async importKeyPair(dehydrated) {
1284
1307
  const ecdhPubJwk = JSON.parse(deserialize(dehydrated.ecdhPub));
1285
1308
  const ecdhPublicKey = await crypto.subtle.importKey('jwk', ecdhPubJwk, { name: 'ECDH', namedCurve: 'P-384' }, true, []);
1286
1309
  const ecdsaPubJwk = JSON.parse(deserialize(dehydrated.ecdsaPub));
@@ -1295,14 +1318,10 @@ class KeyStore {
1295
1318
  const ecdsaPrivJwk = JSON.parse(deserialize(dehydrated.ecdsaPriv));
1296
1319
  ecdsaPrivateKey = await crypto.subtle.importKey('jwk', ecdsaPrivJwk, { name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign']);
1297
1320
  }
1298
- const existing = this._keys.get(id);
1299
- if (!existing || !existing.privateKey) {
1300
- this._keys.set(id, { privateKey: ecdhPrivateKey, publicKey: ecdhPublicKey });
1301
- }
1302
- const existingAuth = this._authKeys.get(id);
1303
- if (!existingAuth || !existingAuth.privateKey) {
1304
- this._authKeys.set(id, { privateKey: ecdsaPrivateKey, publicKey: ecdsaPublicKey });
1305
- }
1321
+ return {
1322
+ ecdh: { privateKey: ecdhPrivateKey, publicKey: ecdhPublicKey },
1323
+ ecdsa: { privateKey: ecdsaPrivateKey, publicKey: ecdsaPublicKey },
1324
+ };
1306
1325
  }
1307
1326
  getKeyPair(id) {
1308
1327
  return this._keys.get(id) || null;
@@ -1310,12 +1329,54 @@ class KeyStore {
1310
1329
  getAuthKeyPair(id) {
1311
1330
  return this._authKeys.get(id) || null;
1312
1331
  }
1332
+ /** Record that the pair `id` holds now came from the seed-wrapped vault (ChatKeyVault). */
1333
+ markVaultBacked(id) {
1334
+ const ecdh = this._keys.get(id);
1335
+ const ecdsa = this._authKeys.get(id);
1336
+ if (ecdh && ecdsa)
1337
+ this._vaultBacked.set(id, { ecdh, ecdsa });
1338
+ }
1339
+ /**
1340
+ * Put `pair` in place as `id`'s own pair and mark it vault-backed, in one
1341
+ * synchronous step: whatever `id` held before is dropped, so a pair minted
1342
+ * while the vault pair was being fetched or generated is never kept and
1343
+ * marked in its place (design §3.3).
1344
+ */
1345
+ replaceOwnKeyPair(id, pair) {
1346
+ this.forget(id);
1347
+ this._keys.set(id, pair.ecdh);
1348
+ this._authKeys.set(id, pair.ecdsa);
1349
+ this._vaultBacked.set(id, { ecdh: pair.ecdh, ecdsa: pair.ecdsa });
1350
+ }
1351
+ /**
1352
+ * Whether `id`'s own pair was loaded from, or persisted to, the vault in this
1353
+ * tab, and is still the very pair that was marked. A Space identity must be
1354
+ * vault-backed (design §3.3): any other pair, such as one minted by a locked
1355
+ * DM subscription, never takes part, even one that lands over the marked pair.
1356
+ */
1357
+ isVaultBacked(id) {
1358
+ const marked = this._vaultBacked.get(id);
1359
+ return !!marked && marked.ecdh.privateKey != null
1360
+ && this._keys.get(id) === marked.ecdh && this._authKeys.get(id) === marked.ecdsa;
1361
+ }
1362
+ /** Drop every key held for `id` (SpecA §6.4 `KeyStore.forget`). */
1363
+ forget(id) {
1364
+ this._keys.delete(id);
1365
+ this._authKeys.delete(id);
1366
+ this._vaultBacked.delete(id);
1367
+ }
1313
1368
  async dehydrateKeyPair(id) {
1314
1369
  const keyPair = this._keys.get(id);
1315
1370
  const authKeyPair = this._authKeys.get(id);
1316
1371
  if (!keyPair || !authKeyPair) {
1317
1372
  throw new Error(`No key pair for ${id}`);
1318
1373
  }
1374
+ return this.exportKeyPair({ ecdh: keyPair, ecdsa: authKeyPair });
1375
+ }
1376
+ /** Dehydrate a pair that is not (yet) in the store, in the same format as {@link dehydrateKeyPair}. */
1377
+ async exportKeyPair(pair) {
1378
+ const keyPair = pair.ecdh;
1379
+ const authKeyPair = pair.ecdsa;
1319
1380
  const ecdhPubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
1320
1381
  const ecdhPrivJwk = keyPair.privateKey ? await crypto.subtle.exportKey('jwk', keyPair.privateKey) : null;
1321
1382
  const ecdsaPubJwk = await crypto.subtle.exportKey('jwk', authKeyPair.publicKey);
@@ -1340,7 +1401,7 @@ class KeyStore {
1340
1401
  const x = fromBase64Url(jwk.x);
1341
1402
  const y = fromBase64Url(jwk.y);
1342
1403
  // SEC1 uncompressed point: 0x04 || x || y
1343
- return concatBytes(new Uint8Array([0x04]), x, y);
1404
+ return concatBytes$1(new Uint8Array([0x04]), x, y);
1344
1405
  }
1345
1406
  async packDehydratedKeys(id) {
1346
1407
  const dehydrated = await this.dehydrateKeyPair(id);
@@ -1371,7 +1432,6 @@ class DoubleRatchet {
1371
1432
  sessionType;
1372
1433
  maxSkip = 3000;
1373
1434
  overlapPeriod = 30000; // ms
1374
- windowSize = 100; // messages per DH ratchet
1375
1435
  constructor(senderId, recipientId, sessionType, isClient = true) {
1376
1436
  this.sessionType = sessionType;
1377
1437
  const keyStore = KeyStore.getInstance();
@@ -1423,7 +1483,7 @@ class DoubleRatchet {
1423
1483
  throw new Error('Missing own private key');
1424
1484
  }
1425
1485
  const sharedSecret = await crypto.subtle.deriveBits({ name: 'ECDH', public: newPubKey }, ownPriv, 384);
1426
- const input = concatBytes(this.state.rootKey, new Uint8Array(sharedSecret));
1486
+ const input = concatBytes$1(this.state.rootKey, new Uint8Array(sharedSecret));
1427
1487
  const keys = await this.hkdf(input, 'DoubleRatchetDH', 64);
1428
1488
  this.state.rootKey = keys.subarray(0, 32);
1429
1489
  this.state.recvChainKey = keys.subarray(32);
@@ -1445,16 +1505,24 @@ class DoubleRatchet {
1445
1505
  throw new Error(`Session type mismatch: expected ${this.sessionType}, got ${sessionType}`);
1446
1506
  }
1447
1507
  const keyStore = KeyStore.getInstance();
1448
- if (this.sessionType === 'specific' && this.state.sendCount >= this.windowSize && this.state.sendCount > 0) {
1449
- newDhKey = true;
1450
- }
1508
+ // The DH-ratchet path is disabled, deliberately. It used to fire by
1509
+ // itself once `sendCount` passed a 100-message window, and it was wrong
1510
+ // in three ways at once: it wrote the fresh pair into the PROCESS-GLOBAL
1511
+ // `KeyStore` under our own id (clobbering the long-term ECDH key that
1512
+ // every other ratchet and the Space key-unwrap path read back from the
1513
+ // same entry), it stepped our sending chain without telling the peer (so
1514
+ // message 101 of any conversation stopped decrypting), and it hardcoded
1515
+ // the client role regardless of which half of the session we are.
1516
+ //
1517
+ // No caller in the SDK asks for a rotation — every one passes `false` —
1518
+ // so failing loudly here is strictly better than silently desynchronising
1519
+ // a session. Rotation returns on the standard trigger (receipt of a new
1520
+ // peer `dhPub`) with the key schedule rebuilt; see
1521
+ // docs/superpowers/specs/2026-09-24-dm-protocol-design.md §0 and §3.6.
1451
1522
  if (newDhKey && this.sessionType === 'specific') {
1452
- appLogger.debug(`Generating new DH key pair for ${senderId}`);
1453
- const ecdhKeyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
1454
- this.state.clientDhPriv = ecdhKeyPair.privateKey;
1455
- this.state.clientDhPub = ecdhKeyPair.publicKey;
1456
- keyStore.keys.set(senderId, { privateKey: ecdhKeyPair.privateKey, publicKey: ecdhKeyPair.publicKey });
1457
- await this.dhRatchet(this.state.serverDhPub, true);
1523
+ throw new Error('DoubleRatchet: DH key rotation is disabled — the previous implementation ' +
1524
+ 'corrupted the process-global KeyStore and desynchronised the peer. ' +
1525
+ 'Rotation is reinstated with the key-schedule rewrite (audit H1).');
1458
1526
  }
1459
1527
  const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
1460
1528
  this.state.sendChainKey = newChainKey;
@@ -2209,6 +2277,7 @@ class WSTransport extends EventCore {
2209
2277
  pongFrame;
2210
2278
  heartbeatTimer = null;
2211
2279
  pongTimer = null;
2280
+ terminalCloseCodes;
2212
2281
  constructor(opts) {
2213
2282
  super();
2214
2283
  if (!opts || !opts.url) {
@@ -2225,6 +2294,7 @@ class WSTransport extends EventCore {
2225
2294
  this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
2226
2295
  this.pingFrame = opts.pingFrame ?? "ping";
2227
2296
  this.pongFrame = opts.pongFrame ?? "pong";
2297
+ this.terminalCloseCodes = opts.terminalCloseCodes ?? [];
2228
2298
  }
2229
2299
  /** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
2230
2300
  async connect() {
@@ -2277,12 +2347,17 @@ class WSTransport extends EventCore {
2277
2347
  reject(err);
2278
2348
  }
2279
2349
  };
2280
- socket.onclose = () => {
2350
+ socket.onclose = (ev) => {
2351
+ const code = typeof ev?.code === "number" ? ev.code : undefined;
2281
2352
  const wasConnected = this._isConnected;
2282
2353
  this._isConnected = false;
2283
2354
  this._isConnecting = false;
2284
2355
  this.stopHeartbeat();
2285
- this.emit(EventCoreEvents.DISCONNECTED, undefined);
2356
+ this.emit(EventCoreEvents.DISCONNECTED, code !== undefined ? { code, reason: ev?.reason ?? "" } : undefined);
2357
+ if (code !== undefined && this.terminalCloseCodes.includes(code)) {
2358
+ this.autoReconnect = false;
2359
+ return;
2360
+ }
2286
2361
  if (this.autoReconnect && wasConnected) {
2287
2362
  this.scheduleReconnect();
2288
2363
  }
@@ -2312,6 +2387,14 @@ class WSTransport extends EventCore {
2312
2387
  this._isConnected = false;
2313
2388
  this._isConnecting = false;
2314
2389
  }
2390
+ /** Restart reconnecting after a terminal close or `disconnect()`; the next URL comes from `urlProvider`. */
2391
+ resume() {
2392
+ // A reconnect already pending is left to run: a second timer would dial on top of it.
2393
+ if (this._isConnected || this._isConnecting || this.reconnectTimeout)
2394
+ return;
2395
+ this.autoReconnect = true;
2396
+ this.scheduleReconnect();
2397
+ }
2315
2398
  /**
2316
2399
  * Send a raw frame. If currently disconnected, the frame is queued and sent
2317
2400
  * after the next successful reconnect (up to `maxQueueSize`).
@@ -2412,6 +2495,7 @@ class WSTransport extends EventCore {
2412
2495
  this.reconnectAttempts++;
2413
2496
  this.emit(EventCoreEvents.RECONNECTING, { attempt: this.reconnectAttempts });
2414
2497
  this.reconnectTimeout = setTimeout(async () => {
2498
+ this.reconnectTimeout = null;
2415
2499
  try {
2416
2500
  await this.connect();
2417
2501
  }
@@ -2529,8 +2613,9 @@ class BroadcastChannel {
2529
2613
  void this.announce();
2530
2614
  }
2531
2615
  });
2532
- this.transport.on(EventCoreEvents.DISCONNECTED, () => {
2533
- this.emit(BroadcastChannelEvents.DISCONNECTED);
2616
+ // `detail` is the close `{code, reason}` when the socket reported one.
2617
+ this.transport.on(EventCoreEvents.DISCONNECTED, (e) => {
2618
+ this.emit(BroadcastChannelEvents.DISCONNECTED, e.detail);
2534
2619
  });
2535
2620
  this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
2536
2621
  this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
@@ -2565,6 +2650,10 @@ class BroadcastChannel {
2565
2650
  disconnect() {
2566
2651
  this.transport.disconnect();
2567
2652
  }
2653
+ /** Reconnect after a terminal close (see `terminalCloseCodes`) or `disconnect()`, on a fresh `urlProvider` URL. */
2654
+ resume() {
2655
+ this.transport.resume();
2656
+ }
2568
2657
  /**
2569
2658
  * Send our `keyExchange` frame to the room. Idempotent — only sends on the
2570
2659
  * first call. Call this once your higher-level handshake (e.g. sending a
@@ -3424,6 +3513,79 @@ class DoubleRatchetManager {
3424
3513
  }
3425
3514
  }
3426
3515
 
3516
+ /**
3517
+ * OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
3518
+ *
3519
+ * Used to gate the password/recovery factors of the identity vault
3520
+ * ({@link ./vault}). The wrap key for a password factor is
3521
+ * `HKDF(OPRF(serverKey, scrypt(password)))`, so:
3522
+ * - the client cannot derive the wrap key offline (it needs the server's
3523
+ * secret-keyed evaluation), and
3524
+ * - the server learns nothing — it only ever sees a *blinded* group element,
3525
+ * never the password or the resulting key.
3526
+ *
3527
+ * Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
3528
+ * round-trip to a rate-limited server endpoint. The same OPRF backs the M2
3529
+ * Google/email factors.
3530
+ *
3531
+ * Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
3532
+ * (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
3533
+ * {@link oprfBlind} + {@link oprfFinalize}.
3534
+ *
3535
+ * The `oprfDeriveKey` / `oprfBlindEvaluate` helpers here are the SERVER half,
3536
+ * but note they are used only by this repo's tests. The accelerator does NOT
3537
+ * import them: it has an independent implementation in its own
3538
+ * `src/services/oprf.ts`, also built directly on `@noble/curves`. (An earlier
3539
+ * version of this comment claimed the accelerator's VaultDO imported them; it
3540
+ * does not — it imports `@muhkoo/connect` in exactly two files, both for
3541
+ * Groth16 verification.) Any protocol change therefore has to land in BOTH
3542
+ * repos in lockstep.
3543
+ *
3544
+ * KNOWN WEAKNESS — mode 0x00, tracked as audit finding C4 and NOT fixed here.
3545
+ * This uses RFC 9497 base mode: the server returns a bare evaluation with no
3546
+ * DLEQ proof, and {@link oprfFinalize} verifies nothing. A hostile server can
3547
+ * answer `{evaluated: blinded}` (i.e. k=1); the unblinded point is then just
3548
+ * `H(input)` and the wrap key collapses to a pure function of public strings,
3549
+ * destroying the offline-uncrackability guarantee stated above.
3550
+ *
3551
+ * The fix is `ristretto255_oprf.voprf` (mode 0x01) with the server's public key
3552
+ * pinned as a build-time constant and the proof verified on every finalize.
3553
+ * noble's `voprf` variant is already present and unused. It cannot be done
3554
+ * client-side alone: the wire response (`{evaluated}` / `{evaluated, evaluated2}`)
3555
+ * carries no proof field, and the pinned public keys must correspond to the
3556
+ * accelerator's actual K1/K2 secrets. Changing only this file would break login
3557
+ * against the live server.
3558
+ */
3559
+ const OPRF = ristretto255_oprf.oprf;
3560
+ // Domain separation for the server key derivation (RFC 9497 `info`).
3561
+ new TextEncoder().encode("muhkoo-oprf-v1");
3562
+ /**
3563
+ * The RFC 9497 mode this client speaks, declared on every evaluation request.
3564
+ *
3565
+ * Declaring it is what lets the server refuse loudly instead of answering in a
3566
+ * mode we did not ask for — the silent substitution that IS audit finding C4.
3567
+ * A server too old to know the field drops it and answers in base mode, which is
3568
+ * what we asked for anyway, so sending it is safe in both directions and imposes
3569
+ * no deploy ordering.
3570
+ *
3571
+ * NOT to be confused with the vault's KEY VERSION, a different axis that also
3572
+ * uses small integers: there, `2` means "per-account key". Here `2` means POPRF.
3573
+ * The server half of these constants is `accelerator/src/services/oprf.ts` and
3574
+ * MUST hold the same numbers.
3575
+ */
3576
+ const OPRF_PROTOCOL_BASE = 0;
3577
+ /** What this SDK build actually speaks. Bumped to POPRF by the mode migration. */
3578
+ const OPRF_PROTOCOL = OPRF_PROTOCOL_BASE;
3579
+ /** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
3580
+ function oprfBlind(input) {
3581
+ const { blind, blinded } = OPRF.blind(input);
3582
+ return { blind, blinded };
3583
+ }
3584
+ /** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
3585
+ function oprfFinalize(input, blind, evaluated) {
3586
+ return OPRF.finalize(input, blind, evaluated);
3587
+ }
3588
+
3427
3589
  /**
3428
3590
  * HTTP client for the accelerator's `/api/auth/*` endpoints.
3429
3591
  *
@@ -3436,6 +3598,27 @@ class DoubleRatchetManager {
3436
3598
  * a `baseUrl`, optional custom `fetch`, throws typed `Error`s on failure
3437
3599
  * (with the worker's message inlined where available).
3438
3600
  */
3601
+ /**
3602
+ * A non-2xx response from `/api/auth/*`, carrying the HTTP status and the
3603
+ * worker's machine-readable `code` alongside its human `error` string.
3604
+ *
3605
+ * Exists because the status used to be discarded: every failure arrived as a
3606
+ * bare `Error` whose only distinguishing feature was its message, so classifying
3607
+ * one meant regex-matching a sentence the server is free to reword. See
3608
+ * `VaultUnavailableError`, which still does exactly that for rate limits.
3609
+ */
3610
+ class AuthHttpError extends Error {
3611
+ label;
3612
+ status;
3613
+ code;
3614
+ constructor(label, status, code, detail) {
3615
+ super(`AuthClient.${label}: ${detail}`);
3616
+ this.label = label;
3617
+ this.status = status;
3618
+ this.code = code;
3619
+ this.name = "AuthHttpError";
3620
+ }
3621
+ }
3439
3622
  class AuthClient {
3440
3623
  baseUrl;
3441
3624
  fetchFn;
@@ -3474,18 +3657,26 @@ class AuthClient {
3474
3657
  return await this.parse("verify", res);
3475
3658
  }
3476
3659
  // ---- Identity vault (M1.0) ----------------------------------------------
3477
- /** Blinded OPRF evaluation — the server-gated half of a password/recovery wrap key. */
3478
- async oprfEvaluate(username, blinded) {
3479
- const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded }));
3660
+ /**
3661
+ * Blinded OPRF evaluation — the server-gated half of a password/recovery wrap key.
3662
+ *
3663
+ * `oprfProtocol` declares the RFC 9497 mode this client speaks so the server
3664
+ * can refuse rather than answer in a different one. A server too old to know
3665
+ * the field drops it and answers in base mode, so this is safe against any
3666
+ * deployment.
3667
+ */
3668
+ async oprfEvaluate(username, blinded, oprfProtocol = OPRF_PROTOCOL) {
3669
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, oprfProtocol }));
3480
3670
  return await this.parse("oprfEvaluate", res);
3481
3671
  }
3482
3672
  /**
3483
- * Gated split-key OPRF evaluation (M2 — email/google factors). Requires a
3484
- * live `verifyToken` from {@link emailOtpVerify}; returns BOTH trust
3485
- * domains' evaluations. The recover token is consumed by this call.
3673
+ * Gated OPRF evaluation (M2 — email/google factors). Requires a live
3674
+ * `verifyToken` from {@link emailOtpVerify}; returns BOTH keys'
3675
+ * evaluations (K1 and K2, both operated by Muhkoo). The recover token is
3676
+ * consumed by this call.
3486
3677
  */
3487
- async oprfEvaluateGated(username, blinded, factorType, purpose, verifyToken) {
3488
- const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, factorType, purpose, verifyToken }));
3678
+ async oprfEvaluateGated(username, blinded, factorType, purpose, verifyToken, oprfProtocol = OPRF_PROTOCOL) {
3679
+ const res = await this.fetchFn(`${this.baseUrl}/api/auth/oprf`, this.json("POST", { username, blinded, factorType, purpose, verifyToken, oprfProtocol }));
3489
3680
  return await this.parse("oprfEvaluateGated", res);
3490
3681
  }
3491
3682
  /**
@@ -3592,10 +3783,13 @@ class AuthClient {
3592
3783
  // Leave body null; we'll fall through to the status-only message.
3593
3784
  }
3594
3785
  if (!res.ok) {
3595
- const msg = body && typeof body === "object" && "error" in body
3596
- ? String(body.error)
3597
- : `${res.status} ${res.statusText}`;
3598
- throw new Error(`AuthClient.${label}: ${msg}`);
3786
+ const obj = body && typeof body === "object" ? body : null;
3787
+ const msg = obj && "error" in obj ? String(obj.error) : `${res.status} ${res.statusText}`;
3788
+ // Throws `AuthHttpError` rather than `Error` so callers can classify
3789
+ // by STATUS and CODE instead of regex-matching a human sentence.
3790
+ // `VaultUnavailableError` still does the latter for rate limits; new
3791
+ // code should not copy it — a server-side copy edit breaks it.
3792
+ throw new AuthHttpError(label, res.status, obj && typeof obj.code === "string" ? obj.code : undefined, msg);
3599
3793
  }
3600
3794
  return body;
3601
3795
  }
@@ -6481,7 +6675,7 @@ async function parseOrThrow(response, op) {
6481
6675
  * expect (and isn't serving a stale cached bundle). Keep this in sync with
6482
6676
  * `package.json` `version`.
6483
6677
  */
6484
- const VERSION = "0.14.0-alpha.9";
6678
+ const VERSION = "0.15.0-alpha.1";
6485
6679
 
6486
6680
  /**
6487
6681
  * `HttpClient` — the one place the unified {@link Client} talks to the
@@ -6597,6 +6791,24 @@ class HttpClient {
6597
6791
  const tag = `${init?.method ?? "GET"} ${reqPath(input)}`;
6598
6792
  return this.fetchFn(input, finalInit).then((res) => { console.info(`[muhkoo:http] ${tag} → ${res.status} ${Math.round(performance.now() - t)}ms`); return res; }, (err) => { console.info(`[muhkoo:http] ${tag} → ERROR ${Math.round(performance.now() - t)}ms`); throw err; });
6599
6793
  };
6794
+ /**
6795
+ * {@link fetch} with the JSON helpers' stale-session recovery: a 401 on a
6796
+ * request made while a session token is held runs `onUnauthorized` once
6797
+ * and, if that recovered, replays the request once with the fresh token.
6798
+ * The final response is returned whatever its status, never thrown. The
6799
+ * replay re-sends `init` as given, so its body must be re-sendable (a
6800
+ * string or bytes, not a stream).
6801
+ */
6802
+ fetchWithRecovery = async (input, init) => {
6803
+ const res = await this.fetch(input, init);
6804
+ // `onUnauthorized` is consulted at most once per call, so a replay that
6805
+ // still 401s (recovery minted a token the endpoint still rejects) can't loop.
6806
+ if (res.status !== 401 || !this.onUnauthorized || !this.getSessionToken())
6807
+ return res;
6808
+ if (!(await this.onUnauthorized()))
6809
+ return res;
6810
+ return this.fetch(input, init);
6811
+ };
6600
6812
  /** True when `input` resolves to {@link baseOrigin} (relative URLs always do). */
6601
6813
  isSameOrigin(input) {
6602
6814
  try {
@@ -6627,23 +6839,15 @@ class HttpClient {
6627
6839
  del(path, body) {
6628
6840
  return this.request("DELETE", path, body);
6629
6841
  }
6630
- async request(method, path, body, allowRecovery = true) {
6842
+ async request(method, path, body) {
6631
6843
  const url = path.startsWith("http") ? path : `${this.baseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
6632
6844
  const init = { method };
6633
6845
  if (body !== undefined) {
6634
6846
  init.headers = { "Content-Type": "application/json" };
6635
6847
  init.body = JSON.stringify(body);
6636
6848
  }
6637
- const res = await this.fetch(url, init);
6638
- // Stale session: try to silently re-auth and replay the request once.
6639
- // `allowRecovery` is cleared on the retry so a still-401 response (e.g.
6640
- // recovery minted a token the endpoint still rejects) can't loop.
6641
- if (res.status === 401 && allowRecovery && this.onUnauthorized && this.getSessionToken()) {
6642
- const recovered = await this.onUnauthorized();
6643
- if (recovered) {
6644
- return this.request(method, path, body, false);
6645
- }
6646
- }
6849
+ // Stale session: silently re-auth and replay the request once.
6850
+ const res = await this.fetchWithRecovery(url, init);
6647
6851
  let parsed = null;
6648
6852
  try {
6649
6853
  parsed = await res.json();
@@ -6861,69 +7065,19 @@ function mnemonicToSeed(mnemonic) {
6861
7065
  }
6862
7066
 
6863
7067
  /**
6864
- * OPRF (Oblivious PRF) — RFC 9497, ristretto255-SHA512, mode 0x00.
6865
- *
6866
- * Used to gate the password/recovery factors of the identity vault
6867
- * ({@link ./vault}). The wrap key for a password factor is
6868
- * `HKDF(OPRF(serverKey, scrypt(password)))`, so:
6869
- * - the client cannot derive the wrap key offline (it needs the server's
6870
- * secret-keyed evaluation), and
6871
- * - the server learns nothing — it only ever sees a *blinded* group element,
6872
- * never the password or the resulting key.
6873
- *
6874
- * Net effect: a stolen vault blob is NOT offline-crackable; every guess costs a
6875
- * round-trip to a rate-limited server endpoint. The same OPRF backs the M2
6876
- * Google/email factors.
6877
- *
6878
- * Thin wrapper over `@noble/curves`' vetted `ristretto255_oprf` so the protocol
6879
- * (blind / blindEvaluate / finalize) lives in one named place. CLIENT uses
6880
- * {@link oprfBlind} + {@link oprfFinalize}.
6881
- *
6882
- * The `oprfDeriveKey` / `oprfBlindEvaluate` helpers here are the SERVER half,
6883
- * but note they are used only by this repo's tests. The accelerator does NOT
6884
- * import them: it has an independent implementation in its own
6885
- * `src/services/oprf.ts`, also built directly on `@noble/curves`. (An earlier
6886
- * version of this comment claimed the accelerator's VaultDO imported them; it
6887
- * does not — it imports `@muhkoo/connect` in exactly two files, both for
6888
- * Groth16 verification.) Any protocol change therefore has to land in BOTH
6889
- * repos in lockstep.
6890
- *
6891
- * KNOWN WEAKNESS — mode 0x00, tracked as audit finding C4 and NOT fixed here.
6892
- * This uses RFC 9497 base mode: the server returns a bare evaluation with no
6893
- * DLEQ proof, and {@link oprfFinalize} verifies nothing. A hostile server can
6894
- * answer `{evaluated: blinded}` (i.e. k=1); the unblinded point is then just
6895
- * `H(input)` and the wrap key collapses to a pure function of public strings,
6896
- * destroying the offline-uncrackability guarantee stated above.
6897
- *
6898
- * The fix is `ristretto255_oprf.voprf` (mode 0x01) with the server's public key
6899
- * pinned as a build-time constant and the proof verified on every finalize.
6900
- * noble's `voprf` variant is already present and unused. It cannot be done
6901
- * client-side alone: the wire response (`{evaluated}` / `{evaluated, evaluated2}`)
6902
- * carries no proof field, and the pinned public keys must correspond to the
6903
- * accelerator's actual K1/K2 secrets. Changing only this file would break login
6904
- * against the live server.
6905
- */
6906
- const OPRF = ristretto255_oprf.oprf;
6907
- // Domain separation for the server key derivation (RFC 9497 `info`).
6908
- new TextEncoder().encode("muhkoo-oprf-v1");
6909
- /** CLIENT: blind `input` (e.g. `scrypt(password)`) before the server eval. */
6910
- function oprfBlind(input) {
6911
- const { blind, blinded } = OPRF.blind(input);
6912
- return { blind, blinded };
6913
- }
6914
- /** CLIENT: fold the server's blinded evaluation into the final 64-byte OPRF output. */
6915
- function oprfFinalize(input, blind, evaluated) {
6916
- return OPRF.finalize(input, blind, evaluated);
6917
- }
6918
-
6919
- /**
6920
- * Verification-gated vault factors (M2 — email, later Google).
7068
+ * Verification-gated vault factors (M2 — email and Google).
6921
7069
  *
6922
7070
  * These factors have NO client-side secret: the user proves *channel control*
6923
- * (an email OTP) and the server releases a split-key OPRF evaluation —
6924
- * `wrapKey = HKDF( OPRF(K1, input) ‖ OPRF(K2, input) )` where K1 lives on the
6925
- * accelerator and K2 in a separate trust domain, so no single server-side
6926
- * compromise can derive the wrap key offline. See accelerator
7071
+ * (an email OTP, or a verified Google ID token) and the server releases a
7072
+ * two-key OPRF evaluation —
7073
+ * `wrapKey = HKDF( OPRF(K1, input) ‖ OPRF(K2, input) )`. Both keys are
7074
+ * operated by Muhkoo (K1 is the accelerator's `OPRF_KEY`), and the accelerator
7075
+ * relays K2's evaluation to the client verbatim, which folds it in with no
7076
+ * verification. So the fold protects a stolen vault blob against outside
7077
+ * attackers only — not against the operator, and not against an accelerator
7078
+ * compromise. Muhkoo can recover the seed of any account with a gated factor;
7079
+ * the hosted sign-in UI discloses this, and custom enroll UIs must too. See
7080
+ * `docs/superpowers/specs/2026-09-25-vault-poprf-design.md` §2.4, and accelerator
6927
7081
  * `AUTH_M2_PLAN.md` §1; the canonical reference implementation is
6928
7082
  * accelerator `tests/gated-oprf.test.ts` — keep these derivations in
6929
7083
  * byte-exact lockstep with it.
@@ -7001,6 +7155,14 @@ async function gatedWrapKey(input, blind, evaluatedB64, evaluated2B64) {
7001
7155
  * one-time key delivered in a URL fragment a TV can't receive).
7002
7156
  */
7003
7157
  const PKCE_STORE_KEY = "muhkoo.hosted.pkce";
7158
+ /**
7159
+ * A same-origin path the app may be sent back to after sign-in. It starts with
7160
+ * "/", not "//", and has no backslash or control characters (design §4.4.9).
7161
+ */
7162
+ function isSameOriginPath(p) {
7163
+ return typeof p === "string" && p.startsWith("/") && !p.startsWith("//") && !p.includes("\\")
7164
+ && ![...p].some((c) => c.charCodeAt(0) < 0x20);
7165
+ }
7004
7166
  // ---- device pairing: constants (tv-device-pairing-spec.md §4.5, §9) --------
7005
7167
  /** RFC-8628-style backoff step applied on every `slow_down` (spec §9.1). */
7006
7168
  const SLOW_DOWN_STEP_S = 5;
@@ -7084,12 +7246,15 @@ class HostedAuth {
7084
7246
  * (App side) Begin hosted login: stash a PKCE verifier + state, then send
7085
7247
  * the browser to the hosted authorize page. Returns the URL it redirects to
7086
7248
  * (also useful for popup mode / tests). `redirectUri` must be registered for
7087
- * the app in the portal.
7249
+ * the app in the portal. `returnTo`, a same-origin path, rides the stash and
7250
+ * comes back from {@link handleCallback}; anything else is refused here.
7088
7251
  */
7089
7252
  async login(opts) {
7253
+ if (opts.returnTo !== undefined && !isSameOriginPath(opts.returnTo))
7254
+ throw new Error('returnTo must be a same-origin path (start with "/", not "//").');
7090
7255
  const { codeVerifier, codeChallenge } = await generatePkce();
7091
7256
  const state = randomState();
7092
- this.store({ codeVerifier, state, appId: opts.appId, redirectUri: opts.redirectUri });
7257
+ this.store({ codeVerifier, state, appId: opts.appId, redirectUri: opts.redirectUri, ...(opts.returnTo ? { returnTo: opts.returnTo } : {}) });
7093
7258
  const params = {
7094
7259
  app_id: opts.appId,
7095
7260
  redirect_uri: opts.redirectUri,
@@ -7135,7 +7300,8 @@ class HostedAuth {
7135
7300
  /**
7136
7301
  * (App side) Complete login on the callback URL: verify state, exchange the
7137
7302
  * code (+ PKCE verifier) for the session, unseal the seed from the fragment,
7138
- * establish the session, and scrub the URL. Returns the signed-in user.
7303
+ * establish the session, and scrub the URL. Returns the signed-in user, with
7304
+ * the `returnTo` passed to {@link login} when it is still a same-origin path.
7139
7305
  */
7140
7306
  async handleCallback() {
7141
7307
  const loc = this.location();
@@ -7157,7 +7323,8 @@ class HostedAuth {
7157
7323
  this.deps.session.setIdentity(identity);
7158
7324
  this.deps.session.setSeed(seed);
7159
7325
  this.scrubUrl();
7160
- return { username: res.username, commitment: res.commitment };
7326
+ const returnTo = isSameOriginPath(stash.returnTo) ? stash.returnTo : undefined;
7327
+ return { username: res.username, commitment: res.commitment, ...(returnTo ? { returnTo } : {}) };
7161
7328
  }
7162
7329
  /**
7163
7330
  * (Hosted-page side) Finish an /authorize request after the user has
@@ -7902,6 +8069,34 @@ class VaultUnavailableError extends Error {
7902
8069
  this.cause = cause;
7903
8070
  }
7904
8071
  }
8072
+ /**
8073
+ * The vault refused the OPRF protocol this client speaks.
8074
+ *
8075
+ * Distinct from {@link VaultUnavailableError} because the remedy is different and
8076
+ * the two used to be conflated: "unavailable" means retry, and a version mismatch
8077
+ * never resolves by retrying — it resolves by updating the app (or the server).
8078
+ *
8079
+ * Critically, this must NOT be caught into the
8080
+ * `vaultSeed ?? deriveMasterSeedFromPassword(...)` fallback in `login`/`unlock`.
8081
+ * That fallback is correct for an account with no password factor, and wrong for
8082
+ * a refusal: it derives a different seed, and on a legacy-migrated account —
8083
+ * where the fallback seed happens to be the right one — it would let
8084
+ * `migrateLegacyPasswordFactor` rewrite the factor as a side effect of a failure
8085
+ * the user was never shown.
8086
+ */
8087
+ class VaultProtocolMismatchError extends Error {
8088
+ constructor(cause) {
8089
+ super("This app version can't talk to the secure vault. Update the app and try again.");
8090
+ this.name = "VaultProtocolMismatchError";
8091
+ this.cause = cause;
8092
+ }
8093
+ }
8094
+ /** Is `e` the vault refusing our OPRF protocol (HTTP 409)? */
8095
+ function isProtocolRefusal(e) {
8096
+ // Classified by STATUS and CODE, never by matching the server's prose.
8097
+ const err = e;
8098
+ return !!err && err.status === 409 && err.code === "oprf_protocol_unsupported";
8099
+ }
7905
8100
  /**
7906
8101
  * A stored device pairing that the account has since revoked.
7907
8102
  *
@@ -7996,7 +8191,13 @@ class ZkAuth {
7996
8191
  try {
7997
8192
  await this.enrollPasswordFactor(username, password, seed, token);
7998
8193
  }
7999
- catch {
8194
+ catch (e) {
8195
+ // "Best effort" is right for a transient failure — we retry next
8196
+ // login. It is wrong for a protocol refusal: that will fail every
8197
+ // time, and swallowing it means the user is told nothing while the
8198
+ // account quietly never gets its factor. Surface that one.
8199
+ if (e instanceof VaultProtocolMismatchError)
8200
+ throw e;
8000
8201
  /* migrate next time */
8001
8202
  }
8002
8203
  }
@@ -8194,15 +8395,16 @@ class ZkAuth {
8194
8395
  }
8195
8396
  /**
8196
8397
  * Add an **email** as a recovery + login factor (M2 verification-gated:
8197
- * the server only releases the split-key OPRF eval after the address is
8398
+ * the server only releases the gated OPRF eval after the address is
8198
8399
  * OTP-verified). Sends the code, then returns a `confirm` continuation:
8199
8400
  *
8200
8401
  * const { confirm } = await client.auth.zk.enrollEmailFactor("me@x.com");
8201
8402
  * await confirm(codeFromInbox);
8202
8403
  *
8203
8404
  * Requires being signed in with the seed held (like the other enrolls).
8204
- * Custody note: gated factors trade some custody for recoverability — the
8205
- * trade-off should be disclosed in the enroll UI.
8405
+ * Custody note: Muhkoo holds the keys behind gated factors, so it can
8406
+ * recover the seed of any account with an email or Google factor. The
8407
+ * hosted sign-in UI discloses this; a custom enroll UI must too.
8206
8408
  */
8207
8409
  async enrollEmailFactor(email) {
8208
8410
  const seed = this.deps.session.seed;
@@ -8228,7 +8430,7 @@ class ZkAuth {
8228
8430
  /**
8229
8431
  * Recover an account via its enrolled **email** factor. Sends a code to the
8230
8432
  * enrolled address (the response never reveals whether one exists), then
8231
- * `confirm(code)` completes: gated read + split-key eval → unwrap seed →
8433
+ * `confirm(code)` completes: gated read + gated OPRF eval → unwrap seed →
8232
8434
  * normal ZK login. After this, call {@link changePassword} to set a new
8233
8435
  * password.
8234
8436
  *
@@ -8258,12 +8460,15 @@ class ZkAuth {
8258
8460
  }
8259
8461
  // --- Google factor (M2.3) ------------------------------------------------
8260
8462
  // Google is pure authentication UX: its verified ID token gates the
8261
- // split-key OPRF eval that releases the master seed. The ZK identity layer
8463
+ // OPRF eval that releases the master seed. The ZK identity layer
8262
8464
  // is unchanged. The app obtains `idToken` via Google Identity Services and
8263
8465
  // passes it in; the SDK never renders Google UI.
8264
8466
  /**
8265
8467
  * Link a Google account as a recovery + login factor. Requires being signed
8266
8468
  * in with the seed held. `idToken` is a fresh Google ID token (GIS).
8469
+ * Custody note: as with {@link enrollEmailFactor}, Muhkoo holds the keys
8470
+ * behind this factor and can recover the seed of an account that has it.
8471
+ * The hosted sign-in UI discloses this; a custom enroll UI must too.
8267
8472
  */
8268
8473
  async enrollGoogleFactor(idToken) {
8269
8474
  const seed = this.deps.session.seed;
@@ -8281,7 +8486,7 @@ class ZkAuth {
8281
8486
  }
8282
8487
  /**
8283
8488
  * Sign in with Google — verify the ID token, unlock the seed via the gated
8284
- * split-key eval, and establish a session. Same machinery serves "Sign in
8489
+ * OPRF eval, and establish a session. Same machinery serves "Sign in
8285
8490
  * with Google" and "recover with Google"; the account must have linked the
8286
8491
  * factor first.
8287
8492
  */
@@ -8304,7 +8509,8 @@ class ZkAuth {
8304
8509
  * Register a brand-new, **passwordless** account whose only factor is Google.
8305
8510
  * A random master seed is generated and wrapped under the Google-gated key;
8306
8511
  * the user can add a password / passkey / phrase later. The ZK registration
8307
- * is identical to a password signup.
8512
+ * is identical to a password signup. Custody note: see
8513
+ * {@link enrollGoogleFactor} — Muhkoo can recover this account's seed.
8308
8514
  */
8309
8515
  async registerWithGoogle(username, idToken) {
8310
8516
  const seed = randomSeed();
@@ -8631,7 +8837,16 @@ class ZkAuth {
8631
8837
  async passwordWrapKey(username, password) {
8632
8838
  const pre = passwordPreHash(username, password);
8633
8839
  const { blind, blinded } = oprfBlind(pre);
8634
- const { evaluated } = await this.deps.auth.oprfEvaluate(username, toBase64$1(blinded));
8840
+ const { evaluated } = await this.deps.auth
8841
+ .oprfEvaluate(username, toBase64$1(blinded), OPRF_PROTOCOL)
8842
+ .catch((e) => {
8843
+ // Raised here, at the one place that knows the call was an OPRF
8844
+ // evaluation, so every caller up the stack sees the typed error
8845
+ // rather than a generic transport failure.
8846
+ if (isProtocolRefusal(e))
8847
+ throw new VaultProtocolMismatchError(e);
8848
+ throw e;
8849
+ });
8635
8850
  return wrapKeyFromOprf(oprfFinalize(pre, blind, fromBase64$1(evaluated)));
8636
8851
  }
8637
8852
  /**
@@ -8655,7 +8870,14 @@ class ZkAuth {
8655
8870
  return null; // genuinely no factor → legacy account
8656
8871
  const key = await this
8657
8872
  .passwordWrapKey(username, password)
8658
- .catch((e) => { throw new VaultUnavailableError(e); }); // OPRF eval unreachable / rate-limited
8873
+ .catch((e) => {
8874
+ // A protocol refusal passes through UNCHANGED. Wrapping it here
8875
+ // would erase the type one line after it was raised, and tell the
8876
+ // user to check their connection about a version mismatch.
8877
+ if (e instanceof VaultProtocolMismatchError)
8878
+ throw e;
8879
+ throw new VaultUnavailableError(e); // OPRF eval unreachable / rate-limited
8880
+ });
8659
8881
  try {
8660
8882
  return await unwrapSeed({ iv: factor.iv, ct: factor.wrap }, key);
8661
8883
  }
@@ -9530,81 +9752,759 @@ class SpacePacketCipher {
9530
9752
  }
9531
9753
 
9532
9754
  /**
9533
- * `Space` — a client handle for one shared space.
9534
- *
9535
- * A space is the unit everything is built on. It carries:
9536
- * - **realtime transport** over a {@link BroadcastChannel} (group handshake,
9537
- * roster frames, raw-frame passthrough);
9538
- * - **fan-out group messaging** — `Message`s sealed once with the space's
9539
- * group key (via {@link SpacePacketCipher}), wrapped in a cleartext
9540
- * {@link Packet} envelope, broadcast AND persisted by the server as
9541
- * history (the `spaceMessage` frame);
9542
- * - **room-scoped file storage** via {@link FileStorage} over the shard
9543
- * endpoint;
9544
- * - **legacy per-peer Double Ratchet messaging** (`send`/`announce`) for the
9545
- * ephemeral, no-history path — unchanged so existing callers (the chat hook
9546
- * reached via `client.message.room(name)`) keep working.
9547
- *
9548
- * `Space` was formerly `Room`; `Room` remains exported as an alias. The
9549
- * fan-out methods (`sendMessage`/`onMessage`/`history`/`join`/`admit`/`rotate`)
9550
- * require a {@link SpaceKeyring} dep; without one only the transport + DR +
9551
- * file surface is available.
9755
+ * Trust-layer errors shared by Spaces and, later, DMs (design §3.3, §6.9;
9756
+ * SpecA §6.6). Class names are public API and are frozen at their first
9757
+ * release (design Q16).
9552
9758
  */
9553
- class Space {
9554
- deps;
9555
- name;
9556
- historyPolicy;
9557
- channel = null;
9558
- pending = [];
9559
- cipher;
9560
- /** Per-instance event bus for fan-out events (isolated across spaces). */
9561
- events = new EventTarget();
9562
- rawWired = false;
9563
- catchUpUnsub = null;
9564
- /** Optional P2P swarm for this Space (block exchange among members). */
9565
- peerNet = null;
9566
- constructor(deps) {
9567
- this.deps = deps;
9568
- this.name = deps.name;
9569
- this.historyPolicy = deps.historyPolicy ?? "static";
9570
- this.cipher = deps.keyring ? new SpacePacketCipher(deps.keyring) : null;
9759
+ /** The session holds no seed, so it has no stable identity and may not take part (design §3.3, T7). */
9760
+ class IdentityLockedError extends Error {
9761
+ code = "identity-locked";
9762
+ constructor(message = "This session is locked. Sign in again (or call client.auth.zk.unlock(password)) to use encrypted spaces. " +
9763
+ "Earlier SDK versions silently used a temporary key here, which hid your earlier messages from other members.") {
9764
+ super(message);
9765
+ this.name = "IdentityLockedError";
9571
9766
  }
9572
- /** The encoded space id (same as the transport name for fan-out spaces). */
9573
- get id() {
9574
- return this.name;
9767
+ }
9768
+ /**
9769
+ * The stable chat keys could not be loaded (design §3.3). `retryable` says
9770
+ * whether the same call can succeed later. It is true when the vault could not
9771
+ * be reached or written (a 5xx or a network error on `chat-keys`), which the
9772
+ * next open retries (§9.2 I8). It is false when the saved keys were read but do
9773
+ * not unwrap under this session's seed, because they are damaged or were saved
9774
+ * under another seed, and when the client has no vault: retrying fails the same way.
9775
+ */
9776
+ class IdentityUnavailableError extends Error {
9777
+ reason;
9778
+ retryable;
9779
+ code = "identity-unavailable";
9780
+ constructor(message = "Your chat keys could not be loaded right now. Try again.", reason, retryable = true) {
9781
+ super(message);
9782
+ this.reason = reason;
9783
+ this.retryable = retryable;
9784
+ this.name = "IdentityUnavailableError";
9575
9785
  }
9576
- /** The keyring backing this space's group keys, if any. */
9577
- get keyring() {
9578
- return this.deps.keyring;
9786
+ }
9787
+ /** A membership-log or pin failure. `detail` carries server fields (`reason`, `head`, ...). */
9788
+ class SpaceTrustError extends Error {
9789
+ code;
9790
+ detail;
9791
+ constructor(code, message, detail) {
9792
+ super(message ?? code);
9793
+ this.code = code;
9794
+ this.detail = detail;
9795
+ this.name = "SpaceTrustError";
9579
9796
  }
9580
- // -- transport -------------------------------------------------------------
9581
- /** Open the space websocket (resolves the ticket + identity lazily). */
9582
- async connect() {
9583
- if (!this.channel) {
9584
- // The WS upgrade ticket is single-use + short-TTL, so a reconnect
9585
- // must mint a FRESH one — otherwise the socket drops (idle timeout,
9586
- // network blip) and never comes back. `buildUrl` is handed to the
9587
- // transport as a `urlProvider` it calls before each reconnect.
9588
- const buildUrl = async () => {
9589
- const ticket = await this.deps.fetchTicket();
9590
- return (`${this.deps.wsBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/websocket` +
9591
- (ticket ? `?ticket=${encodeURIComponent(ticket)}` : ""));
9592
- };
9593
- const url = await buildUrl();
9594
- this.channel = this.deps.createChannel
9595
- ? this.deps.createChannel(url, this.deps.myId())
9596
- : new BroadcastChannel({
9597
- url,
9598
- myId: this.deps.myId(),
9599
- autoAnnounce: false,
9600
- urlProvider: buildUrl,
9601
- // Keep recovering across network flaps (each attempt mints a
9602
- // fresh ticket); teardown calls disconnect() which stops it.
9603
- maxReconnectAttempts: 0,
9797
+ }
9798
+ /** This user may not open this Space (design §4.5.12, V25). */
9799
+ class AdmissionDeniedError extends Error {
9800
+ code;
9801
+ constructor(code, message) {
9802
+ super(message ?? code);
9803
+ this.code = code;
9804
+ this.name = "AdmissionDeniedError";
9805
+ }
9806
+ }
9807
+
9808
+ /**
9809
+ * Length-prefixed encoding (SpecA §3.1), the primitive every signed byte string
9810
+ * in `src/spaces/trust` is built from:
9811
+ *
9812
+ * LP(x) := uint32be(len(x)) ‖ x
9813
+ *
9814
+ * Usernames carry no charset rule, so bare concatenation would not be
9815
+ * injective; every variable field is length-prefixed. A string goes in as the
9816
+ * UTF-8 of the exact transmitted string, never a re-encoding of it.
9817
+ *
9818
+ * Ported unchanged from the accelerator's `src/trust/lp.ts`; the two must stay
9819
+ * byte-identical (`tests/fixtures/mlog-v1.json` pins both).
9820
+ */
9821
+ /** uint32be(len) ‖ bytes */
9822
+ function lp(bytes) {
9823
+ if (bytes.length > 0xffffffff)
9824
+ throw new Error("lp: field too long");
9825
+ const out = new Uint8Array(4 + bytes.length);
9826
+ new DataView(out.buffer).setUint32(0, bytes.length, false);
9827
+ out.set(bytes, 4);
9828
+ return out;
9829
+ }
9830
+ /** lp(utf8(s)) */
9831
+ function lpStr(s) {
9832
+ return lp(utf8Encode(s));
9833
+ }
9834
+ /**
9835
+ * Canonical decimal of a safe integer: `String(n)`, so "-1", "0", "42" (and
9836
+ * -0 is "0"). Throws on a float, NaN, an infinity or an unsafe integer, so no
9837
+ * such value can ever reach a signature.
9838
+ */
9839
+ function dec(n) {
9840
+ if (!Number.isSafeInteger(n))
9841
+ throw new Error("dec: not a safe integer");
9842
+ return String(n);
9843
+ }
9844
+ function concatBytes(...parts) {
9845
+ let length = 0;
9846
+ for (const p of parts)
9847
+ length += p.length;
9848
+ const out = new Uint8Array(length);
9849
+ let offset = 0;
9850
+ for (const p of parts) {
9851
+ out.set(p, offset);
9852
+ offset += p.length;
9853
+ }
9854
+ return out;
9855
+ }
9856
+
9857
+ /**
9858
+ * The canonical byte strings of the e2e-channels trust layer (spec §6.10).
9859
+ * Every signature and every hash is computed over these bytes, and the
9860
+ * accelerator builds the same bytes; `tests/fixtures/mlog-v1.json` pins both
9861
+ * sides.
9862
+ *
9863
+ * Rules:
9864
+ * - every field is `LP`'d (`lp.ts`); strings as the UTF-8 of the exact
9865
+ * transmitted string (an `ik` is never normalised before signing);
9866
+ * - integers go through `dec`, so "-1" is valid (the enable intent's seq);
9867
+ * - a hash is never over signature bytes: ECDSA is malleable.
9868
+ *
9869
+ * Changing any byte here breaks every stored log. It is a new label, not an edit.
9870
+ *
9871
+ * Ported unchanged from the accelerator's `src/trust/canonical.ts` (the
9872
+ * server-only app roster encoder is left out). No npm dependencies (§6.9).
9873
+ */
9874
+ const LABEL_CHATKEY = "muhkoo-chatkey-v1";
9875
+ const LABEL_MLOG = "muhkoo-mlog-v1";
9876
+ const LABEL_EPOCH_COMMIT = "muhkoo-epoch-commit-v1";
9877
+ const CANONICAL_DEC = /^(0|[1-9]\d*)$/;
9878
+ /** LP(LABEL_CHATKEY) ‖ LP(lower(memberId)) ‖ LP(ik) ‖ LP(ecdh) ‖ LP(ecdsa) */
9879
+ function certBytes(memberId, cert) {
9880
+ return concatBytes(lpStr(LABEL_CHATKEY), lpStr(memberId.toLowerCase()), lpStr(cert.ik), lpStr(cert.ecdh), lpStr(cert.ecdsa));
9881
+ }
9882
+ /**
9883
+ * Over the integer keys in ascending numeric order: LP(dec(e)) ‖ LP(value).
9884
+ * `{}` gives no bytes. Throws on a key that is not a canonical decimal
9885
+ * ("01", "-1", "1.0") or a value that is not a string.
9886
+ */
9887
+ function epochMapBytes(map) {
9888
+ const epochs = [];
9889
+ for (const key of Object.keys(map)) {
9890
+ if (!CANONICAL_DEC.test(key))
9891
+ throw new Error("epochMapBytes: key is not a canonical decimal");
9892
+ const e = Number(key);
9893
+ if (!Number.isSafeInteger(e))
9894
+ throw new Error("epochMapBytes: key is not a safe integer");
9895
+ if (typeof map[key] !== "string")
9896
+ throw new Error("epochMapBytes: value is not a string");
9897
+ epochs.push(e);
9898
+ }
9899
+ epochs.sort((a, b) => a - b);
9900
+ return concatBytes(...epochs.map((e) => concatBytes(lpStr(dec(e)), lpStr(map[dec(e)]))));
9901
+ }
9902
+ /** The per-op concatenated LPs of §3.5. `leave` has none. */
9903
+ function opBytes(entry) {
9904
+ switch (entry.op) {
9905
+ case "genesis": {
9906
+ const b = entry.body;
9907
+ return concatBytes(lpStr(b.visibility), lpStr(b.policy), lpStr(b.appId), lpStr(dec(b.baseEpoch)), lp(epochMapBytes(b.legacyCommits)));
9908
+ }
9909
+ case "add":
9910
+ return concatBytes(lpStr(entry.body.member), lpStr(entry.body.memberIk), lpStr(entry.body.role));
9911
+ case "role":
9912
+ return concatBytes(lpStr(entry.body.member), lpStr(entry.body.role));
9913
+ case "remove":
9914
+ return lpStr(entry.body.member);
9915
+ case "leave":
9916
+ return new Uint8Array(0);
9917
+ case "join":
9918
+ return lpStr(entry.body.via);
9919
+ case "link": {
9920
+ const b = entry.body;
9921
+ return concatBytes(lpStr(b.linkId), lpStr(b.role), lpStr(b.standing ? "1" : "0"), lpStr(dec(b.expiresAt)), lpStr(dec(b.maxUses)), lpStr(b.linkEcdhPub), lpStr(b.linkSignPub), lp(epochMapBytes(b.keyCommits)));
9922
+ }
9923
+ case "unlink":
9924
+ return lpStr(entry.body.linkId);
9925
+ case "redeem":
9926
+ return lpStr(entry.body.linkId);
9927
+ case "svc": {
9928
+ const b = entry.body;
9929
+ return concatBytes(lpStr(b.member), lpStr(b.action), lpStr(b.ecdhPub), lpStr(b.ecdsaPub), lpStr(b.mode), lpStr(dec(b.rosterSeq)));
9930
+ }
9931
+ case "retire":
9932
+ return concatBytes(lpStr(entry.body.member), lpStr(entry.body.reason));
9933
+ default: {
9934
+ const unknownOp = entry;
9935
+ throw new Error(`opBytes: unknown op ${String(unknownOp.op)}`);
9936
+ }
9937
+ }
9938
+ }
9939
+ /** LP(LABEL_MLOG) ‖ LP(spaceId) ‖ LP(dec(seq)) ‖ LP(prev) ‖ LP(op) ‖ LP(by) ‖ LP(ik) ‖ LP(dec(at)) ‖ LP(opBytes). seq −1 is valid (enable intent). */
9940
+ function entryBytes(entry) {
9941
+ return concatBytes(lpStr(LABEL_MLOG), lpStr(entry.spaceId), lpStr(dec(entry.seq)), lpStr(entry.prev), lpStr(entry.op), lpStr(entry.by), lpStr(entry.ik), lpStr(dec(entry.at)), lp(opBytes(entry)));
9942
+ }
9943
+ /** SHA-256 over bytes (WebCrypto). */
9944
+ async function sha256(bytes) {
9945
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
9946
+ }
9947
+ /** b64 (standard, padded) of SHA-256(entryBytes). Never over `sig` or `lsig`. */
9948
+ async function entryHash(entry) {
9949
+ return toBase64$2(await sha256(entryBytes(entry)));
9950
+ }
9951
+ /** LP(LABEL_EPOCH_COMMIT) ‖ LP(spaceId) ‖ LP(dec(epoch)) ‖ LP(dec(prevKeyed)) ‖ LP(keyCommit) ‖ LP(dec(headSeq)) ‖ LP(headHash) ‖ LP(sole?"1":"0") ‖ LP(by) ‖ LP(ik) ‖ LP(dec(at)) */
9952
+ function ecBytes(ec) {
9953
+ return concatBytes(lpStr(LABEL_EPOCH_COMMIT), lpStr(ec.spaceId), lpStr(dec(ec.epoch)), lpStr(dec(ec.prevKeyed)), lpStr(ec.keyCommit), lpStr(dec(ec.headSeq)), lpStr(ec.headHash), lpStr("1" ), lpStr(ec.by), lpStr(ec.ik), lpStr(dec(ec.at)));
9954
+ }
9955
+ /**
9956
+ * keyCommit(e, K) := b64url_nopad(HKDF-SHA256(ikm = K, salt = utf8("epoch:" + e),
9957
+ * info = utf8("muhkoo/space/key-commit/v1\n" + spaceId), L = 32)) (design §4.2.4).
9958
+ * The same function as accelerator/scripts/gen-mlog-fixture.mjs `keyCommit`.
9959
+ */
9960
+ async function keyCommit(epoch, key, spaceId) {
9961
+ const ikm = await crypto.subtle.importKey("raw", key, "HKDF", false, ["deriveBits"]);
9962
+ const bits = await crypto.subtle.deriveBits({
9963
+ name: "HKDF",
9964
+ hash: "SHA-256",
9965
+ salt: utf8Encode(`epoch:${dec(epoch)}`),
9966
+ info: utf8Encode(`muhkoo/space/key-commit/v1\n${spaceId}`),
9967
+ }, ikm, 256);
9968
+ return toBase64Url(new Uint8Array(bits));
9969
+ }
9970
+
9971
+ /**
9972
+ * Identity keys (IK): an account's P-256 ECDSA public key, raw uncompressed
9973
+ * SEC1 (`0x04 ‖ X ‖ Y`, 65 bytes), registered once with the account.
9974
+ *
9975
+ * Stored IKs come in two spellings. Newer clients register base64
9976
+ * (`exportPublicKeyBase64`); older registrations stored 130-char hex. The
9977
+ * decode rule is the accelerator's: 130 hex digits (either case, optional
9978
+ * `0x`) first, otherwise base64.
9979
+ *
9980
+ * Two IKs are compared as decoded bytes, never as strings: a hex-registered
9981
+ * account and a base64 log entry name the same key.
9982
+ *
9983
+ * Verification is P-256 ECDSA with SHA-256 over IEEE P1363 (r ‖ s, 64 bytes)
9984
+ * signatures in standard or url base64. It fails closed and never throws: a
9985
+ * bad key, a bad signature, or a value with no canonical encoding is simply
9986
+ * "not validly signed". Nothing here logs; a refusal is the caller's to report.
9987
+ *
9988
+ * Ported unchanged from the accelerator's `src/lib/ik.ts` and the primitives of
9989
+ * `src/trust/verify.ts`. No npm dependencies (§6.9).
9990
+ */
9991
+ /** Raw uncompressed P-256 point length. */
9992
+ const RAW_P256_BYTES = 65;
9993
+ /** The longest accepted spelling: `0x` + 130 hex. A base64 IK is 88 chars. */
9994
+ const MAX_IK_CHARS = 132;
9995
+ const HEX_IK = /^[0-9a-fA-F]{130}$/;
9996
+ /** Standard or url alphabet, optional trailing padding. No whitespace: `atob` would skip it. */
9997
+ const B64_CHARS = /^[A-Za-z0-9+/_-]+={0,2}$/;
9998
+ const P256 = { name: "ECDSA", namedCurve: "P-256" };
9999
+ const ECDSA_SHA256 = { name: "ECDSA", hash: "SHA-256" };
10000
+ const P1363_P256_BYTES = 64;
10001
+ /** 64 bytes is 88 base64 chars; anything much longer is not a signature. */
10002
+ const MAX_SIG_CHARS = 128;
10003
+ /**
10004
+ * Raw uncompressed P-256 point (0x04‖X‖Y, 65 bytes) from 130-hex (optional 0x,
10005
+ * either case) or base64 (standard or url alphabet, padded or not). `null` if
10006
+ * malformed, the wrong length, or not 0x04-prefixed. It does not check that
10007
+ * the point is on the curve; `importIk` (below) does.
10008
+ */
10009
+ function decodeRawP256(s) {
10010
+ if (typeof s !== "string" || s.length === 0 || s.length > MAX_IK_CHARS)
10011
+ return null;
10012
+ const hex = s.startsWith("0x") ? s.slice(2) : s;
10013
+ let raw;
10014
+ if (HEX_IK.test(hex)) {
10015
+ raw = new Uint8Array(RAW_P256_BYTES);
10016
+ for (let i = 0; i < RAW_P256_BYTES; i++)
10017
+ raw[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
10018
+ }
10019
+ else {
10020
+ if (!B64_CHARS.test(s))
10021
+ return null;
10022
+ try {
10023
+ raw = fromBase64Url(s);
10024
+ }
10025
+ catch {
10026
+ // Malformed base64 is not a key; null is the "invalid" answer.
10027
+ return null;
10028
+ }
10029
+ }
10030
+ if (raw.length !== RAW_P256_BYTES || raw[0] !== 0x04)
10031
+ return null;
10032
+ return raw;
10033
+ }
10034
+ /** Byte equality of two IK encodings; false if either does not decode. */
10035
+ function ikEquals(a, b) {
10036
+ const x = decodeRawP256(a);
10037
+ const y = decodeRawP256(b);
10038
+ if (!x || !y)
10039
+ return false;
10040
+ let diff = 0;
10041
+ for (let i = 0; i < RAW_P256_BYTES; i++)
10042
+ diff |= x[i] ^ y[i];
10043
+ return diff === 0;
10044
+ }
10045
+ /** `null` if `ik` does not decode, or is not a point on P-256. */
10046
+ async function importIk(ik) {
10047
+ const raw = decodeRawP256(ik);
10048
+ if (!raw)
10049
+ return null;
10050
+ try {
10051
+ return await crypto.subtle.importKey("raw", raw, P256, false, ["verify"]);
10052
+ }
10053
+ catch {
10054
+ // Not on the curve: not an identity key.
10055
+ return null;
10056
+ }
10057
+ }
10058
+ /** `sig`: 64-byte P1363, standard or url base64. Never throws. */
10059
+ async function verifyP256(ik, sigB64, data) {
10060
+ if (typeof sigB64 !== "string" || sigB64 === "" || sigB64.length > MAX_SIG_CHARS)
10061
+ return false;
10062
+ let sig;
10063
+ try {
10064
+ sig = fromBase64Url(sigB64);
10065
+ }
10066
+ catch {
10067
+ return false;
10068
+ }
10069
+ if (sig.length !== P1363_P256_BYTES)
10070
+ return false;
10071
+ const key = await importIk(ik);
10072
+ if (!key)
10073
+ return false;
10074
+ try {
10075
+ return await crypto.subtle.verify(ECDSA_SHA256, key, sig, data);
10076
+ }
10077
+ catch {
10078
+ return false;
10079
+ }
10080
+ }
10081
+ /** fp(IK) = b64(SHA-256(raw 65 bytes)) (design §3.2). Throws if `ik` is not a P-256 point. */
10082
+ async function fingerprint$1(ik) {
10083
+ const raw = decodeRawP256(ik);
10084
+ if (!raw)
10085
+ throw new Error("fingerprint: not a P-256 identity key");
10086
+ return toBase64$2(new Uint8Array(await crypto.subtle.digest("SHA-256", raw)));
10087
+ }
10088
+
10089
+ /**
10090
+ * KeyringClient — HTTP implementation of {@link KeyringTransport} against the
10091
+ * accelerator's per-space keyring endpoints. All blobs are opaque to the
10092
+ * server; this client just moves them.
10093
+ *
10094
+ * POST /api/spaces/:id/keyring/join-request { ...JoinRequest }
10095
+ * GET /api/spaces/:id/keyring/blobs/:member -> WrappedKey[]
10096
+ * POST /api/spaces/:id/keyring/wrap { targetMemberId, fromMemberId, wrapped }
10097
+ * GET /api/spaces/:id/keyring/pending -> JoinRequest[]
10098
+ * GET /api/spaces/:id/keyring/roster -> { memberId, identityEcdhPub }[]
10099
+ * POST /api/spaces/:id/keyring/rotate { nextEpoch } -> { epoch }
10100
+ * GET /api/spaces/:id/keyring/log ?since&limit -> { entries, head }
10101
+ * POST /api/spaces/:id/keyring/log { entry, ec? } -> { ok, head }
10102
+ * GET /api/spaces/:id/keyring/state (status only)
10103
+ * GET /api/spaces/:id/metadata -> SpaceMetadata
10104
+ */
10105
+ /** A keyring route answered non-2xx; status and JSON body preserved (the existing methods keep their plain Errors). */
10106
+ class KeyringHttpError extends Error {
10107
+ status;
10108
+ body;
10109
+ constructor(status, body, route) {
10110
+ super(`KeyringClient ${route}: ${status}${body && typeof body.error === "string" ? ` ${body.error}` : ""}`);
10111
+ this.status = status;
10112
+ this.body = body;
10113
+ this.name = "KeyringHttpError";
10114
+ }
10115
+ }
10116
+ class KeyringClient {
10117
+ deps;
10118
+ constructor(deps) {
10119
+ this.deps = deps;
10120
+ }
10121
+ url(suffix) {
10122
+ return `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.deps.spaceId)}${suffix}`;
10123
+ }
10124
+ async post(suffix, body) {
10125
+ const res = await this.deps.fetch(this.url(suffix), {
10126
+ method: "POST",
10127
+ headers: { "Content-Type": "application/json" },
10128
+ body: JSON.stringify(body),
10129
+ });
10130
+ if (!res.ok)
10131
+ throw new Error(`KeyringClient POST ${suffix}: ${res.status} ${res.statusText}`);
10132
+ return (await res.json().catch(() => ({})));
10133
+ }
10134
+ async get(suffix) {
10135
+ const res = await this.deps.fetch(this.url(suffix));
10136
+ if (!res.ok)
10137
+ throw new Error(`KeyringClient GET ${suffix}: ${res.status} ${res.statusText}`);
10138
+ return (await res.json());
10139
+ }
10140
+ /**
10141
+ * The log routes. Sent through `fetchWithRecovery` when given, as `probeState` is, so a stale session is
10142
+ * re-established before a 401 reads as locked (design §6.9): a GET replays safely, and a POST body is a string.
10143
+ */
10144
+ async request(method, suffix, body) {
10145
+ const res = await (this.deps.fetchWithRecovery ?? this.deps.fetch)(this.url(suffix), method === "POST"
10146
+ ? { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
10147
+ : undefined);
10148
+ const text = await res.text();
10149
+ let json = null;
10150
+ try {
10151
+ json = text ? JSON.parse(text) : null;
10152
+ }
10153
+ catch {
10154
+ json = null;
10155
+ }
10156
+ if (!res.ok)
10157
+ throw new KeyringHttpError(res.status, json, `${method} ${suffix.split("?")[0]}`);
10158
+ return json;
10159
+ }
10160
+ /** GET /keyring/log (design §3.6): entries with seq > since, ascending, ≤ limit; head null = no log. */
10161
+ async fetchLog(since = -1, limit = 500) {
10162
+ return this.request("GET", `/keyring/log?since=${since}&limit=${limit}`);
10163
+ }
10164
+ /** POST /keyring/log {entry, ec?} → {ok, head}; `409 {error:"Log moved", head}` (no code) on a compare-and-set miss. */
10165
+ async postLogEntry(entry, ec) {
10166
+ return this.request("POST", "/keyring/log", ec ? { entry, ec } : { entry });
10167
+ }
10168
+ /**
10169
+ * Status of GET /keyring/state, or "network" when the request fails: 403 is not-member, 401 is locked,
10170
+ * anything else resumes (design §6.9). Sent through `fetchWithRecovery` when given, so a stale session
10171
+ * is re-established before a 401 is believed.
10172
+ */
10173
+ async probeState() {
10174
+ try {
10175
+ const res = await (this.deps.fetchWithRecovery ?? this.deps.fetch)(this.url("/keyring/state"));
10176
+ try {
10177
+ await res.text();
10178
+ }
10179
+ catch { /* ignore */ }
10180
+ return res.status;
10181
+ }
10182
+ catch {
10183
+ return "network";
10184
+ }
10185
+ }
10186
+ async postJoinRequest(req) {
10187
+ await this.post("/keyring/join-request", req);
10188
+ }
10189
+ async fetchBlobs(memberId) {
10190
+ const body = await this.get(`/keyring/blobs/${encodeURIComponent(memberId)}`);
10191
+ return body.blobs ?? [];
10192
+ }
10193
+ async postWrappedKey(targetMemberId, wrapped, fromMemberId) {
10194
+ await this.post("/keyring/wrap", { targetMemberId, fromMemberId, wrapped });
10195
+ }
10196
+ async fetchPending() {
10197
+ const body = await this.get("/keyring/pending");
10198
+ return body.pending ?? [];
10199
+ }
10200
+ async fetchRoster() {
10201
+ const body = await this.get("/keyring/roster");
10202
+ return body.roster ?? [];
10203
+ }
10204
+ async rotate(nextEpoch) {
10205
+ return this.post("/keyring/rotate", { nextEpoch });
10206
+ }
10207
+ async invite(username) {
10208
+ await this.post("/keyring/invite", { username });
10209
+ }
10210
+ // -- Shareable invite links ----------------------------------------------
10211
+ // A capability token tied to this space. Redeeming it allowlists the holder
10212
+ // so the keeper admits them — the link IS the invitation. Mint/list/revoke
10213
+ // require the caller to be the creator or an existing member.
10214
+ async createInviteLink(opts = {}) {
10215
+ return this.post("/keyring/link", opts);
10216
+ }
10217
+ async listInviteLinks() {
10218
+ const body = await this.get("/keyring/links");
10219
+ return body.links ?? [];
10220
+ }
10221
+ /** Set a member's role ("viewer" | "editor"). Owner-only on the server. */
10222
+ async setMemberRole(username, role) {
10223
+ await this.post("/keyring/role", { username, role });
10224
+ }
10225
+ /** Roster with roles (owner/members only). */
10226
+ async members() {
10227
+ const body = await this.get("/keyring/members");
10228
+ return body.members ?? [];
10229
+ }
10230
+ async revokeInviteLink(token) {
10231
+ await this.post("/keyring/link-revoke", { token });
10232
+ }
10233
+ /** Redeem a link: the server allowlists this member and asks the keeper to
10234
+ * wrap the group key for their identity. Caller must be authenticated. */
10235
+ async redeemInvite(req) {
10236
+ await this.post("/keyring/redeem", req);
10237
+ }
10238
+ async fetchMetadata() {
10239
+ try {
10240
+ return await this.get("/metadata");
10241
+ }
10242
+ catch {
10243
+ return null;
10244
+ }
10245
+ }
10246
+ }
10247
+
10248
+ /**
10249
+ * `Space` — a client handle for one shared space.
10250
+ *
10251
+ * A space is the unit everything is built on. It carries:
10252
+ * - **realtime transport** over a {@link BroadcastChannel} (group handshake,
10253
+ * roster frames, raw-frame passthrough);
10254
+ * - **fan-out group messaging** — `Message`s sealed once with the space's
10255
+ * group key (via {@link SpacePacketCipher}), wrapped in a cleartext
10256
+ * {@link Packet} envelope, broadcast AND persisted by the server as
10257
+ * history (the `spaceMessage` frame);
10258
+ * - **room-scoped file storage** via {@link FileStorage} over the shard
10259
+ * endpoint;
10260
+ * - **legacy per-peer Double Ratchet messaging** (`send`/`announce`) for the
10261
+ * ephemeral, no-history path — unchanged so existing callers (the chat hook
10262
+ * reached via `client.message.room(name)`) keep working.
10263
+ *
10264
+ * `Space` was formerly `Room`; `Room` remains exported as an alias. The
10265
+ * fan-out methods (`sendMessage`/`onMessage`/`history`/`join`/`admit`/`rotate`)
10266
+ * require a {@link SpaceKeyring} dep; without one only the transport + DR +
10267
+ * file surface is available.
10268
+ */
10269
+ /** WebSocket close code for a refused private-Space socket (design §3.6; accelerator NOT_MEMBER_CLOSE_CODE). */
10270
+ const NOT_MEMBER_CLOSE_CODE = 4403;
10271
+ /** At most one resume after a 4403 per Space per this interval (design §6.7). */
10272
+ const NOT_MEMBER_RECHECK_MS = 60_000;
10273
+ const NOT_MEMBER_MESSAGE = "You're not a member of this private channel. Ask the owner to add you.";
10274
+ const IDENTITY_MISMATCH_MESSAGE = "The owner added a different identity for you. Ask them to re-add you.";
10275
+ class Space {
10276
+ deps;
10277
+ name;
10278
+ historyPolicy;
10279
+ channel = null;
10280
+ /**
10281
+ * The log turned this user away and closed `channel` (design §3.7). Its
10282
+ * single-use ticket is spent, so it is never dialled again: the next
10283
+ * `connect()` checks the log and opens a new channel on a fresh ticket.
10284
+ */
10285
+ channelSpent = false;
10286
+ /** Every raw channel listener (`on`), attached to each channel this Space opens. */
10287
+ listeners = [];
10288
+ cipher;
10289
+ /** Per-instance event bus for fan-out events (isolated across spaces). */
10290
+ events = new EventTarget();
10291
+ rawWired = false;
10292
+ catchUpUnsub = null;
10293
+ /** Optional P2P swarm for this Space (block exchange among members). */
10294
+ peerNet = null;
10295
+ _admission;
10296
+ /** Whether admission follows the log's changes yet (subscribed once, on the first open the log admits). */
10297
+ admissionWired = false;
10298
+ /** When the last resume after a 4403 ran (design §6.7: at most one per {@link NOT_MEMBER_RECHECK_MS}). */
10299
+ lastResumeAt = -Infinity;
10300
+ resumeTimer = null;
10301
+ /** Bumped by `disconnect()`: a /keyring/state answer that arrives after it is dropped. */
10302
+ teardowns = 0;
10303
+ constructor(deps) {
10304
+ this.deps = deps;
10305
+ this.name = deps.name;
10306
+ this.historyPolicy = deps.historyPolicy ?? "static";
10307
+ this.cipher = deps.keyring ? new SpacePacketCipher(deps.keyring) : null;
10308
+ this._admission = deps.locked ? "locked" : "requesting";
10309
+ deps.keyring?.onKeysChanged(() => {
10310
+ if (this._admission === "requesting")
10311
+ this.setAdmission("keyed");
10312
+ });
10313
+ }
10314
+ /** The encoded space id (same as the transport name for fan-out spaces). */
10315
+ get id() {
10316
+ return this.name;
10317
+ }
10318
+ /** The keyring backing this space's group keys, if any. */
10319
+ get keyring() {
10320
+ return this.deps.keyring;
10321
+ }
10322
+ /** Where this member stands in this Space (design §4.5.12, as the slice ships it). */
10323
+ get admission() {
10324
+ return this._admission;
10325
+ }
10326
+ /** Subscribe to admission changes. Returns an unsubscribe. */
10327
+ onAdmission(h) {
10328
+ const l = (e) => h(e.detail);
10329
+ this.events.addEventListener("admission", l);
10330
+ return () => this.events.removeEventListener("admission", l);
10331
+ }
10332
+ setAdmission(s) {
10333
+ if (this._admission === s)
10334
+ return;
10335
+ this._admission = s;
10336
+ this.emitEvent("admission", s);
10337
+ }
10338
+ /** The log turned this user away; nothing reconnects until it admits them again. */
10339
+ denied() {
10340
+ return this._admission === "not-member" || this._admission === "identity-mismatch";
10341
+ }
10342
+ /**
10343
+ * Sync the membership log and derive admission from it, before any ticket
10344
+ * (design §3.4, §3.7, §4.5.12). A 403 from the log route is not-member and a
10345
+ * 401 is locked. Any other failure is fatal only where this client has
10346
+ * verified the log before (it holds a high-water mark). Otherwise the Space
10347
+ * opens as unlogged this time, and the next open or `mlog` frame syncs again.
10348
+ */
10349
+ async admitByLog(ls) {
10350
+ try {
10351
+ await ls.sync();
10352
+ }
10353
+ catch (e) {
10354
+ if (e instanceof KeyringHttpError && e.status === 403) {
10355
+ this.setAdmission("not-member");
10356
+ throw new AdmissionDeniedError("not-member", NOT_MEMBER_MESSAGE);
10357
+ }
10358
+ if (e instanceof KeyringHttpError && e.status === 401) {
10359
+ this.setAdmission("locked");
10360
+ throw new IdentityLockedError();
10361
+ }
10362
+ // A mark that cannot be read counts as held: the failure is not waved through.
10363
+ if (await ls.hasHighWater().catch(() => true))
10364
+ throw e;
10365
+ log.warn("Space: the member list could not be read; opening without it", {
10366
+ spaceId: this.name,
10367
+ code: failureCode(e),
10368
+ message: e instanceof Error ? e.message : String(e),
10369
+ });
10370
+ }
10371
+ await this.evaluateAdmission();
10372
+ if (this._admission === "not-member")
10373
+ throw new AdmissionDeniedError("not-member", NOT_MEMBER_MESSAGE);
10374
+ if (this._admission === "identity-mismatch")
10375
+ throw new AdmissionDeniedError("identity-mismatch", IDENTITY_MISMATCH_MESSAGE);
10376
+ if (!this.admissionWired) {
10377
+ this.admissionWired = true;
10378
+ ls.onChange(() => {
10379
+ void this.evaluateAdmission().catch((e) => {
10380
+ log.warn("Space: admission could not be re-derived from the member list", {
10381
+ spaceId: this.name,
10382
+ code: failureCode(e),
10383
+ message: e instanceof Error ? e.message : String(e),
10384
+ });
10385
+ });
10386
+ });
10387
+ }
10388
+ }
10389
+ /** Re-derive admission from the verified log (design §3.4 rows, §3.7, §4.5.12). */
10390
+ async evaluateAdmission() {
10391
+ const ls = this.deps.logSync;
10392
+ if (!ls)
10393
+ return;
10394
+ // Before the logEnabled check: a log that went missing leaves the Space unlogged, and broken.
10395
+ if (ls.integrity.broken) {
10396
+ this.setAdmission("integrity");
10397
+ return;
10398
+ }
10399
+ if (!ls.logEnabled)
10400
+ return;
10401
+ const m = ls.member(this.deps.myId());
10402
+ if (!m) {
10403
+ this.setAdmission("not-member");
10404
+ this.dropChannel();
10405
+ return;
10406
+ }
10407
+ if (!ikEquals(m.ik, await ls.signer.identityKeyRaw())) {
10408
+ this.setAdmission("identity-mismatch");
10409
+ this.dropChannel();
10410
+ return;
10411
+ }
10412
+ if (this._admission !== "keyed")
10413
+ this.setAdmission(this.deps.keyring?.hasAnyKey() ? "keyed" : "requesting");
10414
+ }
10415
+ /**
10416
+ * A 4403 alone never shows not-member: ask /keyring/state first (design §6.9, AV12).
10417
+ * 403 is not-member and 401, which `probeState` returns only once a silent re-auth has failed, is
10418
+ * locked; either closes the channel for good, like a log refusal. Any
10419
+ * other answer (404 while step I has no state route, 2xx, 5xx, a network error) resumes, at most
10420
+ * once per {@link NOT_MEMBER_RECHECK_MS}.
10421
+ */
10422
+ async onNotMemberClose() {
10423
+ const teardowns = this.teardowns;
10424
+ const status = this.deps.probeState
10425
+ ? await this.deps.probeState().catch(() => "network")
10426
+ : "network";
10427
+ if (teardowns !== this.teardowns)
10428
+ return; // disconnected while asking: nothing to resume
10429
+ if (status === 403) {
10430
+ this.setAdmission("not-member");
10431
+ this.dropChannel();
10432
+ return;
10433
+ }
10434
+ if (status === 401) {
10435
+ this.setAdmission("locked");
10436
+ this.dropChannel();
10437
+ return;
10438
+ }
10439
+ if (this.resumeTimer)
10440
+ return;
10441
+ const wait = Math.max(0, this.lastResumeAt + NOT_MEMBER_RECHECK_MS - Date.now());
10442
+ this.resumeTimer = setTimeout(() => {
10443
+ this.resumeTimer = null;
10444
+ this.lastResumeAt = Date.now();
10445
+ this.channel?.resume?.();
10446
+ }, wait);
10447
+ }
10448
+ /** Close the socket of a user the log turned away, and never dial that channel again (design §3.7). */
10449
+ dropChannel() {
10450
+ if (!this.channel || this.channelSpent)
10451
+ return;
10452
+ this.channelSpent = true;
10453
+ this.disconnect();
10454
+ }
10455
+ // -- transport -------------------------------------------------------------
10456
+ /**
10457
+ * Open the space websocket (resolves the ticket + identity lazily). On a
10458
+ * Space with a membership log, the log is checked first: a user it does not
10459
+ * list, or lists under another identity key, is refused with
10460
+ * {@link AdmissionDeniedError} before any ticket is fetched (design §4.5.12).
10461
+ */
10462
+ async connect() {
10463
+ if (this.deps.locked)
10464
+ throw new IdentityLockedError();
10465
+ const ls = this.deps.logSync;
10466
+ // Checked on the first open, and again on a Space the log turned away, so a later call never reopens its socket unchecked.
10467
+ // A channel the log closed is replaced, not redialled: its ticket was single-use.
10468
+ if (ls && (!this.channel || this.channelSpent || this.denied()))
10469
+ await this.admitByLog(ls);
10470
+ if (!this.channel || this.channelSpent) {
10471
+ const spent = this.channel;
10472
+ // The WS upgrade ticket is single-use + short-TTL, so a reconnect
10473
+ // must mint a FRESH one — otherwise the socket drops (idle timeout,
10474
+ // network blip) and never comes back. `buildUrl` is handed to the
10475
+ // transport as a `urlProvider` it calls before each reconnect.
10476
+ const buildUrl = async (reconnect = false) => {
10477
+ const ticket = await this.deps.fetchTicket();
10478
+ if (!ticket && reconnect && this.deps.keyring && this.deps.hasSession?.()) {
10479
+ // A reconnect with a live session never goes ticketless (anonymous): let the transport retry.
10480
+ throw new Error("Space: no WebSocket ticket yet; retrying");
10481
+ }
10482
+ const q = new URLSearchParams();
10483
+ if (ticket)
10484
+ q.set("ticket", ticket);
10485
+ q.set("refuse", "close"); // be refused with the 4403 close, which this SDK treats as terminal (design §3.6)
10486
+ return `${this.deps.wsBaseUrl}/api/spaces/${encodeURIComponent(this.name)}/websocket?${q.toString()}`;
10487
+ };
10488
+ const url = await buildUrl();
10489
+ this.channel = this.deps.createChannel
10490
+ ? this.deps.createChannel(url, this.deps.myId())
10491
+ : new BroadcastChannel({
10492
+ url,
10493
+ myId: this.deps.myId(),
10494
+ autoAnnounce: false,
10495
+ urlProvider: () => buildUrl(true),
10496
+ // Keep recovering across network flaps (each attempt mints a
10497
+ // fresh ticket); teardown calls disconnect() which stops it.
10498
+ maxReconnectAttempts: 0,
10499
+ // A 4403 stops the transport; the Space decides whether to resume.
10500
+ terminalCloseCodes: [NOT_MEMBER_CLOSE_CODE],
9604
10501
  });
9605
- for (const [event, handler] of this.pending)
10502
+ this.channelSpent = false;
10503
+ this.rawWired = false;
10504
+ for (const [event, handler] of this.listeners) {
10505
+ spent?.off(event, handler);
9606
10506
  this.channel.on(event, handler);
9607
- this.pending.length = 0;
10507
+ }
9608
10508
  // Fan-out spaces drive their own `{name}` handshake + frame routing.
9609
10509
  // Legacy (keyring-less) callers wire these themselves, so leave that
9610
10510
  // path byte-for-byte unchanged.
@@ -9615,6 +10515,11 @@ class Space {
9615
10515
  // (re-runs on each reconnect to re-form the mesh).
9616
10516
  this.peerNet?.start();
9617
10517
  });
10518
+ this.channel.on(BroadcastChannelEvents.DISCONNECTED, (e) => {
10519
+ const detail = e.detail;
10520
+ if (detail?.code === NOT_MEMBER_CLOSE_CODE)
10521
+ void this.onNotMemberClose();
10522
+ });
9618
10523
  this.wireFanout();
9619
10524
  // Register offline catch-up once: on each reconnect the sync
9620
10525
  // engine pages history forward to fill any gap we missed.
@@ -9626,6 +10531,11 @@ class Space {
9626
10531
  await this.channel.connect();
9627
10532
  }
9628
10533
  disconnect() {
10534
+ this.teardowns++;
10535
+ if (this.resumeTimer) {
10536
+ clearTimeout(this.resumeTimer);
10537
+ this.resumeTimer = null;
10538
+ }
9629
10539
  this.channel?.disconnect();
9630
10540
  this.catchUpUnsub?.();
9631
10541
  this.catchUpUnsub = null;
@@ -9651,21 +10561,16 @@ class Space {
9651
10561
  onPeerGossip(handler) {
9652
10562
  return this.peerNet?.onGossip(handler) ?? (() => { });
9653
10563
  }
9654
- /** Subscribe to a raw channel event. Buffered until `connect()` if early. */
10564
+ /** Subscribe to a raw channel event. Buffered until `connect()` if early, and kept across a new channel. */
9655
10565
  on(event, handler) {
9656
- if (this.channel)
9657
- this.channel.on(event, handler);
9658
- else
9659
- this.pending.push([event, handler]);
10566
+ this.listeners.push([event, handler]);
10567
+ this.channel?.on(event, handler);
9660
10568
  }
9661
10569
  off(event, handler) {
9662
- if (this.channel)
9663
- this.channel.off(event, handler);
9664
- else {
9665
- const i = this.pending.findIndex(([e, h]) => e === event && h === handler);
9666
- if (i >= 0)
9667
- this.pending.splice(i, 1);
9668
- }
10570
+ const i = this.listeners.findIndex(([e, h]) => e === event && h === handler);
10571
+ if (i >= 0)
10572
+ this.listeners.splice(i, 1);
10573
+ this.channel?.off(event, handler);
9669
10574
  }
9670
10575
  // -- legacy Double Ratchet path (unchanged) --------------------------------
9671
10576
  /** Advertise our keyExchange to the room (idempotent per connection). */
@@ -9905,6 +10810,8 @@ class Space {
9905
10810
  * hold nothing, request a key and poll until one arrives (or timeout).
9906
10811
  */
9907
10812
  async join(opts = {}) {
10813
+ if (this.deps.locked)
10814
+ return false;
9908
10815
  const keyring = this.requireKeyring();
9909
10816
  await this.connect();
9910
10817
  if (await keyring.loadFromCache())
@@ -9922,10 +10829,27 @@ class Space {
9922
10829
  }
9923
10830
  return false;
9924
10831
  }
9925
- /** Creator path: mint the initial group key, then connect. */
10832
+ /** Creator path: mint epoch 0; for a private Space, sign the genesis + ec(0) BEFORE connecting (design §4.5.10). */
9926
10833
  async create() {
10834
+ if (this.deps.locked)
10835
+ throw new IdentityLockedError();
9927
10836
  const keyring = this.requireKeyring();
9928
10837
  await keyring.bootstrapNew();
10838
+ const ls = this.deps.logSync;
10839
+ if (this.deps.visibility === "private" && ls) {
10840
+ if (!this.deps.appId)
10841
+ throw new Error("Space: a private Space's genesis needs the app id.");
10842
+ const appId = await this.deps.appId();
10843
+ const k0 = keyring.rawKey(0);
10844
+ await ls.append("genesis", { visibility: "private", policy: this.historyPolicy, appId, baseEpoch: -1, legacyCommits: {} }, async (hash, entry) => {
10845
+ const ec = {
10846
+ v: 1, spaceId: this.name, epoch: 0, prevKeyed: -1,
10847
+ keyCommit: await keyCommit(0, k0, this.name),
10848
+ headSeq: 0, headHash: hash, sole: true, by: entry.by, ik: entry.ik, at: entry.at,
10849
+ };
10850
+ return { ...ec, sig: await ls.signer.sign(ecBytes(ec)) };
10851
+ });
10852
+ }
9929
10853
  await this.connect();
9930
10854
  // Publish our identity in the member directory so peers can verify our
9931
10855
  // message signatures (the creator never "joins", so register here).
@@ -9945,8 +10869,45 @@ class Space {
9945
10869
  }
9946
10870
  /** Rotate to a fresh epoch and re-wrap to the roster (rotate spaces). */
9947
10871
  async rotate(roster) {
10872
+ if (this.deps.logSync?.logEnabled)
10873
+ throw new SpaceTrustError("log-refused", "Key rotation for signed spaces arrives with a later release.");
9948
10874
  return this.requireKeyring().rotate(roster);
9949
10875
  }
10876
+ // -- signed member list (design §3.5–§3.8) ---------------------------------
10877
+ /** Whether this Space has a verified membership log. */
10878
+ get logEnabled() {
10879
+ return !!this.deps.logSync?.logEnabled;
10880
+ }
10881
+ /** The verified member list, or null when this Space has no log. */
10882
+ membership() {
10883
+ return this.deps.logSync?.logEnabled ? this.deps.logSync.members() : null;
10884
+ }
10885
+ /** Called with the member list (null when unlogged) each time the log is re-synced. Returns an unsubscribe. */
10886
+ onMembership(h) {
10887
+ return this.deps.logSync ? this.deps.logSync.onChange(() => h(this.membership())) : () => { };
10888
+ }
10889
+ requireLog() {
10890
+ const ls = this.deps.logSync;
10891
+ if (!ls?.logEnabled)
10892
+ throw new SpaceTrustError("log-refused", "This space has no signed member list.");
10893
+ return ls;
10894
+ }
10895
+ /** Add by exact username (design §3.8; the owner, or an editor for viewers). Looks the IK up in the directory. */
10896
+ async add(username, opts = {}) {
10897
+ const ls = this.requireLog();
10898
+ const memberIk = await ls.directory.lookup(username);
10899
+ return ls.append("add", { member: username, memberIk, role: opts.role ?? "viewer" });
10900
+ }
10901
+ /** Remove a member. In this build it cuts them off at the relay; it does not rotate the key. */
10902
+ async remove(memberId) {
10903
+ return this.requireLog().append("remove", { member: memberId });
10904
+ }
10905
+ async setRole(memberId, role) {
10906
+ return this.requireLog().append("role", { member: memberId, role });
10907
+ }
10908
+ async leave() {
10909
+ return this.requireLog().append("leave", {});
10910
+ }
9950
10911
  // -- room-scoped files (no websocket needed) -------------------------------
9951
10912
  async putFile(file, metadata, opts = {}) {
9952
10913
  const result = await this.fileStorage(opts.backgroundUpload)
@@ -10033,6 +10994,13 @@ class Space {
10033
10994
  const f = frame;
10034
10995
  if (!f || typeof f !== "object")
10035
10996
  return;
10997
+ if (f.mlog && typeof f.mlog === "object") {
10998
+ this.deps.logSync?.scheduleSync();
10999
+ return;
11000
+ }
11001
+ if (f.rotateDue) {
11002
+ return;
11003
+ } // no client can answer a rotation request yet: key rotation for signed spaces comes later
10036
11004
  if (typeof f.spaceMessage === "string" && this.cipher) {
10037
11005
  const handle = Number(f.timestamp ?? 0);
10038
11006
  const decoded = await this.decodeFrame(f.spaceMessage, this.cipher, /*raw*/ true, handle);
@@ -10083,7 +11051,10 @@ class Space {
10083
11051
  // A new member appeared — refresh the directory so their future
10084
11052
  // messages verify, and (if we hold the key) auto-admit them.
10085
11053
  void this.deps.keyring?.refreshDirectory({ force: true });
10086
- if (this.deps.autoAdmit !== false && this.deps.keyring?.hasAnyKey() && req.memberId !== this.deps.myId()) {
11054
+ if (this.deps.autoAdmit !== false &&
11055
+ this.deps.keyring?.hasAnyKey() &&
11056
+ req.memberId !== this.deps.myId() &&
11057
+ this.deps.keyring.canWrapTo(req.memberId)) {
10087
11058
  void this.deps.keyring.admit(req.memberId, req.identityEcdhPub).catch(() => { });
10088
11059
  }
10089
11060
  return;
@@ -10099,6 +11070,9 @@ class Space {
10099
11070
  * (replacement content for an existing handle). Requires a keyring.
10100
11071
  */
10101
11072
  async sealMessage(payload, channel, contentType, clientId) {
11073
+ if (this.deps.logSync?.integrity.broken) {
11074
+ throw new SpaceTrustError("integrity", "This space's member list couldn't be verified. You can still read messages you already have; sending is paused.");
11075
+ }
10102
11076
  const cipher = this.requireCipher(contentType);
10103
11077
  const message = new Message(payload);
10104
11078
  const headers = await cipher.seal(message.serialize());
@@ -10278,6 +11252,10 @@ class Space {
10278
11252
  function delay(ms) {
10279
11253
  return new Promise((r) => setTimeout(r, ms));
10280
11254
  }
11255
+ /** A failure's HTTP status or error code, for a log line. */
11256
+ function failureCode(e) {
11257
+ return (e instanceof KeyringHttpError ? e.status : e?.code) ?? "unknown";
11258
+ }
10281
11259
  /**
10282
11260
  * Zero-pad a numeric server handle for use as a cache key, so string-ordered
10283
11261
  * keys sort in handle order. Must match the offline layer's `padHandle`.
@@ -10494,7 +11472,7 @@ function parseMaybeJson(text) {
10494
11472
  * at channel creation hands the key to the keeper. MUST match the accelerator's
10495
11473
  * `KEEPER_MEMBER_ID`.
10496
11474
  */
10497
- const KEEPER_MEMBER_ID = "__keeper__";
11475
+ const KEEPER_MEMBER_ID$1 = "__keeper__";
10498
11476
  /** Staging-only keyring diagnostics (group-key cache hit/miss, persist, pulls). */
10499
11477
  const KEYRING_DEBUG = (() => {
10500
11478
  try {
@@ -10523,10 +11501,24 @@ class SpaceKeyring {
10523
11501
  * this window won't re-fetch (the roster hasn't changed that fast); genuine
10524
11502
  * membership changes use `{ force: true }` (join requests / history loads). */
10525
11503
  static DIR_COOLDOWN_MS = 5000;
11504
+ keyListeners = new Set();
10526
11505
  constructor(deps) {
10527
11506
  this.deps = deps;
10528
11507
  this.policy = deps.historyPolicy ?? "static";
10529
11508
  }
11509
+ /** Notified whenever this keyring gains a key (bootstrap, cache hit, pull). Returns an unsubscribe. */
11510
+ onKeysChanged(h) {
11511
+ this.keyListeners.add(h);
11512
+ return () => this.keyListeners.delete(h);
11513
+ }
11514
+ notifyKeys() {
11515
+ for (const h of this.keyListeners) {
11516
+ try {
11517
+ h();
11518
+ }
11519
+ catch { /* a listener's failure never breaks the keyring */ }
11520
+ }
11521
+ }
10530
11522
  // -- member directory (for verifying sender signatures) -------------------
10531
11523
  /** The cached ECDSA verify key for a member, if known. */
10532
11524
  ecdsaKeyFor(memberId) {
@@ -10581,6 +11573,14 @@ class SpaceKeyring {
10581
11573
  hasAnyKey() {
10582
11574
  return this.keys.size > 0;
10583
11575
  }
11576
+ /** The raw 32-byte group key for `epoch`, for its key commitment (design §4.2.4). */
11577
+ rawKey(epoch) {
11578
+ return this.keys.get(epoch);
11579
+ }
11580
+ /** Whether a key may be wrapped to `memberId` (design §4.5.1: on a signed Space, log members and the keeper only). */
11581
+ canWrapTo(memberId) {
11582
+ return this.deps.canWrapTo ? this.deps.canWrapTo(memberId) : true;
11583
+ }
10584
11584
  // -- lifecycle -------------------------------------------------------------
10585
11585
  /** Hydrate keys from the PersonalSpace cache, if available. Best-effort —
10586
11586
  * a cache miss/failure (e.g. not unlocked) must not block joining. */
@@ -10622,6 +11622,7 @@ class SpaceKeyring {
10622
11622
  }
10623
11623
  this.epoch = Math.max(0, ...Array.from(this.keys.keys()));
10624
11624
  klog(`loadFromCache: HIT ${this.keys.size} epoch(s), current=${this.epoch}`);
11625
+ this.notifyKeys();
10625
11626
  return true;
10626
11627
  }
10627
11628
  async persist() {
@@ -10651,19 +11652,29 @@ class SpaceKeyring {
10651
11652
  this.keys.set(0, generateSpaceKey());
10652
11653
  this.epoch = 0;
10653
11654
  await this.persist();
11655
+ this.notifyKeys();
10654
11656
  }
10655
11657
  // -- newcomer: request + pull ---------------------------------------------
10656
11658
  /**
10657
11659
  * Post a join request so an online key-holder can wrap a key for us. Also
10658
11660
  * publishes our identity keys in the member directory (so others can verify
10659
- * our message signatures, and the keeper can wrap to us).
11661
+ * our message signatures, and the keeper can wrap to us). With a `keyCert`
11662
+ * dep it also carries the IK certificate over exactly those two strings.
10660
11663
  */
10661
11664
  async requestKey(desiredEpoch) {
11665
+ let keyCert;
11666
+ try {
11667
+ keyCert = this.deps.keyCert ? await this.deps.keyCert() : undefined;
11668
+ }
11669
+ catch {
11670
+ keyCert = undefined; // step I never requires a certificate; the join still goes out (server brief §3)
11671
+ }
10662
11672
  await this.deps.transport.postJoinRequest({
10663
11673
  memberId: this.deps.memberId,
10664
11674
  identityEcdhPub: this.deps.identityEcdhPub,
10665
11675
  identityEcdsaPub: this.deps.identityEcdsaPub,
10666
11676
  desiredEpoch,
11677
+ ...(keyCert ? { keyCert } : {}),
10667
11678
  });
10668
11679
  }
10669
11680
  /**
@@ -10691,6 +11702,7 @@ class SpaceKeyring {
10691
11702
  if (added > 0) {
10692
11703
  this.epoch = Math.max(this.epoch, ...Array.from(this.keys.keys()));
10693
11704
  await this.persist();
11705
+ this.notifyKeys();
10694
11706
  }
10695
11707
  klog(`pullKeys: +${added} epoch(s) (now hold ${this.keys.size}, current=${this.epoch})`);
10696
11708
  return added;
@@ -10717,6 +11729,8 @@ class SpaceKeyring {
10717
11729
  * `rotate` space they receive only the current epoch.
10718
11730
  */
10719
11731
  async admit(targetMemberId, identityEcdhPub) {
11732
+ if (!this.canWrapTo(targetMemberId))
11733
+ throw new Error(`SpaceKeyring: ${targetMemberId} is not a member of this space`);
10720
11734
  const recipient = await importEcdhPublicKey(identityEcdhPub);
10721
11735
  const epochs = this.policy === "static"
10722
11736
  ? Array.from(this.keys.keys())
@@ -10724,148 +11738,55 @@ class SpaceKeyring {
10724
11738
  for (const epoch of epochs) {
10725
11739
  const key = this.keys.get(epoch);
10726
11740
  if (!key)
10727
- continue;
10728
- const wrapped = await wrapSpaceKey(key, epoch, recipient);
10729
- await this.deps.transport.postWrappedKey(targetMemberId, wrapped, this.deps.memberId);
10730
- }
10731
- }
10732
- /**
10733
- * Admit every outstanding join request (an online key-holder fulfilling
10734
- * pending newcomers). Returns the ids admitted.
10735
- */
10736
- async admitPending() {
10737
- const pending = await this.deps.transport.fetchPending();
10738
- const admitted = [];
10739
- for (const req of pending) {
10740
- if (req.memberId === this.deps.memberId)
10741
- continue;
10742
- await this.admit(req.memberId, req.identityEcdhPub);
10743
- admitted.push(req.memberId);
10744
- }
10745
- return admitted;
10746
- }
10747
- /**
10748
- * Rotate to a fresh epoch (rotate spaces). Mints a new group key, advances
10749
- * the epoch, and re-wraps it to every member in `roster` (plus ourselves).
10750
- */
10751
- async rotate(roster) {
10752
- const members = roster ?? (await this.deps.transport.fetchRoster());
10753
- const { epoch: nextEpoch } = await this.deps.transport.rotate(this.epoch + 1);
10754
- const newKey = generateSpaceKey();
10755
- this.keys.set(nextEpoch, newKey);
10756
- this.epoch = nextEpoch;
10757
- for (const m of members) {
10758
- if (m.memberId === this.deps.memberId)
10759
- continue;
10760
- const recipient = await importEcdhPublicKey(m.identityEcdhPub);
10761
- const wrapped = await wrapSpaceKey(newKey, nextEpoch, recipient);
10762
- await this.deps.transport.postWrappedKey(m.memberId, wrapped, this.deps.memberId);
10763
- }
10764
- await this.persist();
10765
- return nextEpoch;
10766
- }
10767
- /** Add `username` to a private channel's membership allowlist (member-only). */
10768
- async invite(username) {
10769
- await this.deps.transport.invite(username);
10770
- }
10771
- }
10772
-
10773
- /**
10774
- * KeyringClient — HTTP implementation of {@link KeyringTransport} against the
10775
- * accelerator's per-space keyring endpoints. All blobs are opaque to the
10776
- * server; this client just moves them.
10777
- *
10778
- * POST /api/spaces/:id/keyring/join-request { ...JoinRequest }
10779
- * GET /api/spaces/:id/keyring/blobs/:member -> WrappedKey[]
10780
- * POST /api/spaces/:id/keyring/wrap { targetMemberId, fromMemberId, wrapped }
10781
- * GET /api/spaces/:id/keyring/pending -> JoinRequest[]
10782
- * GET /api/spaces/:id/keyring/roster -> { memberId, identityEcdhPub }[]
10783
- * POST /api/spaces/:id/keyring/rotate { nextEpoch } -> { epoch }
10784
- * GET /api/spaces/:id/metadata -> SpaceMetadata
10785
- */
10786
- class KeyringClient {
10787
- deps;
10788
- constructor(deps) {
10789
- this.deps = deps;
10790
- }
10791
- url(suffix) {
10792
- return `${this.deps.httpBaseUrl}/api/spaces/${encodeURIComponent(this.deps.spaceId)}${suffix}`;
10793
- }
10794
- async post(suffix, body) {
10795
- const res = await this.deps.fetch(this.url(suffix), {
10796
- method: "POST",
10797
- headers: { "Content-Type": "application/json" },
10798
- body: JSON.stringify(body),
10799
- });
10800
- if (!res.ok)
10801
- throw new Error(`KeyringClient POST ${suffix}: ${res.status} ${res.statusText}`);
10802
- return (await res.json().catch(() => ({})));
10803
- }
10804
- async get(suffix) {
10805
- const res = await this.deps.fetch(this.url(suffix));
10806
- if (!res.ok)
10807
- throw new Error(`KeyringClient GET ${suffix}: ${res.status} ${res.statusText}`);
10808
- return (await res.json());
10809
- }
10810
- async postJoinRequest(req) {
10811
- await this.post("/keyring/join-request", req);
10812
- }
10813
- async fetchBlobs(memberId) {
10814
- const body = await this.get(`/keyring/blobs/${encodeURIComponent(memberId)}`);
10815
- return body.blobs ?? [];
10816
- }
10817
- async postWrappedKey(targetMemberId, wrapped, fromMemberId) {
10818
- await this.post("/keyring/wrap", { targetMemberId, fromMemberId, wrapped });
10819
- }
10820
- async fetchPending() {
10821
- const body = await this.get("/keyring/pending");
10822
- return body.pending ?? [];
10823
- }
10824
- async fetchRoster() {
10825
- const body = await this.get("/keyring/roster");
10826
- return body.roster ?? [];
10827
- }
10828
- async rotate(nextEpoch) {
10829
- return this.post("/keyring/rotate", { nextEpoch });
10830
- }
10831
- async invite(username) {
10832
- await this.post("/keyring/invite", { username });
10833
- }
10834
- // -- Shareable invite links ----------------------------------------------
10835
- // A capability token tied to this space. Redeeming it allowlists the holder
10836
- // so the keeper admits them — the link IS the invitation. Mint/list/revoke
10837
- // require the caller to be the creator or an existing member.
10838
- async createInviteLink(opts = {}) {
10839
- return this.post("/keyring/link", opts);
10840
- }
10841
- async listInviteLinks() {
10842
- const body = await this.get("/keyring/links");
10843
- return body.links ?? [];
10844
- }
10845
- /** Set a member's role ("viewer" | "editor"). Owner-only on the server. */
10846
- async setMemberRole(username, role) {
10847
- await this.post("/keyring/role", { username, role });
10848
- }
10849
- /** Roster with roles (owner/members only). */
10850
- async members() {
10851
- const body = await this.get("/keyring/members");
10852
- return body.members ?? [];
10853
- }
10854
- async revokeInviteLink(token) {
10855
- await this.post("/keyring/link-revoke", { token });
10856
- }
10857
- /** Redeem a link: the server allowlists this member and asks the keeper to
10858
- * wrap the group key for their identity. Caller must be authenticated. */
10859
- async redeemInvite(req) {
10860
- await this.post("/keyring/redeem", req);
11741
+ continue;
11742
+ const wrapped = await wrapSpaceKey(key, epoch, recipient);
11743
+ await this.deps.transport.postWrappedKey(targetMemberId, wrapped, this.deps.memberId);
11744
+ }
10861
11745
  }
10862
- async fetchMetadata() {
10863
- try {
10864
- return await this.get("/metadata");
11746
+ /**
11747
+ * Admit every outstanding join request (an online key-holder fulfilling
11748
+ * pending newcomers). Returns the ids admitted.
11749
+ */
11750
+ async admitPending() {
11751
+ const pending = await this.deps.transport.fetchPending();
11752
+ const admitted = [];
11753
+ for (const req of pending) {
11754
+ if (req.memberId === this.deps.memberId)
11755
+ continue;
11756
+ // The server's pending list still holds ex-members after a remove.
11757
+ if (!this.canWrapTo(req.memberId))
11758
+ continue;
11759
+ await this.admit(req.memberId, req.identityEcdhPub);
11760
+ admitted.push(req.memberId);
10865
11761
  }
10866
- catch {
10867
- return null;
11762
+ return admitted;
11763
+ }
11764
+ /**
11765
+ * Rotate to a fresh epoch (rotate spaces). Mints a new group key, advances
11766
+ * the epoch, and re-wraps it to every member in `roster` (plus ourselves).
11767
+ */
11768
+ async rotate(roster) {
11769
+ const members = roster ?? (await this.deps.transport.fetchRoster());
11770
+ const { epoch: nextEpoch } = await this.deps.transport.rotate(this.epoch + 1);
11771
+ const newKey = generateSpaceKey();
11772
+ this.keys.set(nextEpoch, newKey);
11773
+ this.epoch = nextEpoch;
11774
+ for (const m of members) {
11775
+ if (m.memberId === this.deps.memberId)
11776
+ continue;
11777
+ // The roster keeps ex-members too; the same rule as admit applies to every wrap.
11778
+ if (!this.canWrapTo(m.memberId))
11779
+ continue;
11780
+ const recipient = await importEcdhPublicKey(m.identityEcdhPub);
11781
+ const wrapped = await wrapSpaceKey(newKey, nextEpoch, recipient);
11782
+ await this.deps.transport.postWrappedKey(m.memberId, wrapped, this.deps.memberId);
10868
11783
  }
11784
+ await this.persist();
11785
+ return nextEpoch;
11786
+ }
11787
+ /** Add `username` to a private channel's membership allowlist (member-only). */
11788
+ async invite(username) {
11789
+ await this.deps.transport.invite(username);
10869
11790
  }
10870
11791
  }
10871
11792
 
@@ -11899,25 +12820,640 @@ class PeerNetwork {
11899
12820
  try {
11900
12821
  this.signaler.broadcast("bye");
11901
12822
  }
11902
- catch { /* socket may be gone */ }
11903
- for (const off of this.offs)
11904
- off();
11905
- this.transport.close();
11906
- this.host.close();
11907
- }
11908
- }
11909
-
11910
- /**
11911
- * Whether this environment can do WebRTC peering. `RTCPeerConnection` is
11912
- * Window-only (absent in Node/Workers/SSR), so this gates whether a
11913
- * {@link ./PeerNetwork} can be built at all.
11914
- */
11915
- function isP2pCapable() {
11916
- try {
11917
- return typeof RTCPeerConnection !== "undefined";
12823
+ catch { /* socket may be gone */ }
12824
+ for (const off of this.offs)
12825
+ off();
12826
+ this.transport.close();
12827
+ this.host.close();
12828
+ }
12829
+ }
12830
+
12831
+ /**
12832
+ * Whether this environment can do WebRTC peering. `RTCPeerConnection` is
12833
+ * Window-only (absent in Node/Workers/SSR), so this gates whether a
12834
+ * {@link ./PeerNetwork} can be built at all.
12835
+ */
12836
+ function isP2pCapable() {
12837
+ try {
12838
+ return typeof RTCPeerConnection !== "undefined";
12839
+ }
12840
+ catch {
12841
+ return false;
12842
+ }
12843
+ }
12844
+
12845
+ /**
12846
+ * `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
12847
+ * keypair (the connect `KeyStore` entry keyed by the member id / username).
12848
+ *
12849
+ * Historically each app hand-rolled this (see the web app's old
12850
+ * `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
12851
+ * PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
12852
+ * That scaffolding now lives here so ANY app gets a **stable** member keypair
12853
+ * across reloads for free — without which the member re-admits to every space on
12854
+ * every load and the group-key cache can never round-trip.
12855
+ *
12856
+ * Security invariant: the wrapping secret is the **master seed** (not the
12857
+ * password), so the blob survives password changes and unlocks under a passkey.
12858
+ * The seed is only ever held in memory (never persisted), so this vault can only
12859
+ * provision/rehydrate while the client is unlocked.
12860
+ */
12861
+ /** Staging-only diagnostics: is the member keypair stable across reloads? */
12862
+ const VAULT_DEBUG = (() => {
12863
+ try {
12864
+ return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
12865
+ }
12866
+ catch {
12867
+ return false;
12868
+ }
12869
+ })();
12870
+ function vlog(msg) {
12871
+ if (VAULT_DEBUG)
12872
+ console.info(`[muhkoo:vault] ${msg}`);
12873
+ }
12874
+ /** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
12875
+ async function fingerprint(memberId) {
12876
+ try {
12877
+ const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
12878
+ if (!raw)
12879
+ return "none";
12880
+ let s = 0;
12881
+ for (const b of raw)
12882
+ s = (s * 31 + b) >>> 0;
12883
+ return s.toString(16).padStart(8, "0");
12884
+ }
12885
+ catch {
12886
+ return "err";
12887
+ }
12888
+ }
12889
+ /** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
12890
+ const CHAT_KEYS_KEY = "chat-keys";
12891
+ /**
12892
+ * The saved blob was read but does not unwrap, parse or import under this
12893
+ * seed: it is damaged, or was saved under another seed. Unlike a store
12894
+ * failure, retrying with the same seed fails the same way (design §9.2 I8).
12895
+ */
12896
+ class ChatKeysUnreadableError extends Error {
12897
+ reason;
12898
+ constructor(reason) {
12899
+ super("The saved chat keys do not unwrap under this seed.");
12900
+ this.reason = reason;
12901
+ this.name = "ChatKeysUnreadableError";
12902
+ }
12903
+ }
12904
+ class ChatKeyVault {
12905
+ store;
12906
+ constructor(store) {
12907
+ this.store = store;
12908
+ }
12909
+ /**
12910
+ * Register-time: ensure a vault-backed keypair exists for `memberId`,
12911
+ * seed-wrap it and persist it. Idempotent for a pair that is already
12912
+ * vault-backed. Otherwise a fresh pair is generated, persisted, and only
12913
+ * then swapped in, replacing whatever the tab held: a throwaway key is never
12914
+ * persisted as the stable one, and a failed persist leaves the tab's keys
12915
+ * as they were, with nothing marked.
12916
+ */
12917
+ async provision(memberId, seedSecret) {
12918
+ const ks = KeyStore.getInstance();
12919
+ const fresh = ks.isVaultBacked(memberId) ? null : await ks.generateKeyPair();
12920
+ const dehydrated = fresh ? await ks.exportKeyPair(fresh) : await ks.dehydrateKeyPair(memberId);
12921
+ const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
12922
+ await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
12923
+ if (fresh)
12924
+ ks.replaceOwnKeyPair(memberId, fresh);
12925
+ vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
12926
+ }
12927
+ /**
12928
+ * Sign-in / unlock time: fetch the wrapped blob, unwrap it with the seed, and
12929
+ * hydrate the `KeyStore`, restoring the SAME keypair the member had before.
12930
+ * Returns `true` when a vault-backed keypair is now in hand, and `false` when
12931
+ * there is no blob yet (a brand-new member; the caller provisions). A store
12932
+ * failure propagates as thrown; a blob that does not unwrap, parse or import
12933
+ * throws `ChatKeysUnreadableError`. Nothing in the `KeyStore` changes until
12934
+ * the saved pair is imported; it then replaces whatever pair the tab held,
12935
+ * in one step, so a pair minted meanwhile is never kept in its place.
12936
+ */
12937
+ async rehydrate(memberId, seedSecret) {
12938
+ const ks = KeyStore.getInstance();
12939
+ if (ks.isVaultBacked(memberId))
12940
+ return true;
12941
+ const wrapped = await this.store.get(CHAT_KEYS_KEY);
12942
+ if (!wrapped) {
12943
+ vlog("rehydrate: no chat-keys blob (new member → will provision)");
12944
+ return false;
12945
+ }
12946
+ ks.replaceOwnKeyPair(memberId, await this.unwrapPair(seedSecret, wrapped));
12947
+ vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
12948
+ return true;
12949
+ }
12950
+ /** Unwrap, parse and import a saved pair, outside the `KeyStore`. */
12951
+ async unwrapPair(seedSecret, wrapped) {
12952
+ try {
12953
+ const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
12954
+ return await KeyStore.getInstance().importKeyPair(JSON.parse(new TextDecoder().decode(bytes)));
12955
+ }
12956
+ catch (e) {
12957
+ throw new ChatKeysUnreadableError(e);
12958
+ }
12959
+ }
12960
+ /**
12961
+ * Ensure a stable keypair is in hand: rehydrate from the vault if a blob
12962
+ * exists, otherwise mint one and persist it (so it's stable next session).
12963
+ * Used at register and lazily when a space is first opened.
12964
+ */
12965
+ async ensure(memberId, seedSecret) {
12966
+ if (await this.rehydrate(memberId, seedSecret))
12967
+ return;
12968
+ await this.provision(memberId, seedSecret);
12969
+ }
12970
+ }
12971
+
12972
+ /**
12973
+ * A certificate by the account IK over the member's P-384 chat keys (design §3.2).
12974
+ * `ecdh`/`ecdsa` must be the EXACT strings posted as identityEcdhPub/identityEcdsaPub.
12975
+ * Minted in memory, never stored client-side.
12976
+ */
12977
+ async function mintKeyCert(signer, memberId, ecdh, ecdsa) {
12978
+ const ik = await signer.identityKeyRaw();
12979
+ const sig = await signer.sign(certBytes(memberId, { ik, ecdh, ecdsa }));
12980
+ return { v: 1, ik, ecdh, ecdsa, sig };
12981
+ }
12982
+
12983
+ /** Ids the platform reserves; never a member (server brief §2.8). */
12984
+ const RESERVED = /^(__keeper__$|__log__(:|$)|__agent__:|__fn__:|__link__:|__machine__:)/;
12985
+ /**
12986
+ * Ops this client does not interpret yet: links and redeems arrive with the
12987
+ * link routes (step A0a), and log-enabled channels carry no agents or
12988
+ * functions, so `svc` and `retire` change nothing here.
12989
+ */
12990
+ const UNSUPPORTED = new Set(["join", "link", "unlink", "redeem", "svc", "retire"]);
12991
+ /**
12992
+ * A verified private membership log (design §3.5–§3.7). Pure: it fetches nothing.
12993
+ * An entry that chains correctly but fails its rule, signature or signer
12994
+ * binding is a no-op (SEC4). A chain break, a bad or missing genesis, a genesis
12995
+ * for another app, or an entry that cannot be encoded is integrity, and
12996
+ * nothing after it is applied. `apply` never throws on an entry a relay sends,
12997
+ * and it returns ok only once a genesis has been applied, so after an ok result
12998
+ * `head` is never null (a private log also has its `owner`).
12999
+ */
13000
+ class MembershipLog {
13001
+ opts;
13002
+ byId = new Map();
13003
+ hashes = new Map();
13004
+ invalidList = [];
13005
+ ownerId = null;
13006
+ headVal = null;
13007
+ humanRemovalSeq = -1;
13008
+ publicLog = false;
13009
+ constructor(opts) {
13010
+ this.opts = opts;
13011
+ }
13012
+ get owner() { return this.ownerId; }
13013
+ get head() { return this.headVal; }
13014
+ get members() { return [...this.byId.values()]; }
13015
+ get lastHumanRemovalSeq() { return this.humanRemovalSeq; }
13016
+ get invalid() { return this.invalidList; }
13017
+ /** A public log: this client treats the Space as unlogged (a public genesis is step A0a). */
13018
+ get unsupported() { return this.publicLog; }
13019
+ member(id) { return this.byId.get(id); }
13020
+ isMember(id) { return this.byId.has(id); }
13021
+ hashAt(seq) { return this.hashes.get(seq); }
13022
+ async apply(entries) {
13023
+ for (const e of entries) {
13024
+ if (typeof e !== "object" || e === null)
13025
+ return { ok: false, integrity: "malformed" };
13026
+ const expectSeq = (this.headVal?.seq ?? -1) + 1;
13027
+ if (e.seq !== expectSeq || e.prev !== (this.headVal?.hash ?? "") || e.spaceId !== this.opts.spaceId) {
13028
+ return { ok: false, integrity: "chain" };
13029
+ }
13030
+ const { sig, lsig: _lsig, ...unsigned } = e;
13031
+ let hash;
13032
+ try {
13033
+ hash = await entryHash(unsigned);
13034
+ }
13035
+ catch {
13036
+ // An unknown op, a non-integer `at`, a missing body or a bad epoch map:
13037
+ // an honest server never stores one, and its hash cannot be computed.
13038
+ return { ok: false, integrity: "malformed" };
13039
+ }
13040
+ if (e.seq === 0) {
13041
+ const bad = await this.checkGenesis(e, sig);
13042
+ if (bad)
13043
+ return { ok: false, integrity: bad };
13044
+ }
13045
+ else {
13046
+ const reason = await this.check(e, sig);
13047
+ if (reason)
13048
+ this.invalidList.push({ seq: e.seq, reason });
13049
+ else
13050
+ this.mutate(e);
13051
+ }
13052
+ this.headVal = { seq: e.seq, hash };
13053
+ this.hashes.set(e.seq, hash);
13054
+ }
13055
+ // A server that reports a head but sends no entries has withheld the genesis.
13056
+ if (!this.headVal)
13057
+ return { ok: false, integrity: "missing-genesis" };
13058
+ return { ok: true };
13059
+ }
13060
+ async checkGenesis(e, sig) {
13061
+ if (e.op !== "genesis")
13062
+ return "chain";
13063
+ const b = e.body;
13064
+ if (!(await verifyP256(e.ik, sig, entryBytes(e))))
13065
+ return "genesis";
13066
+ if (b.appId !== this.opts.appId)
13067
+ return "wrong-app";
13068
+ if (b.visibility !== "private") {
13069
+ this.publicLog = true;
13070
+ return null;
13071
+ }
13072
+ this.ownerId = e.by;
13073
+ this.byId.set(e.by, Object.freeze({ id: e.by, ik: e.ik, role: "owner", since: 0 }));
13074
+ return null;
13075
+ }
13076
+ async check(e, sig) {
13077
+ if (this.publicLog)
13078
+ return "unsupported-op";
13079
+ if (e.op === "genesis")
13080
+ return "genesis-seq";
13081
+ if (UNSUPPORTED.has(e.op))
13082
+ return "unsupported-op";
13083
+ const signer = this.byId.get(e.by);
13084
+ if (!signer)
13085
+ return "not-member";
13086
+ if (!ikEquals(signer.ik, e.ik))
13087
+ return "identity-bound";
13088
+ if (!(await verifyP256(e.ik, sig, entryBytes(e))))
13089
+ return "bad-signature";
13090
+ const b = e.body;
13091
+ switch (e.op) {
13092
+ case "add": {
13093
+ if (signer.role === "viewer")
13094
+ return "not-owner";
13095
+ if (signer.role === "editor" && b.role !== "viewer")
13096
+ return "editor-role";
13097
+ if (!b.member || RESERVED.test(b.member))
13098
+ return "target-reserved";
13099
+ if (this.byId.has(b.member))
13100
+ return "target-member";
13101
+ if (!decodeRawP256(b.memberIk))
13102
+ return "bad-member-ik";
13103
+ if (b.role !== "viewer" && b.role !== "editor")
13104
+ return "malformed";
13105
+ return null;
13106
+ }
13107
+ case "role": {
13108
+ if (signer.role !== "owner")
13109
+ return "not-owner";
13110
+ if (!b.member || RESERVED.test(b.member))
13111
+ return "target-reserved";
13112
+ if (b.member === this.ownerId)
13113
+ return "target-owner";
13114
+ if (!this.byId.has(b.member))
13115
+ return "target-not-member";
13116
+ if (b.role !== "viewer" && b.role !== "editor")
13117
+ return "malformed";
13118
+ return null;
13119
+ }
13120
+ case "remove": {
13121
+ if (signer.role === "viewer")
13122
+ return "not-owner";
13123
+ if (!b.member || RESERVED.test(b.member))
13124
+ return "target-reserved";
13125
+ if (b.member === this.ownerId)
13126
+ return "target-owner";
13127
+ const target = this.byId.get(b.member);
13128
+ if (!target)
13129
+ return "target-not-member";
13130
+ if (signer.role === "editor" && target.role === "editor")
13131
+ return "target-editor";
13132
+ return null;
13133
+ }
13134
+ case "leave":
13135
+ return e.by === this.ownerId ? "owner-leave" : null;
13136
+ default:
13137
+ return "unsupported-op";
13138
+ }
13139
+ }
13140
+ /**
13141
+ * Applies an entry `check` accepted. A member record is never changed in
13142
+ * place: a role change stores a new record, so a `LogMember` a caller
13143
+ * already holds keeps the state it was read at.
13144
+ */
13145
+ mutate(e) {
13146
+ const b = e.body;
13147
+ switch (e.op) {
13148
+ case "add":
13149
+ this.byId.set(b.member, Object.freeze({ id: b.member, ik: b.memberIk, role: b.role, since: e.seq }));
13150
+ return;
13151
+ case "role":
13152
+ this.byId.set(b.member, Object.freeze({ ...this.byId.get(b.member), role: b.role }));
13153
+ return;
13154
+ case "remove":
13155
+ this.byId.delete(b.member);
13156
+ this.humanRemovalSeq = e.seq;
13157
+ return;
13158
+ case "leave":
13159
+ this.byId.delete(e.by);
13160
+ this.humanRemovalSeq = e.seq;
13161
+ return;
13162
+ }
13163
+ }
13164
+ }
13165
+
13166
+ const MAX_APPEND_RETRIES = 3;
13167
+ const SYNC_DEBOUNCE_MS = 1000;
13168
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13169
+ /** Must equal SpaceKeyring's KEEPER_MEMBER_ID (and the accelerator's); duplicated so this module does not import SpaceKeyring. */
13170
+ const KEEPER_MEMBER_ID = "__keeper__";
13171
+ /**
13172
+ * Who a client may wrap a Space key to (design §4.5.1): the keeper always; while
13173
+ * the log's integrity is broken, nobody else; on a log-enabled Space, current
13174
+ * log members only, whatever the server's roster or pending list still holds
13175
+ * after a remove; on a Space without a log, anyone, as before.
13176
+ */
13177
+ function logCanWrapTo(ls) {
13178
+ return (id) => id === KEEPER_MEMBER_ID || (!ls.integrity.broken && (!ls.logEnabled || ls.isMember(id)));
13179
+ }
13180
+ /**
13181
+ * One Space's membership log, as this client has verified it (design §3.6–§3.7).
13182
+ * Syncs the whole log from −1 on each call (slice logs are tiny, so no entry
13183
+ * cache), checks the head against the high-water mark kept in `muhkoo.trust`,
13184
+ * checks the genesis against the pins (TOFU against the directory the first
13185
+ * time), and pins every IK a verified `add` names (design §3.4).
13186
+ */
13187
+ class LogSync {
13188
+ deps;
13189
+ log = null;
13190
+ enabled = false;
13191
+ integrityState = { broken: false };
13192
+ changed = new Set();
13193
+ fps = new Map();
13194
+ listeners = new Set();
13195
+ timer = null;
13196
+ running = null;
13197
+ followUp = null;
13198
+ constructor(deps) {
13199
+ this.deps = deps;
13200
+ }
13201
+ get logEnabled() { return this.enabled; }
13202
+ get integrity() { return this.integrityState; }
13203
+ get signer() { return this.deps.signer; }
13204
+ get directory() { return this.deps.directory; }
13205
+ member(id) { return this.log?.member(id); }
13206
+ isMember(id) { return !!this.log?.isMember(id); }
13207
+ members() {
13208
+ return (this.log?.members ?? []).map((m) => ({
13209
+ id: m.id, role: m.role, ik: m.ik,
13210
+ fingerprint: this.fps.get(m.id) ?? "",
13211
+ identityChanged: this.changed.has(m.id.toLowerCase()),
13212
+ }));
13213
+ }
13214
+ onChange(h) {
13215
+ this.listeners.add(h);
13216
+ return () => this.listeners.delete(h);
13217
+ }
13218
+ /**
13219
+ * An `mlog` frame marks the log stale; sync once a burst of frames settles.
13220
+ * A failure is logged with its code and reported through `onChange`, never
13221
+ * thrown; integrity is left as it was, and the next frame or open retries.
13222
+ */
13223
+ scheduleSync() {
13224
+ if (this.timer)
13225
+ return;
13226
+ this.timer = setTimeout(() => {
13227
+ this.timer = null;
13228
+ void this.sync().catch((e) => {
13229
+ const code = e instanceof KeyringHttpError ? e.status : e?.code;
13230
+ log.warn("LogSync: background sync failed", {
13231
+ spaceId: this.deps.spaceId,
13232
+ code: code ?? "unknown",
13233
+ message: e instanceof Error ? e.message : String(e),
13234
+ });
13235
+ this.emit();
13236
+ });
13237
+ }, SYNC_DEBOUNCE_MS);
13238
+ }
13239
+ hwKey() {
13240
+ return `loghw:${this.deps.commitment()}:${this.deps.spaceId}`;
13241
+ }
13242
+ /** Whether this client holds a high-water mark for this Space, i.e. has verified its log before (design §3.7 item 3). */
13243
+ async hasHighWater() {
13244
+ return (await (await this.deps.store()).get(this.hwKey())) !== undefined;
13245
+ }
13246
+ /**
13247
+ * Fetch and verify the whole log. Single-flight: a call made while a sync
13248
+ * runs does not start a second one alongside it. It waits for exactly one
13249
+ * follow-up run, which starts once the running one ends, so it sees whatever
13250
+ * prompted the call; every call made in the meantime shares that follow-up.
13251
+ */
13252
+ sync() {
13253
+ if (this.followUp)
13254
+ return this.followUp;
13255
+ if (!this.running)
13256
+ return this.startSync();
13257
+ const followUp = this.running.catch(() => undefined).then(() => {
13258
+ this.followUp = null;
13259
+ return this.startSync();
13260
+ });
13261
+ this.followUp = followUp;
13262
+ return followUp;
13263
+ }
13264
+ startSync() {
13265
+ const run = this.runSync().finally(() => {
13266
+ if (this.running === run)
13267
+ this.running = null;
13268
+ });
13269
+ this.running = run;
13270
+ return run;
13271
+ }
13272
+ async runSync() {
13273
+ const store = await this.deps.store();
13274
+ const hw = await store.get(this.hwKey()); // read BEFORE the request (design §3.7 item 3)
13275
+ const all = [];
13276
+ let since = -1;
13277
+ let head = null;
13278
+ for (let page = 0; page < 10_000; page++) {
13279
+ const r = await this.deps.transport.fetchLog(since, 500);
13280
+ head = r.head;
13281
+ const fresh = r.entries.filter((e) => e.seq > since);
13282
+ all.push(...fresh);
13283
+ if (!r.head || fresh.length === 0 || all[all.length - 1].seq >= r.head.seq)
13284
+ break;
13285
+ since = all[all.length - 1].seq;
13286
+ }
13287
+ if (!head) {
13288
+ this.log = null;
13289
+ this.enabled = false;
13290
+ this.integrityState = hw ? { broken: true, reason: "log-missing" } : { broken: false };
13291
+ this.emit();
13292
+ return;
13293
+ }
13294
+ // Only a Space that has a log needs the app id, so an unlogged Space never waits on it.
13295
+ const appId = await this.deps.appId();
13296
+ const log = new MembershipLog({ spaceId: this.deps.spaceId, appId });
13297
+ const res = await log.apply(all);
13298
+ if (log.unsupported) {
13299
+ // A public genesis is unlogged only where this client never recorded a private
13300
+ // chain: a mark or a genesis pin means the log this client verified was replaced.
13301
+ let swapped = { broken: false };
13302
+ if (hw)
13303
+ swapped = { broken: true, reason: "fork" };
13304
+ else {
13305
+ const pinned = await this.deps.pins.get("spaces", await this.spacePinKey());
13306
+ if (pinned !== undefined && pinned !== log.hashAt(0))
13307
+ swapped = { broken: true, reason: "genesis-pin" };
13308
+ }
13309
+ this.log = null;
13310
+ this.enabled = false;
13311
+ this.integrityState = swapped;
13312
+ this.emit();
13313
+ return;
13314
+ }
13315
+ let integrity = res.ok ? { broken: false } : { broken: true, reason: res.integrity };
13316
+ if (!integrity.broken && hw) {
13317
+ if (log.head.seq < hw.seq)
13318
+ integrity = { broken: true, reason: "rollback" };
13319
+ else if (log.hashAt(hw.seq) !== hw.hash)
13320
+ integrity = { broken: true, reason: "fork" };
13321
+ }
13322
+ // The view this client already holds is a high-water mark too: a lower head is never adopted.
13323
+ const prior = this.log?.head ?? null;
13324
+ const lower = prior !== null && (log.head?.seq ?? -1) < prior.seq;
13325
+ if (!integrity.broken && lower)
13326
+ integrity = { broken: true, reason: "rollback" };
13327
+ if (!integrity.broken)
13328
+ integrity = await this.checkGenesis(log);
13329
+ if (!integrity.broken)
13330
+ await this.pinMembers(log);
13331
+ if (!integrity.broken) {
13332
+ // Re-read: another tab may have recorded a newer head while this sync ran. The mark never goes down.
13333
+ const cur = await store.get(this.hwKey());
13334
+ if (!cur || cur.seq <= log.head.seq) {
13335
+ await store.put(this.hwKey(), {
13336
+ seq: log.head.seq,
13337
+ hash: log.head.hash,
13338
+ humanRemovalSeq: Math.max(hw?.humanRemovalSeq ?? -1, cur?.humanRemovalSeq ?? -1, log.lastHumanRemovalSeq),
13339
+ });
13340
+ }
13341
+ }
13342
+ if (!lower)
13343
+ this.log = log;
13344
+ this.enabled = true;
13345
+ this.integrityState = integrity;
13346
+ this.emit();
13347
+ }
13348
+ /** Genesis trust (design §3.7 item 2): the pinned genesis hash, else TOFU with the owner's IK checked against the directory. */
13349
+ async checkGenesis(log) {
13350
+ const owner = log.owner;
13351
+ // A signed genesis can still name no usable owner (`by` null or ""): an honest server never stores one.
13352
+ if (typeof owner !== "string" || owner === "")
13353
+ return { broken: true, reason: "malformed" };
13354
+ const ownerIk = log.member(owner).ik;
13355
+ const genesisHash = log.hashAt(0);
13356
+ const sKey = await this.spacePinKey();
13357
+ const pinned = await this.deps.pins.get("spaces", sKey);
13358
+ if (pinned !== undefined && pinned !== genesisHash)
13359
+ return { broken: true, reason: "genesis-pin" };
13360
+ if (pinned === undefined) {
13361
+ if (!ikEquals(await this.deps.directory.lookup(owner), ownerIk))
13362
+ return { broken: true, reason: "owner-identity" };
13363
+ // A conflict here means another tab pinned a different genesis after our read.
13364
+ const { conflicts } = await this.deps.pins.pin("spaces", { [sKey]: genesisHash });
13365
+ if (conflicts.length)
13366
+ return { broken: true, reason: "genesis-pin" };
13367
+ }
13368
+ const { conflicts } = await this.deps.pins.pin("identity", { [owner.toLowerCase()]: await fingerprint$1(ownerIk) });
13369
+ return conflicts.length ? { broken: true, reason: "owner-identity" } : { broken: false };
13370
+ }
13371
+ /** "s:" + b64url(SHA-256(utf8(spaceId))), the spaces-v1 key; computed exactly as PinStore.spacePinKey, which this layer may not import. */
13372
+ async spacePinKey() {
13373
+ return "s:" + toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(this.deps.spaceId))));
13374
+ }
13375
+ /** Pin every IK a verified add names (design §3.4 row 2); a different pinned IK flags the member (identity-changed). */
13376
+ async pinMembers(log) {
13377
+ const entries = {};
13378
+ for (const m of log.members) {
13379
+ const fp = await fingerprint$1(m.ik);
13380
+ this.fps.set(m.id, fp);
13381
+ if (m.id !== log.owner)
13382
+ entries[m.id.toLowerCase()] = fp;
13383
+ }
13384
+ if (!Object.keys(entries).length)
13385
+ return;
13386
+ const { conflicts } = await this.deps.pins.pin("identity", entries);
13387
+ this.changed = new Set(conflicts);
13388
+ }
13389
+ /**
13390
+ * Sign and append one entry. On `409 Log moved`: re-sync, re-sign against the
13391
+ * new head and retry, at most 3 times with jitter (design §9.2 I9). Nothing is
13392
+ * signed while integrity is broken, including after a re-sync that breaks it.
13393
+ * Any other refusal → SpaceTrustError("log-refused") carrying the server's code and reason.
13394
+ * Once the server has stored the entry, its `{seq}` is returned even if the sync confirming it fails.
13395
+ */
13396
+ async append(op, body, ec) {
13397
+ for (let attempt = 0; attempt <= MAX_APPEND_RETRIES; attempt++) {
13398
+ if (attempt > 0 || (op !== "genesis" && !this.log))
13399
+ await this.sync();
13400
+ if (this.integrityState.broken)
13401
+ throw new SpaceTrustError("integrity", "This space's member list couldn't be verified. You can still read messages you already have; sending is paused.");
13402
+ const head = this.log?.head ?? null;
13403
+ const unsigned = {
13404
+ v: 1,
13405
+ spaceId: this.deps.spaceId,
13406
+ seq: head ? head.seq + 1 : 0,
13407
+ prev: head?.hash ?? "",
13408
+ op,
13409
+ by: this.deps.me(),
13410
+ ik: await this.deps.signer.identityKeyRaw(),
13411
+ at: Date.now(),
13412
+ body,
13413
+ };
13414
+ const sig = await this.deps.signer.sign(entryBytes(unsigned));
13415
+ const entry = { ...unsigned, sig };
13416
+ const commit = ec ? await ec(await entryHash(unsigned), unsigned) : undefined;
13417
+ let r;
13418
+ try {
13419
+ r = await this.deps.transport.postLogEntry(entry, commit);
13420
+ }
13421
+ catch (e) {
13422
+ if (e instanceof KeyringHttpError && e.status === 409 && !e.body?.code) {
13423
+ await sleep(50 + Math.floor(Math.random() * 200));
13424
+ continue;
13425
+ }
13426
+ if (e instanceof KeyringHttpError) {
13427
+ throw new SpaceTrustError("log-refused", String(e.body?.error ?? e.message), { status: e.status, ...(e.body ?? {}) });
13428
+ }
13429
+ throw e;
13430
+ }
13431
+ // The entry is stored: a failure of this sync is not a refusal, and the entry is never re-sent.
13432
+ // It is logged, a background sync applies the entry later, and the stored seq is still returned.
13433
+ try {
13434
+ await this.sync();
13435
+ }
13436
+ catch (e) {
13437
+ const code = e instanceof KeyringHttpError ? e.status : e?.code;
13438
+ log.warn("LogSync: the entry was stored, but the sync confirming it failed", {
13439
+ spaceId: this.deps.spaceId,
13440
+ seq: r.head.seq,
13441
+ code: code ?? "unknown",
13442
+ message: e instanceof Error ? e.message : String(e),
13443
+ });
13444
+ this.scheduleSync();
13445
+ }
13446
+ return { seq: r.head.seq };
13447
+ }
13448
+ throw new SpaceTrustError("log-conflict", "The member list changed while saving. Try again.");
11918
13449
  }
11919
- catch {
11920
- return false;
13450
+ emit() {
13451
+ for (const h of this.listeners) {
13452
+ try {
13453
+ h();
13454
+ }
13455
+ catch { /* a listener's failure never breaks sync */ }
13456
+ }
11921
13457
  }
11922
13458
  }
11923
13459
 
@@ -11959,6 +13495,10 @@ class SpaceNamespace {
11959
13495
  spaceCache;
11960
13496
  /** Spaces built this session, by id — used to route offline send replays. */
11961
13497
  openSpaces = new Map();
13498
+ /** In-flight identity loads, by member id; each entry is deleted once it settles. */
13499
+ identityInFlight = new Map();
13500
+ /** Chat-key certificates, by member and the exact key strings; a failed mint is dropped so the next join retries. */
13501
+ certs = new Map();
11962
13502
  /** Unique id for THIS device/session — the P2P peer identity. A user's own
11963
13503
  * devices share a member id, so the mesh MUST distinguish them by this or a
11964
13504
  * device treats its siblings' signals as its own echo and never connects. */
@@ -11972,7 +13512,8 @@ class SpaceNamespace {
11972
13512
  deps.offline.registerReplayer("space", async (entry) => {
11973
13513
  const { spaceId, frame } = entry.args;
11974
13514
  let space = this.openSpaces.get(spaceId);
11975
- if (!space)
13515
+ // A Space built while the session was locked never connects: build it again now (design §3.3).
13516
+ if (!space || space.admission === "locked")
11976
13517
  space = await this.get(spaceId);
11977
13518
  await space.connect();
11978
13519
  if (!space.isConnected())
@@ -11996,13 +13537,15 @@ class SpaceNamespace {
11996
13537
  * the first VFS write hang forever instead of failing.
11997
13538
  */
11998
13539
  async createSpace(opts = {}) {
13540
+ if (opts.messaging !== false && !this.deps.session.seed)
13541
+ throw new IdentityLockedError();
11999
13542
  const historyPolicy = opts.historyPolicy ?? "static";
12000
13543
  const identity = await generateSpaceIdentity();
12001
13544
  // Register the space (server records pubkey + policy; the DO is lazy).
12002
13545
  // For a private space the server also records the creator (this user) as
12003
13546
  // its first member — so the metadata POST must carry the user session.
12004
13547
  await this.deps.http.post(`/api/spaces/${encodeURIComponent(identity.id)}/metadata`, { spacePubKey: identity.id, historyPolicy, visibility: opts.private ? "private" : "public" });
12005
- const space = await this.build(identity.id, historyPolicy);
13548
+ const space = await this.build(identity.id, historyPolicy, opts.private ? "private" : "public", { messaging: opts.messaging });
12006
13549
  if (opts.messaging !== false)
12007
13550
  await space.create();
12008
13551
  return space;
@@ -12014,9 +13557,13 @@ class SpaceNamespace {
12014
13557
  await space.join({ timeoutMs: opts.timeoutMs });
12015
13558
  return space;
12016
13559
  }
12017
- /** A handle to a space without auto-connecting (uses cached keys on demand). */
12018
- async get(spaceId, historyPolicy = "static") {
12019
- return this.build(spaceId, historyPolicy);
13560
+ /**
13561
+ * A handle to a space without auto-connecting (uses cached keys on demand).
13562
+ * `messaging: false` opens a files-only space, as {@link createSpace} makes:
13563
+ * no keyring and no identity, so it opens whether or not the session is locked.
13564
+ */
13565
+ async get(spaceId, historyPolicy = "static", opts = {}) {
13566
+ return this.build(spaceId, historyPolicy, undefined, opts);
12020
13567
  }
12021
13568
  // -------------------------------------------------------------------------
12022
13569
  // Channels — named, app-scoped pointers to spaces. The name → space-id
@@ -12063,7 +13610,7 @@ class SpaceNamespace {
12063
13610
  try {
12064
13611
  const { pubkey } = await this.deps.http.get("/api/app/keeper");
12065
13612
  if (pubkey && space.keyring)
12066
- await space.keyring.admit(KEEPER_MEMBER_ID, pubkey);
13613
+ await space.keyring.admit(KEEPER_MEMBER_ID$1, pubkey);
12067
13614
  }
12068
13615
  catch (err) {
12069
13616
  console.warn("[client.space] keeper admit failed (channel still usable):", err);
@@ -12114,6 +13661,8 @@ class SpaceNamespace {
12114
13661
  * Returns the open {@link Space}.
12115
13662
  */
12116
13663
  async joinByInvite(spaceId, token, opts = {}) {
13664
+ if (!this.deps.session.seed)
13665
+ throw new IdentityLockedError();
12117
13666
  const memberId = this.myId();
12118
13667
  const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
12119
13668
  await this.keyringFor(spaceId).redeemInvite({ token, identityEcdhPub: ecdhPub, identityEcdsaPub: ecdsaPub });
@@ -12128,19 +13677,64 @@ class SpaceNamespace {
12128
13677
  spaceId,
12129
13678
  httpBaseUrl: this.deps.http.baseUrl,
12130
13679
  fetch: this.deps.http.fetch,
13680
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
12131
13681
  });
12132
13682
  }
12133
13683
  // -------------------------------------------------------------------------
12134
13684
  // Internals
12135
13685
  // -------------------------------------------------------------------------
12136
- async build(spaceId, historyPolicy) {
13686
+ async build(spaceId, historyPolicy, visibility, opts = {}) {
12137
13687
  const memberId = this.myId();
13688
+ const filesOnly = opts.messaging === false;
13689
+ if (filesOnly || !this.deps.session.seed) {
13690
+ // Locked (design §3.3): no identity, no keyring, no join request. The
13691
+ // Space reports admission "locked"; the app re-opens it after sign-in.
13692
+ // A files-only Space (`messaging: false`) is built the same way whether
13693
+ // or not the session is locked, and is never marked locked: its file
13694
+ // chunks carry their own keys, so it needs no identity at all.
13695
+ const keyless = new Space({
13696
+ name: spaceId,
13697
+ wsBaseUrl: this.deps.wsBaseUrl,
13698
+ httpBaseUrl: this.deps.http.baseUrl,
13699
+ fetch: this.deps.http.fetch,
13700
+ myId: () => memberId,
13701
+ fetchTicket: () => this.fetchTicket(),
13702
+ historyPolicy,
13703
+ locked: !filesOnly,
13704
+ shardCache: this.deps.offline?.fileCache,
13705
+ deferShardUpload: this.deps.offline
13706
+ ? (hash) => this.deps.offline.deferShardUpload(hash)
13707
+ : undefined,
13708
+ });
13709
+ this.openSpaces.set(spaceId, keyless);
13710
+ return keyless;
13711
+ }
12138
13712
  const { ecdhPub, ecdsaPub } = await this.ensureIdentity(memberId);
12139
13713
  const transport = new KeyringClient({
12140
13714
  spaceId,
12141
13715
  httpBaseUrl: this.deps.http.baseUrl,
12142
13716
  fetch: this.deps.http.fetch,
13717
+ // The 4403 state check runs after the HttpClient's 401 recovery (design §6.9).
13718
+ fetchWithRecovery: this.deps.http.fetchWithRecovery,
12143
13719
  });
13720
+ // The Space's membership log (design §3.6–§3.7), when this client has trust wired.
13721
+ const trust = this.deps.trust;
13722
+ const logSync = trust
13723
+ ? new LogSync({
13724
+ spaceId,
13725
+ me: () => memberId,
13726
+ appId: () => trust.appId(),
13727
+ commitment: () => this.deps.session.commitment,
13728
+ transport: {
13729
+ fetchLog: (s, l) => transport.fetchLog(s, l),
13730
+ postLogEntry: (e, ec) => transport.postLogEntry(e, ec),
13731
+ },
13732
+ signer: trust.signer,
13733
+ directory: trust.directory,
13734
+ pins: trust.pins,
13735
+ store: () => trust.store(),
13736
+ })
13737
+ : undefined;
12144
13738
  const keyring = new SpaceKeyring({
12145
13739
  spaceId,
12146
13740
  memberId,
@@ -12150,6 +13744,8 @@ class SpaceNamespace {
12150
13744
  transport,
12151
13745
  cache: this.deps.cache,
12152
13746
  historyPolicy,
13747
+ keyCert: this.deps.trust ? () => this.certFor(memberId, ecdhPub, ecdsaPub) : undefined,
13748
+ canWrapTo: logSync ? logCanWrapTo(logSync) : undefined,
12153
13749
  });
12154
13750
  const space = new Space({
12155
13751
  name: spaceId,
@@ -12160,6 +13756,11 @@ class SpaceNamespace {
12160
13756
  fetchTicket: () => this.fetchTicket(),
12161
13757
  keyring,
12162
13758
  historyPolicy,
13759
+ logSync,
13760
+ visibility,
13761
+ appId: trust ? () => trust.appId() : undefined,
13762
+ probeState: () => transport.probeState(),
13763
+ hasSession: () => !!this.deps.session.token,
12163
13764
  offline: this.spaceCache,
12164
13765
  shardCache: this.deps.offline?.fileCache,
12165
13766
  deferShardUpload: this.deps.offline
@@ -12181,43 +13782,63 @@ class SpaceNamespace {
12181
13782
  }
12182
13783
  return space;
12183
13784
  }
12184
- /** Ensure the member has an identity keypair; return its public ECDH + ECDSA keys. */
12185
- async ensureIdentity(memberId) {
13785
+ /**
13786
+ * The member's stable chat identity (design §3.3). A locked session (no
13787
+ * seed) throws `IdentityLockedError`. A vault failure throws
13788
+ * `IdentityUnavailableError`: `retryable` when the vault could not be
13789
+ * reached (§9.2 I8), and not when the saved keys do not unwrap under this
13790
+ * seed. A throwaway pair is never minted: doing so is what hid a reloaded
13791
+ * member's earlier messages from everyone else.
13792
+ *
13793
+ * Concurrent calls for one member share a single load, so two first opens
13794
+ * can never interleave their forget/generate steps and persist a second or
13795
+ * mixed pair.
13796
+ */
13797
+ ensureIdentity(memberId) {
13798
+ let pending = this.identityInFlight.get(memberId);
13799
+ if (!pending) {
13800
+ pending = this.loadIdentity(memberId).finally(() => this.identityInFlight.delete(memberId));
13801
+ this.identityInFlight.set(memberId, pending);
13802
+ }
13803
+ return pending;
13804
+ }
13805
+ async loadIdentity(memberId) {
12186
13806
  const store = KeyStore.getInstance();
12187
- if (!store.getKeyPair(memberId)) {
12188
- // Unlocked (seed in hand): rehydrate the member's STABLE keypair from
12189
- // the vault, or provision + persist one on first use. This keeps the
12190
- // member the same identity across reloads — no re-admit each session,
12191
- // and the group-key cache round-trips. Best-effort: a personal-space
12192
- // hiccup falls through to an ephemeral keypair (today's behavior).
12193
- const seed = this.deps.session.seed;
12194
- const staging = (() => { try {
12195
- return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
13807
+ const seed = this.deps.session.seed;
13808
+ if (!seed)
13809
+ throw new IdentityLockedError();
13810
+ if (!store.isVaultBacked(memberId)) {
13811
+ if (!this.deps.chatKeys) {
13812
+ throw new IdentityUnavailableError("This client has no chat-key vault; sign in with a session first.", undefined, false);
12196
13813
  }
12197
- catch {
12198
- return false;
12199
- } })();
12200
- if (staging)
12201
- console.info(`[muhkoo:vault] ensureIdentity: unlocked=${!!seed} vault=${!!this.deps.chatKeys}`);
12202
- if (this.deps.chatKeys && seed) {
12203
- try {
12204
- await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
12205
- }
12206
- catch (e) {
12207
- if (staging)
12208
- console.info(`[muhkoo:vault] ensureIdentity: vault.ensure failed — ${e instanceof Error ? e.message : e}`);
12209
- }
13814
+ try {
13815
+ await this.deps.chatKeys.ensure(memberId, toBase64$2(seed));
12210
13816
  }
12211
- if (!store.getKeyPair(memberId)) {
12212
- if (staging)
12213
- console.info("[muhkoo:vault] ensureIdentity: EPHEMERAL keypair (locked / no vault) → will re-admit");
12214
- await store.generateOwnKeyPair(memberId);
13817
+ catch (e) {
13818
+ if (e instanceof ChatKeysUnreadableError) {
13819
+ throw new IdentityUnavailableError("Your saved chat keys could not be unlocked with this session's key: they are damaged, " +
13820
+ "or were saved under a different key. Trying again will not fix this.", e, false);
13821
+ }
13822
+ throw new IdentityUnavailableError(undefined, e);
12215
13823
  }
13824
+ if (!store.isVaultBacked(memberId))
13825
+ throw new IdentityUnavailableError();
12216
13826
  }
12217
13827
  const ecdhPub = await exportEcdhPublicKey(store.getKeyPair(memberId).publicKey);
12218
13828
  const ecdsaPub = await exportEcdsaPublicKey(store.getAuthKeyPair(memberId).publicKey);
12219
13829
  return { ecdhPub, ecdsaPub };
12220
13830
  }
13831
+ /** One certificate per (member, chat keys) for the life of the Client (design §3.2: global, not per Space). */
13832
+ certFor(memberId, ecdh, ecdsa) {
13833
+ const k = `${memberId}\n${ecdh}\n${ecdsa}`;
13834
+ let p = this.certs.get(k);
13835
+ if (!p) {
13836
+ p = mintKeyCert(this.deps.trust.signer, memberId, ecdh, ecdsa);
13837
+ p.catch(() => this.certs.delete(k));
13838
+ this.certs.set(k, p);
13839
+ }
13840
+ return p;
13841
+ }
12221
13842
  async fetchPolicy(spaceId) {
12222
13843
  const meta = await new KeyringClient({
12223
13844
  spaceId,
@@ -12506,6 +14127,328 @@ class AccessTokensNamespace {
12506
14127
  }
12507
14128
  }
12508
14129
 
14130
+ /**
14131
+ * POST /api/auth/identity (SpecA §4.1 as amended by CS-7). An unknown name
14132
+ * answers a deterministic decoy, so a lookup never says whether an account
14133
+ * exists. Responses are cached for the life of the Client. Any failure,
14134
+ * including a 429, fails closed (SpecA §4.3).
14135
+ */
14136
+ class IdentityDirectory {
14137
+ http;
14138
+ cache = new Map();
14139
+ constructor(http) {
14140
+ this.http = http;
14141
+ }
14142
+ async lookup(username) {
14143
+ const l = username.toLowerCase();
14144
+ return (await this.lookupMany([username]))[l];
14145
+ }
14146
+ async lookupMany(usernames) {
14147
+ const out = {};
14148
+ const missing = [];
14149
+ for (const u of usernames) {
14150
+ const l = u.toLowerCase();
14151
+ const hit = this.cache.get(l);
14152
+ if (hit)
14153
+ out[l] = hit;
14154
+ else if (!missing.includes(l))
14155
+ missing.push(l);
14156
+ }
14157
+ for (let i = 0; i < missing.length; i += 64) {
14158
+ const batch = missing.slice(i, i + 64);
14159
+ let keys;
14160
+ try {
14161
+ keys = (await this.http.post("/api/auth/identity", { usernames: batch }))?.keys;
14162
+ }
14163
+ catch (e) {
14164
+ throw new SpaceTrustError("lookup-failed", "Couldn't verify members right now; retrying.", {
14165
+ status: e instanceof HttpError ? e.status : undefined,
14166
+ });
14167
+ }
14168
+ for (const l of batch) {
14169
+ const k = keys?.[l];
14170
+ if (typeof k !== "string")
14171
+ throw new SpaceTrustError("lookup-failed", "Couldn't verify members right now; retrying.");
14172
+ this.cache.set(l, k);
14173
+ out[l] = k;
14174
+ }
14175
+ }
14176
+ return out;
14177
+ }
14178
+ }
14179
+
14180
+ /**
14181
+ * Derives the IK from the in-memory seed (HKDF(seed,"ecdsa-priv"), non-extractable),
14182
+ * and checks it against the account's registered IK before its first use, because
14183
+ * `unlockWithSeed`/`unlockWithDevice` set a seed without a commitment check.
14184
+ * Locked or mismatched → IdentityLockedError.
14185
+ */
14186
+ class SeedIdentitySigner {
14187
+ deps;
14188
+ cached = null;
14189
+ constructor(deps) {
14190
+ this.deps = deps;
14191
+ }
14192
+ async key() {
14193
+ const { session } = this.deps;
14194
+ const seed = session.seed;
14195
+ const username = session.username;
14196
+ if (!seed || !username)
14197
+ throw new IdentityLockedError();
14198
+ if (this.cached && this.cached.seed === seed)
14199
+ return this.cached;
14200
+ const identity = session.identity ?? (await deriveIdentityFromSeed(seed));
14201
+ const ik = await exportPublicKeyBase64(identity.ecdsaKeyPair.publicKey);
14202
+ const registered = await this.deps.directory.lookup(username);
14203
+ if (!ikEquals(registered, ik)) {
14204
+ throw new IdentityLockedError("This session's key is not your account's registered identity. Sign in again.");
14205
+ }
14206
+ this.cached = { seed, priv: identity.ecdsaKeyPair.privateKey, ik };
14207
+ return this.cached;
14208
+ }
14209
+ async identityKeyRaw() {
14210
+ return (await this.key()).ik;
14211
+ }
14212
+ async sign(bytes) {
14213
+ const { priv } = await this.key();
14214
+ return toBase64$2(new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, priv, bytes)));
14215
+ }
14216
+ }
14217
+
14218
+ /** Reserved personal-KV keys and signing labels (SpecA §4.2; design §3.4, §6.6a). Frozen formats. */
14219
+ const DOCS = {
14220
+ identity: { key: "muhkoo.pins/identity-v1", label: "muhkoo-pinmap-v1" },
14221
+ spaces: { key: "muhkoo.pins/spaces-v1", label: "muhkoo-spacepins-v1" },
14222
+ };
14223
+ /** `hlc` for a pin write: dec(counter) zero-padded to 16 digits, because the server compares strings (CP9). */
14224
+ const pad16 = (n) => dec(n).padStart(16, "0");
14225
+ /** LP(label) ‖ LP(ownerCommitment) ‖ LP(dec(counter)) ‖ LP(concat over keys sorted (code units) of LP(k) ‖ LP(v)). */
14226
+ function pinDocBytes(label, ownerCommitment, counter, entries) {
14227
+ const keys = Object.keys(entries).sort();
14228
+ return concatBytes(lpStr(label), lpStr(ownerCommitment), lpStr(dec(counter)), lp(concatBytes(...keys.map((k) => concatBytes(lpStr(k), lpStr(entries[k]))))));
14229
+ }
14230
+ /** The `spaces-v1` key for a Space's genesis pin: "s:" + b64url(SHA-256(utf8(spaceId))). */
14231
+ async function spacePinKey(spaceId) {
14232
+ return "s:" + toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8Encode(spaceId))));
14233
+ }
14234
+ class PinStore {
14235
+ deps;
14236
+ constructor(deps) {
14237
+ this.deps = deps;
14238
+ }
14239
+ commitment() {
14240
+ const c = this.deps.commitment();
14241
+ if (!c)
14242
+ throw new SpaceTrustError("pins-invalid", "Sign in first.");
14243
+ return c;
14244
+ }
14245
+ markKey(doc) {
14246
+ return `pinhw:${DOCS[doc].key}:${this.commitment()}`;
14247
+ }
14248
+ kvPath(doc, suffix = "") {
14249
+ return `/api/personal/${encodeURIComponent(this.commitment())}/kv/${encodeURIComponent(DOCS[doc].key)}${suffix}`;
14250
+ }
14251
+ async fetchDoc(doc) {
14252
+ try {
14253
+ const res = await this.deps.http.post(this.kvPath(doc, "/get"), {});
14254
+ return res?.value ?? null;
14255
+ }
14256
+ catch (e) {
14257
+ if (e instanceof HttpError && e.status === 404)
14258
+ return null;
14259
+ throw new SpaceTrustError("pins-stale", "Couldn't verify members right now; retrying.");
14260
+ }
14261
+ }
14262
+ async verify(doc, d) {
14263
+ const ok = d && d.v === 1 && d.ownerCommitment === this.commitment() &&
14264
+ Number.isSafeInteger(d.counter) && d.counter >= 1 &&
14265
+ d.entries && typeof d.entries === "object" &&
14266
+ Object.values(d.entries).every((v) => typeof v === "string") &&
14267
+ (await verifyP256(await this.deps.signer.identityKeyRaw(), d.sig, pinDocBytes(DOCS[doc].label, d.ownerCommitment, d.counter, d.entries)));
14268
+ if (!ok)
14269
+ throw new SpaceTrustError("pins-invalid", "Your saved trust records could not be verified.");
14270
+ }
14271
+ /** The current verified document; an empty bootstrap at counter 0 when none exists and no mark does either. */
14272
+ async read(doc) {
14273
+ const store = await this.deps.store();
14274
+ for (let attempt = 0; attempt < 2; attempt++) {
14275
+ const before = await store.get(this.markKey(doc));
14276
+ const d = await this.fetchDoc(doc);
14277
+ const now = await store.get(this.markKey(doc));
14278
+ if (!d) {
14279
+ if (before !== undefined)
14280
+ throw new SpaceTrustError("pins-missing", "Your saved trust records are missing.");
14281
+ return { v: 1, ownerCommitment: this.commitment(), counter: 0, entries: {}, sig: "" };
14282
+ }
14283
+ await this.verify(doc, d);
14284
+ if (before !== undefined && d.counter < before)
14285
+ throw new SpaceTrustError("pins-rollback", "Your saved trust records were rolled back.");
14286
+ if (now !== undefined && d.counter < now)
14287
+ continue; // stale, not a rollback (KI6): re-read once
14288
+ await store.put(this.markKey(doc), Math.max(d.counter, now ?? 0));
14289
+ return d;
14290
+ }
14291
+ throw new SpaceTrustError("pins-stale", "Couldn't verify members right now; retrying.");
14292
+ }
14293
+ async get(doc, key) {
14294
+ return (await this.read(doc)).entries[key];
14295
+ }
14296
+ /** Pin new keys. A key already pinned to a different value is reported, never overwritten (design §3.4). */
14297
+ async pin(doc, entries) {
14298
+ return this.mutate(doc, (cur) => {
14299
+ const next = { ...cur };
14300
+ const conflicts = [];
14301
+ for (const [k, v] of Object.entries(entries)) {
14302
+ if (next[k] === undefined)
14303
+ next[k] = v;
14304
+ else if (next[k] !== v)
14305
+ conflicts.push(k);
14306
+ }
14307
+ return { next, conflicts };
14308
+ });
14309
+ }
14310
+ async unpin(doc, keys) {
14311
+ await this.mutate(doc, (cur) => {
14312
+ const next = { ...cur };
14313
+ for (const k of keys)
14314
+ delete next[k];
14315
+ return { next, conflicts: [] };
14316
+ });
14317
+ }
14318
+ /** User-confirmed recovery (design §9.1 "Your saved trust records are missing."): forget the high-water mark. */
14319
+ async resetMark(doc) {
14320
+ await (await this.deps.store()).del(this.markKey(doc));
14321
+ }
14322
+ async mutate(doc, change) {
14323
+ const store = await this.deps.store();
14324
+ for (let attempt = 0; attempt <= 3; attempt++) {
14325
+ const cur = await this.read(doc);
14326
+ const { next, conflicts } = change(cur.entries);
14327
+ const same = Object.keys(next).length === Object.keys(cur.entries).length &&
14328
+ Object.entries(next).every(([k, v]) => cur.entries[k] === v);
14329
+ if (same)
14330
+ return { conflicts };
14331
+ const counter = cur.counter + 1;
14332
+ const c = this.commitment();
14333
+ const sig = await this.deps.signer.sign(pinDocBytes(DOCS[doc].label, c, counter, next));
14334
+ const value = { v: 1, ownerCommitment: c, counter, entries: next, sig };
14335
+ const res = await this.deps.http.post(this.kvPath(doc), { value, hlc: pad16(counter) });
14336
+ if (res?.applied === false)
14337
+ continue; // a concurrent writer won: re-read, merge, retry
14338
+ await store.put(this.markKey(doc), counter);
14339
+ return { conflicts };
14340
+ }
14341
+ throw new SpaceTrustError("pins-write-failed", "Couldn't save trust records right now.");
14342
+ }
14343
+ }
14344
+
14345
+ const TRUST_OPEN_TIMEOUT_MS = 2000;
14346
+ const DB_NAME$1 = "muhkoo.trust";
14347
+ const STORE = "kv";
14348
+ function memoryTrustStore() {
14349
+ const m = new Map();
14350
+ return {
14351
+ persistent: false,
14352
+ async get(k) { return m.get(k); },
14353
+ async put(k, v) { m.set(k, structuredClone(v)); },
14354
+ async del(k) { m.delete(k); },
14355
+ };
14356
+ }
14357
+ async function openTrustStore(opts = {}) {
14358
+ const idb = opts.idb === undefined ? globalThis.indexedDB : opts.idb;
14359
+ if (!idb)
14360
+ return memoryTrustStore();
14361
+ const db = await new Promise((resolve) => {
14362
+ let settled = false;
14363
+ const done = (v) => { if (!settled) {
14364
+ settled = true;
14365
+ resolve(v);
14366
+ } };
14367
+ const timer = setTimeout(() => done(null), opts.timeoutMs ?? TRUST_OPEN_TIMEOUT_MS);
14368
+ try {
14369
+ const req = idb.open(DB_NAME$1, 1);
14370
+ req.onupgradeneeded = () => {
14371
+ if (!req.result.objectStoreNames.contains(STORE))
14372
+ req.result.createObjectStore(STORE);
14373
+ };
14374
+ req.onsuccess = () => { clearTimeout(timer); done(req.result); };
14375
+ req.onerror = () => { clearTimeout(timer); done(null); };
14376
+ }
14377
+ catch {
14378
+ clearTimeout(timer);
14379
+ done(null);
14380
+ }
14381
+ });
14382
+ if (!db)
14383
+ return memoryTrustStore();
14384
+ const run = (mode, fn) => new Promise((resolve, reject) => {
14385
+ const r = fn(db.transaction(STORE, mode).objectStore(STORE));
14386
+ r.onsuccess = () => resolve(r.result);
14387
+ r.onerror = () => reject(r.error);
14388
+ });
14389
+ return {
14390
+ persistent: true,
14391
+ get: (k) => run("readonly", (s) => s.get(k)),
14392
+ put: async (k, v) => { await run("readwrite", (s) => s.put(v, k)); },
14393
+ del: async (k) => { await run("readwrite", (s) => s.delete(k)); },
14394
+ };
14395
+ }
14396
+
14397
+ /** One per Client: shared by every Space (and, in sub-project 4, DMs). */
14398
+ class TrustContext {
14399
+ deps;
14400
+ directory;
14401
+ signer;
14402
+ pins;
14403
+ storeP = null;
14404
+ opened = null;
14405
+ appIdP = null;
14406
+ constructor(deps) {
14407
+ this.deps = deps;
14408
+ this.directory = new IdentityDirectory(deps.http);
14409
+ this.signer = new SeedIdentitySigner({ session: deps.session, directory: this.directory });
14410
+ this.pins = new PinStore({
14411
+ http: deps.http,
14412
+ commitment: () => deps.session.commitment,
14413
+ signer: this.signer,
14414
+ store: () => this.store(),
14415
+ });
14416
+ // Start opening muhkoo.trust now, so `persistent` has settled by the time an app asks (KI1).
14417
+ // A failed open reaches whoever awaits store(); it is not an unhandled rejection here.
14418
+ this.store().catch(() => undefined);
14419
+ }
14420
+ /** Whether muhkoo.trust survives a restart (KI1): false until the open settles, and false for an in-memory store. */
14421
+ get persistent() {
14422
+ return this.opened?.persistent ?? false;
14423
+ }
14424
+ store() {
14425
+ return (this.storeP ??= (this.deps.openStore ?? openTrustStore)().then((s) => (this.opened = s)));
14426
+ }
14427
+ /** This app's id, from GET /api/app/trust (a genesis must name it, D4). Cached; a failure is retried next call. */
14428
+ appId() {
14429
+ return (this.appIdP ??= this.deps.http
14430
+ .get("/api/app/trust")
14431
+ .then((r) => {
14432
+ if (!r?.appId)
14433
+ throw new Error("app trust: no appId");
14434
+ return r.appId;
14435
+ })
14436
+ .catch((e) => {
14437
+ this.appIdP = null;
14438
+ throw e;
14439
+ }));
14440
+ }
14441
+ /** User-confirmed "Reset trust for this space" (design §9.1): drop its genesis pin and log high-water. */
14442
+ async resetSpacePins(spaceId) {
14443
+ await this.pins.unpin("spaces", [await spacePinKey(spaceId)]);
14444
+ await (await this.store()).del(`loghw:${this.deps.session.commitment}:${spaceId}`);
14445
+ }
14446
+ /** `client.trust.unpin(username)`: forget a member's pinned identity after comparing safety numbers (design §3.4). */
14447
+ async unpinUser(username) {
14448
+ await this.pins.unpin("identity", [username.toLowerCase()]);
14449
+ }
14450
+ }
14451
+
12509
14452
  /**
12510
14453
  * Absolute POSIX paths for the VFS.
12511
14454
  *
@@ -13515,6 +15458,18 @@ class VfsNamespace {
13515
15458
  * than by TTL — a stale directory listing shows files that are not there.
13516
15459
  */
13517
15460
  cache = new Map();
15461
+ /**
15462
+ * The write of each directory record still in flight, by id.
15463
+ *
15464
+ * Two writes of one record must land in the order they were made. The
15465
+ * personal space has no compare-and-swap: of two writes of one key that
15466
+ * are in flight together it keeps whichever request finishes last, and
15467
+ * that is often the one sent first. Then the earlier write, sealed before
15468
+ * the later edit was made, undoes it. That is how a file deleted forever
15469
+ * from the Trash, while the move that put it there was still being
15470
+ * written, came back. So each write waits for the one before it.
15471
+ */
15472
+ writing = new Map();
13518
15473
  historyLimit;
13519
15474
  maxEntries;
13520
15475
  maxPathChars;
@@ -13978,10 +15933,7 @@ class VfsNamespace {
13978
15933
  const parentPath = dirname(target);
13979
15934
  await this.mkdir(parentPath, { recursive: true });
13980
15935
  const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
13981
- const { manifest, size } = await this.deps.content.write(bytes, {
13982
- name,
13983
- type: opts.type ?? contentTypeFor(name),
13984
- });
15936
+ const { manifest, size } = await this.deps.content.write(bytes, { name, type: opts.type ?? contentTypeFor(name) }, { onProgress: opts.onProgress });
13985
15937
  const parent = await this.openDir(parentPath);
13986
15938
  const prior = entryIn(parent.node, name);
13987
15939
  if (prior?.kind === "dir")
@@ -14539,24 +16491,14 @@ class VfsNamespace {
14539
16491
  if (Object.hasOwn(dir.node.entries, name))
14540
16492
  delete dir.hidden[name];
14541
16493
  }
14542
- const stored = Object.keys(dir.hidden).length
14543
- ? { ...dir.node, entries: Object.assign(Object.create(null), dir.hidden, dir.node.entries) }
14544
- : dir.node;
16494
+ // Not sent until any earlier write of this record has landed: see
16495
+ // `writing`. Whether that one worked or not, this one still goes.
16496
+ const before = this.writing.get(dir.id);
16497
+ const send = () => this.writeRecord(dir);
16498
+ const write = before ? before.then(send, send) : send();
16499
+ this.writing.set(dir.id, write);
14545
16500
  try {
14546
- let sealed;
14547
- try {
14548
- sealed = await seal(dir.key, stored);
14549
- }
14550
- catch (err) {
14551
- // JSON.stringify's own failures: a value too deeply nested to
14552
- // write. Reading drops those from a stored record, so this is
14553
- // what the write itself was given.
14554
- if (!(err instanceof RangeError || err instanceof TypeError))
14555
- throw err;
14556
- const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
14557
- throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
14558
- }
14559
- await this.deps.store.put(dirKey(dir.id), sealed);
16501
+ await write;
14560
16502
  }
14561
16503
  catch (err) {
14562
16504
  // The edit above was made in place, on the very object the cache
@@ -14567,8 +16509,38 @@ class VfsNamespace {
14567
16509
  this.cache.delete(dir.id);
14568
16510
  throw err;
14569
16511
  }
16512
+ finally {
16513
+ if (this.writing.get(dir.id) === write)
16514
+ this.writing.delete(dir.id);
16515
+ }
14570
16516
  this.cache.set(dir.id, Promise.resolve(dir));
14571
16517
  }
16518
+ /**
16519
+ * Seal a directory record as it stands NOW and put it in the store.
16520
+ *
16521
+ * Read at the moment of sending, not when the edit was made, so a write
16522
+ * that waited behind another carries every edit made to the record by
16523
+ * then, its own included.
16524
+ */
16525
+ async writeRecord(dir) {
16526
+ const stored = Object.keys(dir.hidden).length
16527
+ ? { ...dir.node, entries: Object.assign(Object.create(null), dir.hidden, dir.node.entries) }
16528
+ : dir.node;
16529
+ let sealed;
16530
+ try {
16531
+ sealed = await seal(dir.key, stored);
16532
+ }
16533
+ catch (err) {
16534
+ // JSON.stringify's own failures: a value too deeply nested to
16535
+ // write. Reading drops those from a stored record, so this is
16536
+ // what the write itself was given.
16537
+ if (!(err instanceof RangeError || err instanceof TypeError))
16538
+ throw err;
16539
+ const where = dir.path ?? `the directory stored as record ${printable(dir.id, 40)}`;
16540
+ throw new VfsConflictError(`cannot write to ${where}: it would hold a value nested too deeply to store`);
16541
+ }
16542
+ await this.deps.store.put(dirKey(dir.id), sealed);
16543
+ }
14572
16544
  async createDir(parentPath, name) {
14573
16545
  assertValidName(name);
14574
16546
  const parent = await this.openDir(parentPath);
@@ -16229,112 +18201,6 @@ class VcsNamespace {
16229
18201
  }
16230
18202
  }
16231
18203
 
16232
- /**
16233
- * `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
16234
- * keypair (the connect `KeyStore` entry keyed by the member id / username).
16235
- *
16236
- * Historically each app hand-rolled this (see the web app's old
16237
- * `personal/spaceLoader.ts`): generate a keypair at register, seed-wrap it, and
16238
- * PUT it to the personal space; on login GET + unwrap + hydrate the `KeyStore`.
16239
- * That scaffolding now lives here so ANY app gets a **stable** member keypair
16240
- * across reloads for free — without which the member re-admits to every space on
16241
- * every load and the group-key cache can never round-trip.
16242
- *
16243
- * Security invariant: the wrapping secret is the **master seed** (not the
16244
- * password), so the blob survives password changes and unlocks under a passkey.
16245
- * The seed is only ever held in memory (never persisted), so this vault can only
16246
- * provision/rehydrate while the client is unlocked.
16247
- */
16248
- /** Staging-only diagnostics: is the member keypair stable across reloads? */
16249
- const VAULT_DEBUG = (() => {
16250
- try {
16251
- return typeof location !== "undefined" && /(^|\.)staging\./.test(location.hostname);
16252
- }
16253
- catch {
16254
- return false;
16255
- }
16256
- })();
16257
- function vlog(msg) {
16258
- if (VAULT_DEBUG)
16259
- console.info(`[muhkoo:vault] ${msg}`);
16260
- }
16261
- /** A short, stable fingerprint of the member's current keypair (ecdsa pub). */
16262
- async function fingerprint(memberId) {
16263
- try {
16264
- const raw = await KeyStore.getInstance().getRawEcdsaPublicKey(memberId);
16265
- if (!raw)
16266
- return "none";
16267
- let s = 0;
16268
- for (const b of raw)
16269
- s = (s * 31 + b) >>> 0;
16270
- return s.toString(16).padStart(8, "0");
16271
- }
16272
- catch {
16273
- return "err";
16274
- }
16275
- }
16276
- /** Personal-space key holding the seed-wrapped, dehydrated ratchet keypair. */
16277
- const CHAT_KEYS_KEY = "chat-keys";
16278
- class ChatKeyVault {
16279
- store;
16280
- constructor(store) {
16281
- this.store = store;
16282
- }
16283
- /**
16284
- * Register-time: ensure a keypair exists for `memberId`, seed-wrap the
16285
- * dehydrated form, and persist it to the personal space. Idempotent — reuses
16286
- * an existing in-tab keypair rather than minting a second one.
16287
- */
16288
- async provision(memberId, seedSecret) {
16289
- const ks = KeyStore.getInstance();
16290
- if (!ks.getKeyPair(memberId)) {
16291
- try {
16292
- await ks.generateOwnKeyPair(memberId);
16293
- }
16294
- catch (e) {
16295
- if (!String(e?.message ?? e).includes("already exists"))
16296
- throw e;
16297
- }
16298
- }
16299
- const dehydrated = await ks.dehydrateKeyPair(memberId);
16300
- const plaintext = new TextEncoder().encode(JSON.stringify(dehydrated));
16301
- await this.store.put(CHAT_KEYS_KEY, await wrapWithPassphrase(seedSecret, plaintext));
16302
- vlog(`provision: minted+persisted keypair fp=${await fingerprint(memberId)}`);
16303
- }
16304
- /**
16305
- * Sign-in / unlock time: fetch the wrapped blob, unwrap with the seed, and
16306
- * hydrate the `KeyStore` — restoring the SAME keypair the member had before.
16307
- * Returns `true` when a keypair is now in hand (already present or hydrated),
16308
- * `false` when there's no blob yet (a brand-new member → caller provisions).
16309
- * Throws only on a genuine unwrap failure (wrong secret / tampered blob).
16310
- */
16311
- async rehydrate(memberId, seedSecret) {
16312
- // Already unlocked in this tab — nothing to do.
16313
- if (KeyStore.getInstance().getKeyPair(memberId)?.privateKey)
16314
- return true;
16315
- const wrapped = await this.store.get(CHAT_KEYS_KEY);
16316
- if (!wrapped) {
16317
- vlog("rehydrate: no chat-keys blob (new member → will provision)");
16318
- return false;
16319
- }
16320
- const bytes = await unwrapWithPassphrase(seedSecret, wrapped);
16321
- const dehydrated = JSON.parse(new TextDecoder().decode(bytes));
16322
- await KeyStore.getInstance().hydrateKeyPair(memberId, dehydrated);
16323
- vlog(`rehydrate: HIT — restored stable keypair fp=${await fingerprint(memberId)}`);
16324
- return true;
16325
- }
16326
- /**
16327
- * Ensure a stable keypair is in hand: rehydrate from the vault if a blob
16328
- * exists, otherwise mint one and persist it (so it's stable next session).
16329
- * Used at register and lazily when a space is first opened.
16330
- */
16331
- async ensure(memberId, seedSecret) {
16332
- if (await this.rehydrate(memberId, seedSecret))
16333
- return;
16334
- await this.provision(memberId, seedSecret);
16335
- }
16336
- }
16337
-
16338
18204
  /**
16339
18205
  * `HlcTimestamp` — a Hybrid Logical Clock reading, packed into a single
16340
18206
  * lexicographically-sortable string so the whole offline layer (and the
@@ -16612,8 +18478,9 @@ class ConnectivityManager {
16612
18478
  * mutation by re-invoking the real namespace method (with an `isReplay` flag so
16613
18479
  * it doesn't re-enqueue). `drain()` replays entries in HLC order:
16614
18480
  * - success → remove the entry;
16615
- * - permanent failure (4xx) → drop it and surface a `SYNC_ERROR` (a write the
16616
- * server will never accept must not wedge the queue forever);
18481
+ * - permanent failure (4xx, or a Space whose member list refuses this user)
18482
+ * → drop it and surface a `SYNC_ERROR` (a write the server will never
18483
+ * accept must not wedge the queue forever);
16617
18484
  * - transient/network failure → stop draining and leave the rest for the next
16618
18485
  * reconnect.
16619
18486
  *
@@ -16688,9 +18555,13 @@ class OutboundQueue {
16688
18555
  }
16689
18556
  }
16690
18557
  }
16691
- /** A 4xx is the server permanently refusing the write; anything else is worth
16692
- * retrying on the next reconnect. */
18558
+ /** A 4xx is the server permanently refusing the write, and so is a Space whose
18559
+ * member list does not admit this user (design §4.5.12): the relay refuses
18560
+ * their frames. Anything else, a locked session included, is worth retrying
18561
+ * on the next reconnect. */
16693
18562
  function isPermanent(err) {
18563
+ if (err instanceof AdmissionDeniedError)
18564
+ return true;
16694
18565
  return err instanceof HttpError && err.status >= 400 && err.status < 500;
16695
18566
  }
16696
18567
 
@@ -17478,6 +19349,8 @@ class Client {
17478
19349
  message;
17479
19350
  /** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
17480
19351
  space;
19352
+ /** Trust records shared by every Space: pins and per-Space log heads (design §3.4, §6.9). */
19353
+ trust;
17481
19354
  /** Programmable Agents — `client.agents.create(appId, …)`, etc. */
17482
19355
  agents;
17483
19356
  /** Serverless functions — `client.functions.deploy(appId, …)`, etc. */
@@ -17617,7 +19490,7 @@ class Client {
17617
19490
  contentSpace = (async () => {
17618
19491
  const existing = await personalStore.get(VFS_SPACE_KEY);
17619
19492
  if (typeof existing === "string" && existing)
17620
- return this.space.get(existing);
19493
+ return this.space.get(existing, "static", { messaging: false });
17621
19494
  // `messaging: false` — this space only ever holds files, and
17622
19495
  // file chunks carry their own keys, so it needs no group
17623
19496
  // keyring and no socket. Creating it the normal way opens a
@@ -17644,9 +19517,9 @@ class Client {
17644
19517
  // GC. `storage.writeFile` would additionally index the manifest
17645
19518
  // in the space — a second, diverging source of truth for a file
17646
19519
  // whose handle the VFS already holds.
17647
- write: async (data, meta) => {
19520
+ write: async (data, meta, opts) => {
17648
19521
  const space = await resolveContentSpace();
17649
- const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type });
19522
+ const { manifest, stat } = await space.putFile(data, { name: meta.name, type: meta.type }, { onProgress: opts?.onProgress });
17650
19523
  return { manifest, size: stat.size };
17651
19524
  },
17652
19525
  // Reads go through the manifest alone — no space needed. That is
@@ -17704,6 +19577,16 @@ class Client {
17704
19577
  },
17705
19578
  };
17706
19579
  const chatKeys = new ChatKeyVault(chatKeyStore);
19580
+ // Shared by every Space (and, in sub-project 4, DMs): the account's
19581
+ // identity signer, the identity directory, and the pin/log-head store
19582
+ // (design §3.4, §6.9). Built before `SpaceNamespace` so it can hand the
19583
+ // namespace a reference to wire into each Space it builds.
19584
+ const trustContext = new TrustContext({ http: this.http, session: this.session });
19585
+ this.trust = {
19586
+ get persistent() { return trustContext.persistent; },
19587
+ unpin: (u) => trustContext.unpinUser(u),
19588
+ resetSpacePins: (id) => trustContext.resetSpacePins(id),
19589
+ };
17707
19590
  this.space = new SpaceNamespace({
17708
19591
  http: this.http,
17709
19592
  session: this.session,
@@ -17712,6 +19595,7 @@ class Client {
17712
19595
  offline: this.offline,
17713
19596
  p2p: options.p2p,
17714
19597
  chatKeys,
19598
+ trust: trustContext,
17715
19599
  });
17716
19600
  this.agents = new AgentsNamespace({ http: this.http });
17717
19601
  this.functions = new FunctionsNamespace({ http: this.http, fnHostSuffix: options.functionsHostSuffix });
@@ -18099,7 +19983,7 @@ class DoubleRatchetCipher {
18099
19983
  if (!sessionId) {
18100
19984
  throw new Error("DoubleRatchetCipher: no active session to seal under");
18101
19985
  }
18102
- const cipherMessage = await this.deps.ratchetManager.encrypt(this.deps.clientId, this.deps.serverId, sessionId, serializedMessage, false, // newDhKey — let the ratchet manage key rotation
19986
+ const cipherMessage = await this.deps.ratchetManager.encrypt(this.deps.clientId, this.deps.serverId, sessionId, serializedMessage, false, // newDhKey — rotation is disabled until the key-schedule rewrite (audit H1)
18103
19987
  this.deps.sessionType);
18104
19988
  return { encrypted: true, cipherMessage: JSON.stringify(cipherMessage) };
18105
19989
  }
@@ -18135,5 +20019,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
18135
20019
  default: loadRsWasm
18136
20020
  });
18137
20021
 
18138
- export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
20022
+ export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES$1 as AES_GCM_TAG_BYTES, AccessTokensNamespace, AdmissionDeniedError, AgentsNamespace, AuthClient, AuthHttpError, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, CircuitIntegrityError, Client, ConnectivityManager, DEFAULT_AUTH_BASE_URL, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceRevokedError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IdentityLockedError, IdentityUnavailableError, IndexedDbStore, KEEPER_MEMBER_ID$1 as KEEPER_MEMBER_ID, KeyStore, KeyringClient, KeyringHttpError, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NOT_MEMBER_CLOSE_CODE, NOT_MEMBER_RECHECK_MS, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PINNED_CIRCUIT_DIGESTS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, ROOT_ID, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, SpaceTrustError, StorageNamespace, SyncEngine, VERSION, VFS_ROOT_INFO, VaultProtocolMismatchError, VaultUnavailableError, VcsError, VcsNamespace, VfsConflictError, VfsCorruptRecordError, VfsLimitError, VfsLockedError, VfsNamespace, VfsNotFoundError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes$1 as concatBytes, configureDeviceStore, createPasskeyWithPrf, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultRpId, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, dirKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, evaluatePasskeyPrf, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, headKey, historyKey, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, isSameOriginPath, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeKey, mergeMap, mergeMessage, mergeRegister, messageKey, objectKey, pack, padHandle, pairingVerificationCode, passkeyPrfCapable, passkeySupported, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, refKey, refsIndexKey, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
18139
20023
  //# sourceMappingURL=index.js.map