@cello-protocol/client 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -110,9 +110,11 @@ import { createHash } from "node:crypto";
110
110
  import { Encoder, decode } from "cbor-x";
111
111
  import * as lp from "it-length-prefixed";
112
112
  import { buildEnvelope, serializeEnvelope, deserializeEnvelope, validateEnvelope, computeGenesisPrevRoot, encodeSealPayload, buildSessionEstablishmentTbs, buildSealTbs, decodeConnectionPackage, validateConnectionPackage, } from "@cello-protocol/protocol-types";
113
- import { verify, buildMerkleTree, merkleRoot, verifyFrostSignature, CONTEXT_SESSION_ESTABLISHMENT, mlDsaKeygen, mlDsaVerify, FileMlDsaKeyProvider } from "@cello-protocol/crypto";
113
+ import { verify, buildMerkleTree, merkleRoot, verifyFrostSignature, CONTEXT_SESSION_ESTABLISHMENT, mlDsaKeygen, mlDsaKeygenWithBytes, mlDsaVerify, FileMlDsaKeyProvider, InMemoryMlDsaKeyProvider } from "@cello-protocol/crypto";
114
+ import { storeDkgResult } from "@cello-protocol/crypto/frost/frost-threshold-signer.js";
114
115
  import { CELLO_PROTOCOL_ID, CELLO_CONTENT_PROTOCOL_ID } from "@cello-protocol/transport";
115
116
  import { NetworkDirectoryNode, runNetworkDkg } from "./network-directory-node.js";
117
+ import { FrostThresholdSigner } from "@cello-protocol/crypto/frost/frost-threshold-signer.js";
116
118
  const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
117
119
  const AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
118
120
  const SIGNALING_PROTOCOL_ID = "/cello/signaling/1.0.0";
@@ -362,7 +364,22 @@ class CelloClientImpl {
362
364
  #decidedRequests = new Set();
363
365
  // ─── PERSIST-014: Logger (injected, defaults to no-op) ───────────────────────
364
366
  #logger;
365
- constructor(node, keyProvider, onMessageQueued, contentGraceMs = 30_000, reconnectTimeoutMs = DEFAULT_RECONNECT_TIMEOUT_MS, thresholdSigner, sealFrostTimeoutMs = DEFAULT_SEAL_FROST_TIMEOUT_MS, directoryEndpoint = null, mlDsaKeyFile, connectionPolicy, connectionTimeoutMs = 300_000, round2TimeoutMs = 120_000, trackEvaluateCount = false, whitelist = [], onConnectionPendingReview, crossCheckDirectoryOnInbound = false, logger) {
367
+ // ─── PERSIST-024: Client state persistence (optional) ────────────────────────
368
+ #persistence = null;
369
+ // ─── PERSIST-024 AC-008: Hash queue for relay resubmission (optional) ────────
370
+ // Set by the composition root via setHashQueue() after loadPersistedState().
371
+ // Populated from pending_hashes DB table. Resubmission occurs on relay reconnect.
372
+ #hashQueue = null;
373
+ // ─── PERSIST-024: In-memory endorsement/attestation caches ───────────────────
374
+ #endorsements = [];
375
+ #attestations = [];
376
+ // ─── PERSIST-024 FINDING-4: pending hashes loaded on startup ────────────────
377
+ // These are populated by loadPersistedState() from the DB pending_hashes table.
378
+ // The caller (composition root) is responsible for passing them to AgentHashQueue
379
+ // so they are resubmitted to the relay on reconnect (AC-008).
380
+ // Access via getLoadedPendingHashes() after loadPersistedState() returns.
381
+ #loadedPendingHashes = [];
382
+ constructor(node, keyProvider, onMessageQueued, contentGraceMs = 30_000, reconnectTimeoutMs = DEFAULT_RECONNECT_TIMEOUT_MS, thresholdSigner, sealFrostTimeoutMs = DEFAULT_SEAL_FROST_TIMEOUT_MS, directoryEndpoint = null, mlDsaKeyFile, connectionPolicy, connectionTimeoutMs = 300_000, round2TimeoutMs = 120_000, trackEvaluateCount = false, whitelist = [], onConnectionPendingReview, crossCheckDirectoryOnInbound = false, logger, persistence) {
366
383
  this.#node = node;
367
384
  this.#keyProvider = keyProvider;
368
385
  this.#onMessageQueued = onMessageQueued;
@@ -380,6 +397,7 @@ class CelloClientImpl {
380
397
  this.#onConnectionPendingReview = onConnectionPendingReview;
381
398
  this.#crossCheckDirectoryOnInbound = crossCheckDirectoryOnInbound;
382
399
  this.#logger = logger ?? { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
400
+ this.#persistence = persistence ?? null;
383
401
  }
384
402
  /**
385
403
  * SESSION-005: Set the FROST primary_pubkey for this client.
@@ -390,8 +408,386 @@ class CelloClientImpl {
390
408
  setPrimaryPubkey(primaryPubkey) {
391
409
  this.#myPrimaryPubkey = new Uint8Array(primaryPubkey);
392
410
  }
411
+ /**
412
+ * PERSIST-024: Load all durable state from the SQLCipher DB and populate in-memory state.
413
+ *
414
+ * Pseudocode:
415
+ * 1. Call persistence.loadStartupState() — emits client.startup.state.loaded
416
+ * 2. Restore FROST key share → call storeDkgResult to populate module-level key store;
417
+ * reconstruct FrostThresholdSigner with same config used at registration
418
+ * 3. Restore ML-DSA keypair → reconstruct InMemoryMlDsaKeyProvider
419
+ * 4. Restore registration state → populate #registrationState
420
+ * 5. Restore connections → populate #connections and #connectionsByPeer
421
+ * 6. Restore connection policy → populate #connectionPolicy
422
+ * 7. Restore sessions → populate #sessions; load leaves per session
423
+ * 8. Restore peers → populate #peers
424
+ * 9. Restore decided requests → populate #decidedRequests
425
+ *
426
+ * Security invariants:
427
+ * SI-001: signing_share bytes never appear in any log event.
428
+ * SI-002: secret_key_blob bytes never appear in any log event.
429
+ *
430
+ * Crypto refs: RFC 9591 (FROST), NIST FIPS 204 (ML-DSA-44)
431
+ */
432
+ async loadPersistedState() {
433
+ const p = this.#persistence;
434
+ if (!p)
435
+ return;
436
+ const state = await p.loadStartupState();
437
+ // ── 1. FROST key share ────────────────────────────────────────────────────
438
+ if (state.frostShare) {
439
+ const row = state.frostShare;
440
+ // Reconstruct FrostSecret: { identifier, signingShare }
441
+ const signingShareBytes = row.signing_share instanceof Buffer
442
+ ? new Uint8Array(row.signing_share)
443
+ : new Uint8Array(row.signing_share);
444
+ const frostSecret = { identifier: row.identifier, signingShare: signingShareBytes };
445
+ // Reconstruct FrostPublic: { signers, commitments, verifyingShares }
446
+ // commitments_cbor is CBOR-encoded Uint8Array[]; verifying_shares_cbor is CBOR-encoded Record<string, Uint8Array>
447
+ let commitments = [];
448
+ let verifyingShares = {};
449
+ try {
450
+ const commitmentsCborBytes = row.commitments_cbor instanceof Buffer
451
+ ? row.commitments_cbor
452
+ : Buffer.from(row.commitments_cbor);
453
+ const decodedCommitments = decode(commitmentsCborBytes);
454
+ if (Array.isArray(decodedCommitments)) {
455
+ commitments = decodedCommitments.map((c) => c instanceof Uint8Array ? c : Buffer.isBuffer(c) ? new Uint8Array(c) : new Uint8Array(0));
456
+ }
457
+ const verifyingSharesCborBytes = row.verifying_shares_cbor instanceof Buffer
458
+ ? row.verifying_shares_cbor
459
+ : Buffer.from(row.verifying_shares_cbor);
460
+ const decodedVerifyingShares = decode(verifyingSharesCborBytes);
461
+ if (decodedVerifyingShares && typeof decodedVerifyingShares === "object" && !Array.isArray(decodedVerifyingShares)) {
462
+ for (const [k, v] of Object.entries(decodedVerifyingShares)) {
463
+ verifyingShares[k] = v instanceof Uint8Array ? v : Buffer.isBuffer(v) ? new Uint8Array(v) : new Uint8Array(0);
464
+ }
465
+ }
466
+ }
467
+ catch {
468
+ // HIGH-2: emit correct error event and return early — do not call storeDkgResult
469
+ // with broken/empty data, which would corrupt the module-level key store.
470
+ this.#logger.error("client.frost.share.load.failed", {
471
+ agentPubkey: row.agent_pubkey,
472
+ reason: "cbor_deserialize_failed",
473
+ });
474
+ return;
475
+ }
476
+ const frostPublic = {
477
+ signers: { min: row.threshold, max: row.participants },
478
+ commitments,
479
+ verifyingShares,
480
+ };
481
+ const myPubkeyHex = row.agent_pubkey;
482
+ try {
483
+ // SI-001: storeDkgResult does not log the secret
484
+ storeDkgResult(myPubkeyHex, frostSecret, frostPublic);
485
+ }
486
+ catch {
487
+ this.#logger.error("client.frost.share.load.failed", {
488
+ agentPubkey: myPubkeyHex,
489
+ reason: "storeDkgResult_failed",
490
+ });
491
+ return;
492
+ }
493
+ // Reconstruct FrostThresholdSigner (config only — secret is in module-level store).
494
+ // HIGH-1: directoryEndpoint is NOT required for construction — the signer can verify
495
+ // signatures even without a live directory. Pass undefined for directoryNodes.
496
+ if (!this.#thresholdSigner) {
497
+ this.#thresholdSigner = new FrostThresholdSigner({
498
+ threshold: row.threshold,
499
+ participants: row.participants,
500
+ directoryNodes: undefined,
501
+ }, Buffer.from(myPubkeyHex, "hex"));
502
+ }
503
+ // Set primary_pubkey from stored commitments[0]
504
+ if (commitments.length > 0 && !this.#myPrimaryPubkey) {
505
+ this.#myPrimaryPubkey = new Uint8Array(commitments[0]);
506
+ }
507
+ // HIGH-1: emit success event after FROST share loaded
508
+ this.#logger.info("client.frost.share.loaded", {
509
+ agentPubkey: myPubkeyHex,
510
+ epochId: row.epoch_id,
511
+ threshold: row.threshold,
512
+ participants: row.participants,
513
+ });
514
+ }
515
+ // HIGH-3: emit alarm when registration exists but no FROST share found
516
+ if (!state.frostShare && state.registrationState) {
517
+ this.#logger.error("client.frost.share.missing", {
518
+ agentPubkey: state.registrationState.agent_pubkey,
519
+ reason: "no_active_share_in_db",
520
+ });
521
+ }
522
+ // ── 2. ML-DSA keypair ─────────────────────────────────────────────────────
523
+ if (state.mlDsaKeypair) {
524
+ const row = state.mlDsaKeypair;
525
+ try {
526
+ const secretKeyBlob = row.secret_key_blob instanceof Buffer
527
+ ? new Uint8Array(row.secret_key_blob)
528
+ : new Uint8Array(row.secret_key_blob);
529
+ const mlDsaPubkeyBytes = Buffer.from(row.ml_dsa_pubkey, "hex");
530
+ // SI-002: InMemoryMlDsaKeyProvider does not log secret key
531
+ this.#mlDsaProvider = new InMemoryMlDsaKeyProvider(mlDsaPubkeyBytes, secretKeyBlob);
532
+ // HIGH-1: emit success event after ML-DSA keypair loaded
533
+ this.#logger.info("client.mldsa.keypair.loaded", {
534
+ agentPubkey: row.agent_pubkey,
535
+ mlDsaPubkey: row.ml_dsa_pubkey,
536
+ });
537
+ }
538
+ catch (err) {
539
+ // Story specifies level: error for this event
540
+ this.#logger.error("client.mldsa.load.failed", {
541
+ agentPubkey: row.agent_pubkey,
542
+ reason: err instanceof Error ? err.message : String(err),
543
+ });
544
+ }
545
+ }
546
+ // ── 3. Registration state ─────────────────────────────────────────────────
547
+ if (state.registrationState) {
548
+ const row = state.registrationState;
549
+ this.#registrationState = {
550
+ agent_id: row.agent_id,
551
+ primary_pubkey: row.primary_pubkey,
552
+ ml_dsa_pubkey: row.ml_dsa_pubkey,
553
+ registered_at: row.registered_at,
554
+ status: "active",
555
+ };
556
+ // HIGH-1: emit success event after registration state loaded
557
+ this.#logger.info("client.registration.loaded", {
558
+ agentPubkey: row.agent_pubkey,
559
+ agentId: row.agent_id,
560
+ status: row.status,
561
+ });
562
+ }
563
+ // ── 4. Connections ────────────────────────────────────────────────────────
564
+ for (const row of state.connections) {
565
+ if (!this.#connections.has(row.connection_id)) {
566
+ const record = {
567
+ connection_id: row.connection_id,
568
+ counterparty_primary_pubkey: row.counterparty_primary_pubkey ?? "",
569
+ counterparty_ml_dsa_pubkey: row.counterparty_ml_dsa_pubkey ?? "",
570
+ counterparty_pubkey: row.counterparty_pubkey,
571
+ established_at: row.established_at,
572
+ status: row.status,
573
+ };
574
+ this.#connections.set(row.connection_id, record);
575
+ this.#connectionsByPeer.set(row.counterparty_pubkey, row.connection_id);
576
+ if (row.profile_unchecked) {
577
+ this.#profileUncheckedPeers.add(row.counterparty_pubkey);
578
+ }
579
+ }
580
+ }
581
+ // ── 5. Connection policy ──────────────────────────────────────────────────
582
+ if (state.connectionPolicy) {
583
+ this.#connectionPolicy = state.connectionPolicy;
584
+ }
585
+ // ── 6. Sessions + leaves ──────────────────────────────────────────────────
586
+ for (const row of state.sessions) {
587
+ const sessionIdHex = row.session_id;
588
+ if (this.#sessions.has(sessionIdHex))
589
+ continue;
590
+ const leaves = await p.loadSessionTreeLeaves(sessionIdHex);
591
+ const localTreeLeaves = leaves.map((l) => ({
592
+ kind: l.leaf_kind,
593
+ s2_cbor: l.s2_cbor instanceof Buffer
594
+ ? new Uint8Array(l.s2_cbor)
595
+ : new Uint8Array(l.s2_cbor),
596
+ }));
597
+ const counterpartyPubkey = row.counterparty_pubkey instanceof Buffer
598
+ ? new Uint8Array(row.counterparty_pubkey)
599
+ : new Uint8Array(row.counterparty_pubkey);
600
+ const directoryPubkey = row.directory_pubkey instanceof Buffer
601
+ ? new Uint8Array(row.directory_pubkey)
602
+ : new Uint8Array(row.directory_pubkey);
603
+ const genesisPrevRoot = row.genesis_prev_root instanceof Buffer
604
+ ? new Uint8Array(row.genesis_prev_root)
605
+ : new Uint8Array(row.genesis_prev_root);
606
+ let counterpartyMultiaddrs = [];
607
+ let relayMultiaddrs = [];
608
+ let directoryMultiaddrs = [];
609
+ try {
610
+ counterpartyMultiaddrs = JSON.parse(row.counterparty_multiaddrs);
611
+ }
612
+ catch { /* ignore */ }
613
+ try {
614
+ relayMultiaddrs = JSON.parse(row.relay_multiaddrs);
615
+ }
616
+ catch { /* ignore */ }
617
+ try {
618
+ directoryMultiaddrs = JSON.parse(row.directory_multiaddrs);
619
+ }
620
+ catch { /* ignore */ }
621
+ const record = {
622
+ session_id: Buffer.from(sessionIdHex, "hex"),
623
+ counterparty_pubkey: counterpartyPubkey,
624
+ counterparty_peer_id: row.counterparty_peer_id,
625
+ counterparty_multiaddrs: counterpartyMultiaddrs,
626
+ relay_endpoint: { peer_id: row.relay_peer_id, multiaddrs: relayMultiaddrs },
627
+ directory_endpoint: { peer_id: row.directory_peer_id, multiaddrs: directoryMultiaddrs },
628
+ directory_pubkey: directoryPubkey,
629
+ genesis_prev_root: genesisPrevRoot,
630
+ last_seen_seq: row.last_seen_seq,
631
+ last_sent_seq: row.last_sent_seq,
632
+ next_expected_seq: row.next_expected_seq,
633
+ status: row.status,
634
+ desynchronized: row.desynchronized !== 0,
635
+ local_tree_leaves: localTreeLeaves,
636
+ sealed_root: row.sealed_root
637
+ ? (row.sealed_root instanceof Buffer ? new Uint8Array(row.sealed_root) : new Uint8Array(row.sealed_root))
638
+ : undefined,
639
+ seal_type: row.seal_type ?? undefined,
640
+ close_timestamp: row.close_timestamp ?? undefined,
641
+ frost_signature: row.frost_signature
642
+ ? (row.frost_signature instanceof Buffer ? new Uint8Array(row.frost_signature) : new Uint8Array(row.frost_signature))
643
+ : undefined,
644
+ signer_pubkey: row.signer_pubkey
645
+ ? (row.signer_pubkey instanceof Buffer ? new Uint8Array(row.signer_pubkey) : new Uint8Array(row.signer_pubkey))
646
+ : undefined,
647
+ directory_signature: row.directory_signature
648
+ ? (row.directory_signature instanceof Buffer ? new Uint8Array(row.directory_signature) : new Uint8Array(row.directory_signature))
649
+ : undefined,
650
+ };
651
+ this.#sessions.set(sessionIdHex, record);
652
+ this.#sessionMessageQueues.set(sessionIdHex, []);
653
+ // HIGH-4: emit alarm when loaded leaf count doesn't match sessions.leaf_count
654
+ if (leaves.length !== row.leaf_count) {
655
+ this.#logger.error("client.session.leaves.mismatch", {
656
+ agentPubkey: row.agent_pubkey,
657
+ sessionId: sessionIdHex,
658
+ expectedLeafCount: row.leaf_count,
659
+ actualLeafCount: leaves.length,
660
+ });
661
+ }
662
+ }
663
+ // ── 7. Peers ──────────────────────────────────────────────────────────────
664
+ for (const row of state.peers) {
665
+ if (!this.#peers.has(row.peer_pubkey_hex)) {
666
+ let multiaddrs = [];
667
+ try {
668
+ multiaddrs = JSON.parse(row.multiaddrs);
669
+ }
670
+ catch { /* ignore */ }
671
+ this.#peers.set(row.peer_pubkey_hex, { peerId: row.peer_id, multiaddrs, connected: false });
672
+ }
673
+ }
674
+ // ── 8. Decided requests ───────────────────────────────────────────────────
675
+ for (const row of state.decidedRequests) {
676
+ this.#decidedRequests.add(row.request_id);
677
+ }
678
+ // ── 9. Pending connection requests ────────────────────────────────────────
679
+ for (const row of state.pendingConnectionRequests) {
680
+ const packageCbor = row.package_cbor instanceof Buffer
681
+ ? new Uint8Array(row.package_cbor)
682
+ : new Uint8Array(row.package_cbor);
683
+ // Populate #pendingInboundRequests so acceptConnection/rejectConnection work
684
+ this.#pendingInboundRequests.set(row.request_id, {
685
+ connection_request_id: row.request_id,
686
+ from_pubkey: row.from_pubkey,
687
+ package_cbor: packageCbor,
688
+ round: row.round,
689
+ });
690
+ // Populate #pendingReviewQueue so awaitConnectionRequest() returns it.
691
+ // Reconstruct a minimal pending_agent_review report — the full policy evaluation
692
+ // result is not persisted, only the package_cbor. The agent review UI only needs
693
+ // the connection_request_id, from_pubkey, and package_cbor to make a decision.
694
+ const restoredReport = {
695
+ verdict: "pending_agent_review",
696
+ policy_summary: {
697
+ mode: "unknown",
698
+ review_mode: "inference",
699
+ requirements_met: [],
700
+ requirements_unmet: [],
701
+ },
702
+ package_summary: {
703
+ pseudonym_label: "",
704
+ endorsement_count: 0,
705
+ attestation_types: [],
706
+ pseudonym_age_days: 0,
707
+ registration_age_days: 0,
708
+ is_provisional: false,
709
+ },
710
+ is_round_2: row.round > 1,
711
+ };
712
+ this.#pendingReviewQueue.push({
713
+ connection_request_id: row.request_id,
714
+ from_pubkey: row.from_pubkey,
715
+ report: restoredReport,
716
+ package_cbor: packageCbor,
717
+ sender_registered_at: 0,
718
+ sender_is_provisional: false,
719
+ });
720
+ }
721
+ // ── 10. Endorsements and attestations (MED-4) ────────────────────────────────
722
+ if (state.endorsements.length > 0) {
723
+ this.#endorsements = state.endorsements;
724
+ }
725
+ if (state.attestations.length > 0) {
726
+ this.#attestations = state.attestations;
727
+ }
728
+ // ── HIGH-3: set #myPubkeyHex from registration state if sessions were loaded ──
729
+ // #myPubkeyHex is used in #sendMessageLocked with a non-null assertion.
730
+ // It is set during receiveSessionAssignment but never set during startup load.
731
+ // If the agent restarts with active sessions and calls sendMessage before any
732
+ // new session assignment, it would crash on the non-null assertion.
733
+ if (!this.#myPubkeyHex && state.registrationState) {
734
+ this.#myPubkeyHex = state.registrationState.agent_pubkey;
735
+ }
736
+ // ── PERSIST-024 FINDING-4: store loaded pending hashes for caller consumption ──
737
+ // The caller (composition root) must pass these to AgentHashQueue so they are
738
+ // resubmitted to the relay on reconnect (AC-008). CelloClientImpl does not hold
739
+ // an AgentHashQueue directly — that is a composition-root concern.
740
+ this.#loadedPendingHashes = state.pendingHashes.map((row) => ({
741
+ sessionId: row.session_id,
742
+ hashHex: row.hash_hex,
743
+ enqueuedAt: row.enqueued_at,
744
+ }));
745
+ // M-5: upsertAgent at the END of startup so last_seen_at only reflects a fully successful boot.
746
+ await p.upsertAgent();
747
+ // PERSIST-024 FINDING-3: emit client.startup.state.loaded AFTER all in-memory structures
748
+ // are populated. AC-013 requires this event to fire only after the full startup sequence.
749
+ // The event is not emitted by loadStartupState() (which only loads DB rows).
750
+ this.#logger.info("client.startup.state.loaded", {
751
+ agentPubkey: this.#myPubkeyHex ?? state.registrationState?.agent_pubkey ?? "unknown",
752
+ connectionCount: state.connectionCount,
753
+ sessionCount: state.sessionCount,
754
+ leafCount: state.leafCount,
755
+ pendingHashCount: state.pendingHashCount,
756
+ hasFrostShare: state.hasFrostShare,
757
+ hasMlDsaKeypair: state.hasMlDsaKeypair,
758
+ hasRegistration: state.hasRegistration,
759
+ hasPolicy: state.hasPolicy,
760
+ });
761
+ }
762
+ /**
763
+ * PERSIST-024 FINDING-4: Return the pending hashes loaded during startup.
764
+ *
765
+ * Call after loadPersistedState() to retrieve hashes that were pending relay
766
+ * submission when the agent last shut down. The composition root must pass these
767
+ * to AgentHashQueue.enqueue() so they are resubmitted to the relay on reconnect.
768
+ *
769
+ * Returns an empty array if loadPersistedState() has not yet been called, if
770
+ * no persistence is configured, or if no hashes were pending.
771
+ */
772
+ getLoadedPendingHashes() {
773
+ return this.#loadedPendingHashes;
774
+ }
775
+ /**
776
+ * PERSIST-024 AC-008: Set the AgentHashQueue to use for relay resubmission.
777
+ *
778
+ * Called by the composition root after loadPersistedState() and after calling
779
+ * queue.loadPending(client.getLoadedPendingHashes()). On relay reconnect,
780
+ * #reconnectRelayStream will drain the queue for the reconnected session.
781
+ */
782
+ setHashQueue(queue) {
783
+ this.#hashQueue = queue;
784
+ }
393
785
  addPeer(peerPubkeyHex, peerId, multiaddrs) {
394
786
  this.#peers.set(peerPubkeyHex, { peerId, multiaddrs, connected: true });
787
+ // PERSIST-024: persist peer to DB
788
+ if (this.#persistence) {
789
+ void this.#persistence.persistPeer({ peerPubkeyHex, peerId, multiaddrs });
790
+ }
395
791
  }
396
792
  async send(peerPubkeyHex, content) {
397
793
  // Step 1: registry lookup
@@ -684,6 +1080,10 @@ class CelloClientImpl {
684
1080
  desynchronized: false,
685
1081
  };
686
1082
  this.#sessions.set(sessionIdHex, record);
1083
+ // PERSIST-024: persist session to DB
1084
+ if (this.#persistence) {
1085
+ void this.#persistence.persistSession(sessionIdHex, record);
1086
+ }
687
1087
  // Store the relay stream and start the persistent reader loop (MSG-004)
688
1088
  this.#relayStreams.set(sessionIdHex, relayStream);
689
1089
  this.#relayRecvSeq.set(sessionIdHex, 0);
@@ -780,6 +1180,13 @@ class CelloClientImpl {
780
1180
  content_bytes: content,
781
1181
  arrived_at: Date.now(),
782
1182
  });
1183
+ // PERSIST-024 AC-008: Persist pending hash BEFORE relay submission (SI-001 of PERSIST-012).
1184
+ // The entry is removed after the ACK is confirmed. This ensures crash recovery can detect
1185
+ // un-ACKed hashes and resubmit them on relay reconnect.
1186
+ const enqueuedAt = Date.now();
1187
+ if (this.#persistence) {
1188
+ void this.#persistence.persistPendingHash({ sessionId: sessionIdHex, hashHex: contentHashHex, enqueuedAt });
1189
+ }
783
1190
  // Set up ack resolver before sending to avoid race with fast relay.
784
1191
  // The outbound queue guarantees at most one in-flight send per session.
785
1192
  // Guard here so a queue bug causes an immediate throw rather than a silent orphan.
@@ -795,13 +1202,25 @@ class CelloClientImpl {
795
1202
  catch {
796
1203
  this.#pendingAckResolvers.delete(sessionIdHex);
797
1204
  this.#ownPendingContent.get(sessionIdHex)?.delete(contentHashHex);
1205
+ // Remove the pending hash entry — the submission never reached the relay
1206
+ if (this.#persistence) {
1207
+ void this.#persistence.removePendingHash(sessionIdHex, contentHashHex);
1208
+ }
798
1209
  return { ok: false, reason: "transport_unavailable" };
799
1210
  }
800
1211
  const ack = await ackPromise;
801
1212
  if (!ack.ok) {
1213
+ // Relay rejected — remove the pending hash entry
1214
+ if (this.#persistence) {
1215
+ void this.#persistence.removePendingHash(sessionIdHex, contentHashHex);
1216
+ }
802
1217
  return { ok: false, reason: "relay_rejected" };
803
1218
  }
804
1219
  const mySeq = ack.sequence_number;
1220
+ // PERSIST-024 AC-008: Relay ACKed — remove from pending_hashes (relay has stored the hash).
1221
+ if (this.#persistence) {
1222
+ void this.#persistence.removePendingHash(sessionIdHex, contentHashHex);
1223
+ }
805
1224
  // Send content to counterparty on /cello/content/1.0.0
806
1225
  // Best-effort: if content path fails, the receiver's 30s grace timer will desync
807
1226
  const sess2 = this.#sessions.get(sessionIdHex);
@@ -1033,6 +1452,8 @@ class CelloClientImpl {
1033
1452
  return { ok: false, reason: "session_not_active" };
1034
1453
  session.status = "sealing";
1035
1454
  this.#sealInitiatedSessions.add(sessionIdHex);
1455
+ // CRIT-1: persist sealing status
1456
+ void this.#persistence?.persistSession(sessionIdHex, session);
1036
1457
  const result = await this.#submitSealLeaf(sessionIdHex, session, "initiator");
1037
1458
  if (!result.ok)
1038
1459
  return result;
@@ -1061,6 +1482,8 @@ class CelloClientImpl {
1061
1482
  if (sealVerifiedEntry) {
1062
1483
  sess.close_timestamp = sealVerifiedEntry.timestamp;
1063
1484
  }
1485
+ // CRIT-1: persist seal_deferred status
1486
+ void this.#persistence?.persistSession(sessionIdHex, sess);
1064
1487
  }
1065
1488
  }
1066
1489
  return { ok: true };
@@ -1387,6 +1810,8 @@ class CelloClientImpl {
1387
1810
  session.seal_type = "frost";
1388
1811
  session.close_timestamp = closeTimestamp;
1389
1812
  this.#sealVerifiedData.delete(sessionIdHex);
1813
+ // CRIT-1: persist sealed state
1814
+ void this.#persistence?.persistSession(sessionIdHex, session);
1390
1815
  // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1391
1816
  this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
1392
1817
  }
@@ -1411,6 +1836,8 @@ class CelloClientImpl {
1411
1836
  session.sealed_root = sealedRoot;
1412
1837
  session.directory_signature = dirSig;
1413
1838
  session.close_timestamp = closeTimestamp;
1839
+ // CRIT-1: persist sealed state
1840
+ void this.#persistence?.persistSession(sessionIdHex, session);
1414
1841
  // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1415
1842
  this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, closeTimestamp);
1416
1843
  }
@@ -1480,6 +1907,8 @@ class CelloClientImpl {
1480
1907
  session.seal_type = "frost";
1481
1908
  session.close_timestamp = resolvedCloseTimestamp;
1482
1909
  this.#sealVerifiedData.delete(sessionIdHex);
1910
+ // CRIT-1: persist sealed state
1911
+ void this.#persistence?.persistSession(sessionIdHex, session);
1483
1912
  // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1484
1913
  this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, resolvedCloseTimestamp);
1485
1914
  // Resolve the seal-frost-timeout waiter so initiateSessionSeal returns promptly
@@ -1490,6 +1919,7 @@ class CelloClientImpl {
1490
1919
  if (!session)
1491
1920
  return;
1492
1921
  session.status = "seal_rejected";
1922
+ void this.#persistence?.persistSession(sessionIdHex, session);
1493
1923
  // Also resolve the seal-frost-timeout waiter so initiateSessionSeal doesn't wait for the timeout
1494
1924
  this.#sealFrostResolvers.get(sessionIdHex)?.();
1495
1925
  }
@@ -1667,6 +2097,8 @@ class CelloClientImpl {
1667
2097
  session.sealed_root = sealedRoot;
1668
2098
  session.seal_type = "unilateral";
1669
2099
  session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
2100
+ // CRIT-1: persist sealed state
2101
+ void this.#persistence?.persistSession(sessionIdHex, session);
1670
2102
  const correlationId = Buffer.from(session.session_id).toString("hex");
1671
2103
  this.#logger.info("session.sealed", {
1672
2104
  sessionId: sessionIdHex,
@@ -1731,6 +2163,8 @@ class CelloClientImpl {
1731
2163
  session.sealed_root = sealedRoot;
1732
2164
  session.seal_type = "unilateral";
1733
2165
  session.close_timestamp = typeof frame["sealed_at"] === "number" ? frame["sealed_at"] : Date.now();
2166
+ // CRIT-1: persist sealed state
2167
+ void this.#persistence?.persistSession(sessionIdHex, session);
1734
2168
  // SESSION-007: enqueue lifecycle event for blocked cello_receive callers.
1735
2169
  if (sealedRoot) {
1736
2170
  this.#enqueueSessionSealedEvent(sessionIdHex, sealedRoot, session.close_timestamp);
@@ -1932,6 +2366,8 @@ class CelloClientImpl {
1932
2366
  session.frost_signature = frostSig;
1933
2367
  session.signer_pubkey = signerPubkey;
1934
2368
  session.seal_type = "frost";
2369
+ // CRIT-1: persist sealed state
2370
+ void this.#persistence?.persistSession(sessionIdHex, session);
1935
2371
  }
1936
2372
  closeSession(sessionIdHex) {
1937
2373
  this.#sessions.delete(sessionIdHex);
@@ -2067,6 +2503,7 @@ class CelloClientImpl {
2067
2503
  return;
2068
2504
  // Mark session transport_lost (SESSION-006 AC-001)
2069
2505
  session.status = "transport_lost";
2506
+ void this.#persistence?.persistSession(sessionIdHex, session);
2070
2507
  // Unblock any waiting sendMessage calls with transport_unavailable
2071
2508
  const ackResolve = this.#pendingAckResolvers.get(sessionIdHex);
2072
2509
  if (ackResolve) {
@@ -2145,6 +2582,21 @@ class CelloClientImpl {
2145
2582
  // Reconnect succeeded: install new stream and resume
2146
2583
  this.#relayStreams.set(sessionIdHex, newStream);
2147
2584
  sessionAfterAuth.status = "active";
2585
+ void this.#persistence?.persistSession(sessionIdHex, sessionAfterAuth);
2586
+ // PERSIST-024 AC-008: After relay reconnect, log any pending hashes that need
2587
+ // resubmission. These are hashes loaded from pending_hashes on startup that
2588
+ // were submitted to the relay before the last crash but not yet ACKed.
2589
+ // The relay is expected to replay hash_submit_ack on reconnect for these hashes.
2590
+ if (this.#hashQueue) {
2591
+ const pendingEntries = await this.#hashQueue.getPending(sessionIdHex);
2592
+ if (pendingEntries.length > 0) {
2593
+ this.#logger.info("client.hashqueue.resubmit.pending", {
2594
+ agentId: myPubkeyHex,
2595
+ sessionId: sessionIdHex,
2596
+ pendingCount: pendingEntries.length,
2597
+ });
2598
+ }
2599
+ }
2148
2600
  // Start the new reader loop (authResult.iter is the continuation iterator)
2149
2601
  void this.#runRelayStreamReader(sessionIdHex, newStream, myPubkeyHex, authResult.iter);
2150
2602
  return;
@@ -2409,14 +2861,28 @@ class CelloClientImpl {
2409
2861
  }
2410
2862
  const kind = leaf_kind === 0x02 ? "ctrl" : "msg";
2411
2863
  // Append to local tree
2864
+ const leafIndex = session.local_tree_leaves.length;
2412
2865
  session.local_tree_leaves.push({ kind, s2_cbor });
2413
2866
  session.next_expected_seq += 1;
2867
+ // PERSIST-024: persist leaf to DB
2868
+ if (this.#persistence) {
2869
+ void this.#persistence.persistSessionTreeLeaf({
2870
+ sessionIdHex,
2871
+ leafIndex,
2872
+ leafKind: kind,
2873
+ s2Cbor: s2_cbor,
2874
+ sequenceNumber: s2.sequence_number,
2875
+ });
2876
+ }
2414
2877
  // Compute leaf hash: SHA-256(leaf_kind_byte || s2_cbor) per MERKLE-001
2415
2878
  const leafHash = new Uint8Array(createHash("sha256").update(new Uint8Array([leaf_kind])).update(s2_cbor).digest());
2416
2879
  if (is_own_send) {
2417
2880
  // Own-send echo: fire the send lock release and advance own-seq tracker.
2418
2881
  // Do NOT enqueue into receiveMessage queues — callers don't "receive" their own sends.
2419
2882
  session.last_sent_seq = s2.sequence_number;
2883
+ // PERSIST-024 FINDING-1: persist sequence counters after last_sent_seq mutation
2884
+ // so a crash between leaf accepts does not leave the reloaded session with stale counters.
2885
+ void this.#persistence?.persistSession(sessionIdHex, session);
2420
2886
  echo_resolve?.();
2421
2887
  }
2422
2888
  else {
@@ -2428,6 +2894,9 @@ class CelloClientImpl {
2428
2894
  // SESSION-003: non-initiator auto-response to counterparty's SEAL leaf.
2429
2895
  // Transition to sealing and submit our own SEAL leaf asynchronously.
2430
2896
  session.status = "sealing";
2897
+ // PERSIST-024 FINDING-2: persist the responder's sealing status so a crash here
2898
+ // does not reload the session as "active". Must happen after status mutation.
2899
+ void this.#persistence?.persistSession(sessionIdHex, session);
2431
2900
  void this.#submitSealLeaf(sessionIdHex, session, "responder").then((result) => {
2432
2901
  if (!result.ok) {
2433
2902
  const s = this.#sessions.get(sessionIdHex);
@@ -2438,6 +2907,8 @@ class CelloClientImpl {
2438
2907
  }
2439
2908
  else {
2440
2909
  // Counterparty message: enqueue for receiveMessage callers.
2910
+ // PERSIST-024 FINDING-1: persist sequence counters after last_seen_seq mutation.
2911
+ void this.#persistence?.persistSession(sessionIdHex, session);
2441
2912
  const msg = {
2442
2913
  type: "message",
2443
2914
  content: content_bytes,
@@ -2458,6 +2929,7 @@ class CelloClientImpl {
2458
2929
  if (!session)
2459
2930
  return;
2460
2931
  session.desynchronized = true;
2932
+ void this.#persistence?.persistSession(sessionIdHex, session);
2461
2933
  // Cancel pending S2 timers AND fire any migrated echo resolvers
2462
2934
  const ps2 = this.#pendingS2.get(sessionIdHex);
2463
2935
  if (ps2) {
@@ -2723,9 +3195,20 @@ class CelloClientImpl {
2723
3195
  return { error: "already_registered" };
2724
3196
  }
2725
3197
  // Step 2: generate or load ML-DSA-44 keypair (NIST FIPS 204)
2726
- const mlDsaProvider = this.#mlDsaKeyFile
2727
- ? await FileMlDsaKeyProvider.load(this.#mlDsaKeyFile)
2728
- : await mlDsaKeygen();
3198
+ // PERSIST-024: use mlDsaKeygenWithBytes when persistence is enabled to capture raw secret bytes
3199
+ let mlDsaProvider;
3200
+ let mlDsaSecretKeyBlob = null;
3201
+ if (this.#mlDsaKeyFile) {
3202
+ mlDsaProvider = await FileMlDsaKeyProvider.load(this.#mlDsaKeyFile);
3203
+ }
3204
+ else if (this.#persistence) {
3205
+ const { provider, secretKeyBlob } = await mlDsaKeygenWithBytes();
3206
+ mlDsaProvider = provider;
3207
+ mlDsaSecretKeyBlob = secretKeyBlob;
3208
+ }
3209
+ else {
3210
+ mlDsaProvider = await mlDsaKeygen();
3211
+ }
2729
3212
  const mlDsaPubkey = await mlDsaProvider.getPublicKey();
2730
3213
  const mlDsaPubkeyHex = Buffer.from(mlDsaPubkey).toString("hex");
2731
3214
  // Step 3: open persistent signaling stream (handles auth including auth_ok wait)
@@ -2780,6 +3263,26 @@ class CelloClientImpl {
2780
3263
  };
2781
3264
  this.#registrationState = state;
2782
3265
  this.#mlDsaProvider = mlDsaProvider;
3266
+ // HIGH-5: persist ML-DSA keypair and registration state on already_registered fast-return
3267
+ // Note: the guard is `this.#persistence && mlDsaSecretKeyBlob`. When #mlDsaKeyFile is set,
3268
+ // the agent uses FileMlDsaKeyProvider which manages its own file-based key persistence —
3269
+ // mlDsaSecretKeyBlob will be null in that path and the SQLCipher persistence skip is intentional.
3270
+ // Note on FROST share: DKG was skipped by the directory in this path, so no FROST share exists.
3271
+ // On the next restart, client.frost.share.missing will fire — that is correct behavior.
3272
+ // The operator should investigate why the directory considers this agent already registered
3273
+ // without a local FROST share.
3274
+ if (this.#persistence && mlDsaSecretKeyBlob) {
3275
+ void this.#persistence.persistMlDsaKeypair({
3276
+ mlDsaPubkey: mlDsaPubkeyHex,
3277
+ secretKeyBlob: mlDsaSecretKeyBlob,
3278
+ });
3279
+ void this.#persistence.persistRegistrationState({
3280
+ agentId: state.agent_id,
3281
+ primaryPubkey: state.primary_pubkey,
3282
+ mlDsaPubkey: state.ml_dsa_pubkey,
3283
+ registeredAt: state.registered_at,
3284
+ });
3285
+ }
2783
3286
  return state;
2784
3287
  }
2785
3288
  return { error: reason };
@@ -2814,6 +3317,22 @@ class CelloClientImpl {
2814
3317
  this.#thresholdSigner = dkgResult.signer;
2815
3318
  // SESSION-005: update primary_pubkey so seal verification uses the DKG-derived key.
2816
3319
  this.#myPrimaryPubkey = new Uint8Array(dkgResult.primaryPubkey);
3320
+ // PERSIST-024: persist FROST key share to DB (SI-001: signingShare is raw bytes, never logged)
3321
+ if (this.#persistence) {
3322
+ const commitmentsCbor = CBOR_ENC.encode(dkgResult.commitments);
3323
+ const verifyingSharesCbor = CBOR_ENC.encode(dkgResult.verifyingShares);
3324
+ void this.#persistence.persistFrostKeyShare({
3325
+ epochId,
3326
+ primaryPubkey: dkgPrimaryPubkeyHex,
3327
+ identifier: dkgResult.identifier,
3328
+ signingShare: dkgResult.signingShare,
3329
+ threshold: dkgResult.threshold,
3330
+ participants: dkgResult.participants,
3331
+ commitmentsCbor,
3332
+ verifyingSharesCbor,
3333
+ dkgMethod: "network_dkg",
3334
+ });
3335
+ }
2817
3336
  }
2818
3337
  catch {
2819
3338
  return { error: "dkg_failed" };
@@ -2856,6 +3375,22 @@ class CelloClientImpl {
2856
3375
  };
2857
3376
  this.#registrationState = state;
2858
3377
  this.#mlDsaProvider = mlDsaProvider;
3378
+ // HIGH-5: persist ML-DSA keypair and registration state on already_registered fast-return
3379
+ // Note: the guard is `this.#persistence && mlDsaSecretKeyBlob`. When #mlDsaKeyFile is set,
3380
+ // the agent uses FileMlDsaKeyProvider which manages its own file-based key persistence —
3381
+ // mlDsaSecretKeyBlob will be null in that path and the SQLCipher persistence skip is intentional.
3382
+ if (this.#persistence && mlDsaSecretKeyBlob) {
3383
+ void this.#persistence.persistMlDsaKeypair({
3384
+ mlDsaPubkey: mlDsaPubkeyHex,
3385
+ secretKeyBlob: mlDsaSecretKeyBlob,
3386
+ });
3387
+ void this.#persistence.persistRegistrationState({
3388
+ agentId: state.agent_id,
3389
+ primaryPubkey: state.primary_pubkey,
3390
+ mlDsaPubkey: state.ml_dsa_pubkey,
3391
+ registeredAt: state.registered_at,
3392
+ });
3393
+ }
2859
3394
  return state;
2860
3395
  }
2861
3396
  return { error: reason };
@@ -2873,6 +3408,21 @@ class CelloClientImpl {
2873
3408
  this.#registrationState = state;
2874
3409
  // Store the ML-DSA key provider so MCP tools can build ConnectionPackages (CELLO-MCP-003).
2875
3410
  this.#mlDsaProvider = mlDsaProvider;
3411
+ // PERSIST-024: persist ML-DSA keypair and registration state (SI-002: secretKeyBlob is raw bytes, never logged)
3412
+ if (this.#persistence && mlDsaSecretKeyBlob) {
3413
+ void this.#persistence.persistMlDsaKeypair({
3414
+ mlDsaPubkey: mlDsaPubkeyHex,
3415
+ secretKeyBlob: mlDsaSecretKeyBlob,
3416
+ });
3417
+ }
3418
+ if (this.#persistence) {
3419
+ void this.#persistence.persistRegistrationState({
3420
+ agentId: state.agent_id,
3421
+ primaryPubkey: state.primary_pubkey,
3422
+ mlDsaPubkey: state.ml_dsa_pubkey,
3423
+ registeredAt: state.registered_at,
3424
+ });
3425
+ }
2876
3426
  return state;
2877
3427
  }
2878
3428
  async #handleInbound(stream) {
@@ -2991,6 +3541,10 @@ class CelloClientImpl {
2991
3541
  */
2992
3542
  setPolicy(policy) {
2993
3543
  this.#connectionPolicy = policy;
3544
+ // PERSIST-024: persist policy to DB
3545
+ if (this.#persistence) {
3546
+ void this.#persistence.persistConnectionPolicy(policy);
3547
+ }
2994
3548
  }
2995
3549
  /**
2996
3550
  * Return the current connection policy. Returns default open/deterministic if none configured.
@@ -3031,6 +3585,10 @@ class CelloClientImpl {
3031
3585
  // Mark as decided before sending to prevent races
3032
3586
  this.#decidedRequests.add(connectionRequestId);
3033
3587
  this.#pendingInboundRequests.delete(connectionRequestId);
3588
+ // CRIT-2: persist decision
3589
+ if (this.#persistence) {
3590
+ void this.#persistence.decidePendingConnectionRequest(connectionRequestId, "accepted");
3591
+ }
3034
3592
  if (!this.#persistentSignalingStream) {
3035
3593
  // Stream gone — still mark as decided
3036
3594
  return { error: { reason: "no_pending_request" } };
@@ -3081,6 +3639,10 @@ class CelloClientImpl {
3081
3639
  // Mark as decided
3082
3640
  this.#decidedRequests.add(connectionRequestId);
3083
3641
  this.#pendingInboundRequests.delete(connectionRequestId);
3642
+ // CRIT-2: persist decision
3643
+ if (this.#persistence) {
3644
+ void this.#persistence.decidePendingConnectionRequest(connectionRequestId, "rejected");
3645
+ }
3084
3646
  if (!this.#persistentSignalingStream) {
3085
3647
  return { rejected: true };
3086
3648
  }
@@ -3119,6 +3681,10 @@ class CelloClientImpl {
3119
3681
  }
3120
3682
  // Advance to Round 2
3121
3683
  pending.round = 2;
3684
+ // CRIT-2: persist disclosure decision
3685
+ if (this.#persistence) {
3686
+ void this.#persistence.decidePendingConnectionRequest(connectionRequestId, "more_disclosure");
3687
+ }
3122
3688
  if (!this.#persistentSignalingStream) {
3123
3689
  return { error: { reason: "no_pending_request" } };
3124
3690
  }
@@ -3276,6 +3842,14 @@ class CelloClientImpl {
3276
3842
  };
3277
3843
  this.#connections.set(connectionId, record);
3278
3844
  this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
3845
+ // PERSIST-024: persist connection to DB
3846
+ if (this.#persistence) {
3847
+ void this.#persistence.persistConnection({
3848
+ connectionId,
3849
+ counterpartyPubkey,
3850
+ establishedAt: record.established_at,
3851
+ });
3852
+ }
3279
3853
  this.#logger.info("connection.established", {
3280
3854
  connectionId,
3281
3855
  counterpartyPubkeyHex: counterpartyPubkey,
@@ -3305,6 +3879,14 @@ class CelloClientImpl {
3305
3879
  };
3306
3880
  this.#connections.set(connectionId, record);
3307
3881
  this.#connectionsByPeer.set(targetPubkeyHex, connectionId);
3882
+ // PERSIST-024: persist connection to DB
3883
+ if (this.#persistence) {
3884
+ void this.#persistence.persistConnection({
3885
+ connectionId,
3886
+ counterpartyPubkey: targetPubkeyHex,
3887
+ establishedAt: record.established_at,
3888
+ });
3889
+ }
3308
3890
  }
3309
3891
  this.#logger.info("connection.established", {
3310
3892
  connectionId,
@@ -3365,16 +3947,24 @@ class CelloClientImpl {
3365
3947
  if (type === "connection_established") {
3366
3948
  const connectionId = frame["connection_id"];
3367
3949
  const counterpartyPubkey = frame["counterparty_pubkey"];
3950
+ const establishedAt = Date.now();
3368
3951
  const record = {
3369
3952
  connection_id: connectionId,
3370
3953
  counterparty_pubkey: counterpartyPubkey,
3371
3954
  counterparty_primary_pubkey: "",
3372
3955
  counterparty_ml_dsa_pubkey: "",
3373
- established_at: Date.now(),
3956
+ established_at: establishedAt,
3374
3957
  status: "active",
3375
3958
  };
3376
3959
  this.#connections.set(connectionId, record);
3377
3960
  this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
3961
+ if (this.#persistence) {
3962
+ void this.#persistence.persistConnection({
3963
+ connectionId,
3964
+ counterpartyPubkey,
3965
+ establishedAt,
3966
+ });
3967
+ }
3378
3968
  return { result: "established", connection_id: connectionId };
3379
3969
  }
3380
3970
  if (type === "connection_rejected") {
@@ -3556,7 +4146,12 @@ class CelloClientImpl {
3556
4146
  }
3557
4147
  sigStream.close().catch(() => { });
3558
4148
  if (respFrame["type"] === "relay_pubkey_response") {
3559
- return respFrame["public_key_hex"];
4149
+ const pubkeyHex = respFrame["public_key_hex"];
4150
+ // PERSIST-024: cache in known_relays so lookupRelayPubkey can serve from DB
4151
+ if (pubkeyHex && this.#persistence) {
4152
+ void this.#persistence.persistKnownRelay(relayId, pubkeyHex, "directory");
4153
+ }
4154
+ return pubkeyHex;
3560
4155
  }
3561
4156
  // relay_pubkey_error (not_found) or unexpected type
3562
4157
  const errorReason = respFrame["reason"] ?? "not_found";
@@ -3780,6 +4375,15 @@ class CelloClientImpl {
3780
4375
  // No caller waiting — enqueue for future awaitConnectionRequest() poll
3781
4376
  this.#pendingReviewQueue.push(reviewItem);
3782
4377
  }
4378
+ // CRIT-2: persist pending connection request
4379
+ if (this.#persistence) {
4380
+ void this.#persistence.persistPendingConnectionRequest({
4381
+ requestId: connectionRequestId,
4382
+ fromPubkey,
4383
+ packageCbor,
4384
+ round: 1,
4385
+ });
4386
+ }
3783
4387
  if (this.#onConnectionPendingReview) {
3784
4388
  this.#onConnectionPendingReview({
3785
4389
  type: "connection_request_inbound",
@@ -3913,6 +4517,14 @@ class CelloClientImpl {
3913
4517
  package_cbor: packageCbor,
3914
4518
  round: 2,
3915
4519
  });
4520
+ if (this.#persistence) {
4521
+ void this.#persistence.persistPendingConnectionRequest({
4522
+ requestId: connectionRequestId,
4523
+ fromPubkey,
4524
+ packageCbor,
4525
+ round: 2,
4526
+ });
4527
+ }
3916
4528
  const round2ReviewItem = {
3917
4529
  connection_request_id: connectionRequestId,
3918
4530
  from_pubkey: fromPubkey,
@@ -4413,6 +5025,15 @@ class CelloClientImpl {
4413
5025
  }
4414
5026
  this.#connections.set(connectionId, record);
4415
5027
  this.#connectionsByPeer.set(counterpartyPubkey, connectionId);
5028
+ // PERSIST-024: persist connection to DB
5029
+ if (this.#persistence) {
5030
+ void this.#persistence.persistConnection({
5031
+ connectionId,
5032
+ counterpartyPubkey,
5033
+ establishedAt: record.established_at,
5034
+ profileUnchecked: record.profile_unchecked,
5035
+ });
5036
+ }
4416
5037
  }
4417
5038
  // Fire onConnectionEstablished handler (both A and B)
4418
5039
  const handler = this.#onConnectionEstablishedHandler;
@@ -4640,7 +5261,7 @@ function parseStringArray(v) {
4640
5261
  }
4641
5262
  // ─── Factory ─────────────────────────────────────────────────────────────────
4642
5263
  export function createClient(node, keyProvider, opts) {
4643
- return new CelloClientImpl(node, keyProvider, opts?.onMessageQueued, opts?.contentGraceMs, opts?.reconnectTimeoutMs, opts?.thresholdSigner, opts?.sealFrostTimeoutMs, opts?.directoryEndpoint ?? null, opts?.mlDsaKeyFile, opts?.connectionPolicy, opts?.connectionTimeoutMs, opts?.round2TimeoutMs, opts?.trackEvaluateCount, opts?.whitelist, opts?.onConnectionPendingReview, opts?.crossCheckDirectoryOnInbound, opts?.logger);
5264
+ return new CelloClientImpl(node, keyProvider, opts?.onMessageQueued, opts?.contentGraceMs, opts?.reconnectTimeoutMs, opts?.thresholdSigner, opts?.sealFrostTimeoutMs, opts?.directoryEndpoint ?? null, opts?.mlDsaKeyFile, opts?.connectionPolicy, opts?.connectionTimeoutMs, opts?.round2TimeoutMs, opts?.trackEvaluateCount, opts?.whitelist, opts?.onConnectionPendingReview, opts?.crossCheckDirectoryOnInbound, opts?.logger, opts?.persistence);
4644
5265
  }
4645
5266
  // ─── Error helpers ────────────────────────────────────────────────────────────
4646
5267
  function isStructuredError(err, reason) {