@agentvault/secure-channel 0.1.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/channel.ts
4
4
  import { EventEmitter } from "node:events";
5
+ import { randomUUID } from "node:crypto";
5
6
 
6
7
  // ../../node_modules/libsodium-sumo/dist/modules-sumo-esm/libsodium-sumo.mjs
7
8
  var __filename;
@@ -45035,6 +45036,28 @@ async function activateDevice(apiUrl2, deviceId) {
45035
45036
  var POLL_INTERVAL_MS = 5e3;
45036
45037
  var RECONNECT_BASE_MS = 1e3;
45037
45038
  var RECONNECT_MAX_MS = 3e4;
45039
+ function migratePersistedState(raw) {
45040
+ if (raw.sessions && raw.primaryConversationId) {
45041
+ return raw;
45042
+ }
45043
+ const legacy = raw;
45044
+ return {
45045
+ deviceId: legacy.deviceId,
45046
+ deviceJwt: legacy.deviceJwt,
45047
+ primaryConversationId: legacy.conversationId,
45048
+ sessions: {
45049
+ [legacy.conversationId]: {
45050
+ ownerDeviceId: "",
45051
+ ratchetState: legacy.ratchetState
45052
+ }
45053
+ },
45054
+ identityKeypair: legacy.identityKeypair,
45055
+ ephemeralKeypair: legacy.ephemeralKeypair,
45056
+ fingerprint: legacy.fingerprint,
45057
+ lastMessageTimestamp: legacy.lastMessageTimestamp,
45058
+ messageHistory: []
45059
+ };
45060
+ }
45038
45061
  var SecureChannel = class extends EventEmitter {
45039
45062
  constructor(config) {
45040
45063
  super();
@@ -45043,9 +45066,9 @@ var SecureChannel = class extends EventEmitter {
45043
45066
  _state = "idle";
45044
45067
  _deviceId = null;
45045
45068
  _fingerprint = null;
45046
- _conversationId = null;
45069
+ _primaryConversationId = "";
45047
45070
  _deviceJwt = null;
45048
- _ratchet = null;
45071
+ _sessions = /* @__PURE__ */ new Map();
45049
45072
  _ws = null;
45050
45073
  _pollTimer = null;
45051
45074
  _reconnectAttempt = 0;
@@ -45061,41 +45084,94 @@ var SecureChannel = class extends EventEmitter {
45061
45084
  get fingerprint() {
45062
45085
  return this._fingerprint;
45063
45086
  }
45087
+ /** Returns the primary conversation ID (backward-compatible). */
45064
45088
  get conversationId() {
45065
- return this._conversationId;
45089
+ return this._primaryConversationId || null;
45090
+ }
45091
+ /** Returns all active conversation IDs. */
45092
+ get conversationIds() {
45093
+ return Array.from(this._sessions.keys());
45094
+ }
45095
+ /** Returns the number of active sessions. */
45096
+ get sessionCount() {
45097
+ return this._sessions.size;
45066
45098
  }
45067
45099
  async start() {
45068
45100
  this._stopped = false;
45069
45101
  await libsodium_wrappers_default.ready;
45070
- const persisted = await loadState(this.config.dataDir);
45071
- if (persisted) {
45072
- this._persisted = persisted;
45073
- this._deviceId = persisted.deviceId;
45074
- this._deviceJwt = persisted.deviceJwt;
45075
- this._conversationId = persisted.conversationId;
45076
- this._fingerprint = persisted.fingerprint;
45077
- this._ratchet = DoubleRatchet.deserialize(persisted.ratchetState);
45102
+ const raw = await loadState(this.config.dataDir);
45103
+ if (raw) {
45104
+ this._persisted = migratePersistedState(raw);
45105
+ if (!this._persisted.messageHistory) {
45106
+ this._persisted.messageHistory = [];
45107
+ }
45108
+ this._deviceId = this._persisted.deviceId;
45109
+ this._deviceJwt = this._persisted.deviceJwt;
45110
+ this._primaryConversationId = this._persisted.primaryConversationId;
45111
+ this._fingerprint = this._persisted.fingerprint;
45112
+ for (const [convId, sessionData] of Object.entries(
45113
+ this._persisted.sessions
45114
+ )) {
45115
+ if (sessionData.ratchetState) {
45116
+ const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
45117
+ this._sessions.set(convId, {
45118
+ ownerDeviceId: sessionData.ownerDeviceId,
45119
+ ratchet,
45120
+ activated: sessionData.activated ?? false
45121
+ });
45122
+ }
45123
+ }
45078
45124
  this._connect();
45079
45125
  return;
45080
45126
  }
45081
45127
  await this._enroll();
45082
45128
  }
45129
+ /**
45130
+ * Append a message to persistent history for cross-device replay.
45131
+ */
45132
+ _appendHistory(sender, text) {
45133
+ if (!this._persisted) return;
45134
+ if (!this._persisted.messageHistory) {
45135
+ this._persisted.messageHistory = [];
45136
+ }
45137
+ const maxSize = this.config.maxHistorySize ?? 500;
45138
+ this._persisted.messageHistory.push({
45139
+ sender,
45140
+ text,
45141
+ ts: (/* @__PURE__ */ new Date()).toISOString()
45142
+ });
45143
+ if (this._persisted.messageHistory.length > maxSize) {
45144
+ this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
45145
+ }
45146
+ }
45147
+ /**
45148
+ * Encrypt and send a message to ALL owner devices (fanout).
45149
+ * Each session gets the same plaintext encrypted independently.
45150
+ */
45083
45151
  async send(plaintext) {
45084
- if (this._state !== "ready" || !this._ratchet || !this._ws) {
45152
+ if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45085
45153
  throw new Error("Channel is not ready");
45086
45154
  }
45087
- const encrypted = this._ratchet.encrypt(plaintext);
45088
- const transport = encryptedMessageToTransport(encrypted);
45089
- this._ws.send(
45090
- JSON.stringify({
45091
- event: "message",
45092
- data: {
45093
- conversation_id: this._conversationId,
45094
- header_blob: transport.header_blob,
45095
- ciphertext: transport.ciphertext
45096
- }
45097
- })
45098
- );
45155
+ this._appendHistory("agent", plaintext);
45156
+ const messageGroupId = randomUUID();
45157
+ for (const [convId, session] of this._sessions) {
45158
+ if (!session.activated) {
45159
+ continue;
45160
+ }
45161
+ const encrypted = session.ratchet.encrypt(plaintext);
45162
+ const transport = encryptedMessageToTransport(encrypted);
45163
+ this._ws.send(
45164
+ JSON.stringify({
45165
+ event: "message",
45166
+ data: {
45167
+ conversation_id: convId,
45168
+ header_blob: transport.header_blob,
45169
+ ciphertext: transport.ciphertext,
45170
+ message_group_id: messageGroupId
45171
+ }
45172
+ })
45173
+ );
45174
+ }
45099
45175
  await this._persistState();
45100
45176
  }
45101
45177
  async stop() {
@@ -45140,8 +45216,10 @@ var SecureChannel = class extends EventEmitter {
45140
45216
  deviceId: result.device_id,
45141
45217
  deviceJwt: "",
45142
45218
  // set after activation
45143
- conversationId: "",
45219
+ primaryConversationId: "",
45144
45220
  // set after activation
45221
+ sessions: {},
45222
+ // populated after activation
45145
45223
  identityKeypair: {
45146
45224
  publicKey: bytesToHex(identity.publicKey),
45147
45225
  privateKey: bytesToHex(identity.privateKey)
@@ -45151,8 +45229,7 @@ var SecureChannel = class extends EventEmitter {
45151
45229
  privateKey: bytesToHex(ephemeral.privateKey)
45152
45230
  },
45153
45231
  fingerprint: result.fingerprint,
45154
- ratchetState: ""
45155
- // set after activation
45232
+ messageHistory: []
45156
45233
  };
45157
45234
  this._poll();
45158
45235
  } catch (err) {
@@ -45191,31 +45268,85 @@ var SecureChannel = class extends EventEmitter {
45191
45268
  this.config.apiUrl,
45192
45269
  this._deviceId
45193
45270
  );
45194
- this._conversationId = result.conversation_id;
45271
+ const conversations = result.conversations || [
45272
+ {
45273
+ conversation_id: result.conversation_id,
45274
+ owner_device_id: "",
45275
+ is_primary: true
45276
+ }
45277
+ ];
45278
+ const primary = conversations.find((c2) => c2.is_primary) || conversations[0];
45279
+ this._primaryConversationId = primary.conversation_id;
45195
45280
  this._deviceJwt = result.device_jwt;
45196
45281
  const identity = this._persisted.identityKeypair;
45197
45282
  const ephemeral = this._persisted.ephemeralKeypair;
45198
- const sharedSecret = await performX3DH({
45199
- myIdentityPrivate: hexToBytes(identity.privateKey),
45200
- myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45201
- theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
45202
- theirEphemeralPublic: hexToBytes(
45203
- result.owner_ephemeral_public_key ?? result.owner_identity_public_key
45204
- ),
45205
- isInitiator: false
45206
- });
45207
- this._ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45208
- publicKey: hexToBytes(identity.publicKey),
45209
- privateKey: hexToBytes(identity.privateKey),
45210
- keyType: "ed25519"
45211
- });
45283
+ const sessions = {};
45284
+ for (const conv of conversations) {
45285
+ const ownerIdentityKey = conv.owner_identity_public_key || result.owner_identity_public_key;
45286
+ const ownerEphemeralKey = conv.owner_ephemeral_public_key || result.owner_ephemeral_public_key || ownerIdentityKey;
45287
+ const sharedSecret = performX3DH({
45288
+ myIdentityPrivate: hexToBytes(identity.privateKey),
45289
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45290
+ theirIdentityPublic: hexToBytes(ownerIdentityKey),
45291
+ theirEphemeralPublic: hexToBytes(ownerEphemeralKey),
45292
+ isInitiator: false
45293
+ });
45294
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45295
+ publicKey: hexToBytes(identity.publicKey),
45296
+ privateKey: hexToBytes(identity.privateKey),
45297
+ keyType: "ed25519"
45298
+ });
45299
+ this._sessions.set(conv.conversation_id, {
45300
+ ownerDeviceId: conv.owner_device_id,
45301
+ ratchet,
45302
+ activated: false
45303
+ // Wait for owner's first message before sending to this session
45304
+ });
45305
+ sessions[conv.conversation_id] = {
45306
+ ownerDeviceId: conv.owner_device_id,
45307
+ ratchetState: ratchet.serialize()
45308
+ };
45309
+ console.log(
45310
+ `[SecureChannel] Session initialized for conv ${conv.conversation_id.slice(0, 8)}... (owner ${conv.owner_device_id.slice(0, 8)}..., primary=${conv.is_primary})`
45311
+ );
45312
+ }
45212
45313
  this._persisted = {
45213
45314
  ...this._persisted,
45214
45315
  deviceJwt: result.device_jwt,
45215
- conversationId: result.conversation_id,
45216
- ratchetState: this._ratchet.serialize()
45316
+ primaryConversationId: primary.conversation_id,
45317
+ sessions,
45318
+ messageHistory: this._persisted.messageHistory ?? []
45217
45319
  };
45218
45320
  await saveState(this.config.dataDir, this._persisted);
45321
+ if (this.config.webhookUrl) {
45322
+ try {
45323
+ const webhookResp = await fetch(
45324
+ `${this.config.apiUrl}/api/v1/devices/self/webhook`,
45325
+ {
45326
+ method: "PATCH",
45327
+ headers: {
45328
+ "Content-Type": "application/json",
45329
+ Authorization: `Bearer ${this._deviceJwt}`
45330
+ },
45331
+ body: JSON.stringify({ webhook_url: this.config.webhookUrl })
45332
+ }
45333
+ );
45334
+ if (webhookResp.ok) {
45335
+ const webhookData = await webhookResp.json();
45336
+ console.log(
45337
+ `[SecureChannel] Webhook registered: ${this.config.webhookUrl} (secret: ${webhookData.webhook_secret?.slice(0, 8)}...)`
45338
+ );
45339
+ this.emit("webhook_registered", {
45340
+ url: this.config.webhookUrl,
45341
+ secret: webhookData.webhook_secret
45342
+ });
45343
+ } else {
45344
+ console.warn(`[SecureChannel] Webhook registration failed: ${webhookResp.status}`);
45345
+ }
45346
+ } catch (err) {
45347
+ console.warn(`[SecureChannel] Webhook registration error: ${err}`);
45348
+ }
45349
+ }
45219
45350
  this._connect();
45220
45351
  } catch (err) {
45221
45352
  this._handleError(err);
@@ -45246,23 +45377,12 @@ var SecureChannel = class extends EventEmitter {
45246
45377
  this._handleError(new Error("Device was revoked"));
45247
45378
  return;
45248
45379
  }
45380
+ if (data.event === "device_linked") {
45381
+ await this._handleDeviceLinked(data.data);
45382
+ return;
45383
+ }
45249
45384
  if (data.event === "message") {
45250
- const msgData = data.data;
45251
- if (msgData.sender_device_id === this._deviceId) return;
45252
- const encrypted = transportToEncryptedMessage({
45253
- header_blob: msgData.header_blob,
45254
- ciphertext: msgData.ciphertext
45255
- });
45256
- const plaintext = this._ratchet.decrypt(encrypted);
45257
- await this._persistState();
45258
- const metadata = {
45259
- messageId: msgData.message_id,
45260
- conversationId: msgData.conversation_id,
45261
- timestamp: msgData.created_at
45262
- };
45263
- this.emit("message", plaintext, metadata);
45264
- this.config.onMessage?.(plaintext, metadata);
45265
- this._persisted.lastMessageTimestamp = msgData.created_at;
45385
+ await this._handleIncomingMessage(data.data);
45266
45386
  }
45267
45387
  } catch (err) {
45268
45388
  this.emit("error", err);
@@ -45277,6 +45397,182 @@ var SecureChannel = class extends EventEmitter {
45277
45397
  this.emit("error", err);
45278
45398
  });
45279
45399
  }
45400
+ /**
45401
+ * Handle an incoming encrypted message from a specific conversation.
45402
+ * Decrypts using the appropriate session ratchet, emits to the agent,
45403
+ * and relays as sync messages to sibling sessions.
45404
+ */
45405
+ async _handleIncomingMessage(msgData) {
45406
+ if (msgData.sender_device_id === this._deviceId) return;
45407
+ const convId = msgData.conversation_id;
45408
+ const session = this._sessions.get(convId);
45409
+ if (!session) {
45410
+ console.warn(
45411
+ `[SecureChannel] No session for conversation ${convId}, skipping`
45412
+ );
45413
+ return;
45414
+ }
45415
+ const encrypted = transportToEncryptedMessage({
45416
+ header_blob: msgData.header_blob,
45417
+ ciphertext: msgData.ciphertext
45418
+ });
45419
+ const plaintext = session.ratchet.decrypt(encrypted);
45420
+ if (!session.activated) {
45421
+ session.activated = true;
45422
+ console.log(`[SecureChannel] Session ${convId.slice(0, 8)}... activated by first owner message`);
45423
+ }
45424
+ let messageText;
45425
+ let messageType;
45426
+ try {
45427
+ const parsed = JSON.parse(plaintext);
45428
+ messageType = parsed.type || "message";
45429
+ messageText = parsed.text || plaintext;
45430
+ } catch {
45431
+ messageType = "message";
45432
+ messageText = plaintext;
45433
+ }
45434
+ if (messageType === "session_init") {
45435
+ console.log(`[SecureChannel] session_init received for ${convId.slice(0, 8)}..., replaying history`);
45436
+ await this._replayHistoryToSession(convId);
45437
+ await this._persistState();
45438
+ return;
45439
+ }
45440
+ if (messageType === "message") {
45441
+ this._appendHistory("owner", messageText);
45442
+ const metadata = {
45443
+ messageId: msgData.message_id,
45444
+ conversationId: convId,
45445
+ timestamp: msgData.created_at
45446
+ };
45447
+ this.emit("message", messageText, metadata);
45448
+ this.config.onMessage?.(messageText, metadata);
45449
+ await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText);
45450
+ }
45451
+ if (this._persisted) {
45452
+ this._persisted.lastMessageTimestamp = msgData.created_at;
45453
+ }
45454
+ await this._persistState();
45455
+ }
45456
+ /**
45457
+ * Relay an owner's message to all sibling sessions as encrypted sync messages.
45458
+ * This allows all owner devices to see messages from any single device.
45459
+ */
45460
+ async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText) {
45461
+ if (!this._ws || this._sessions.size <= 1) return;
45462
+ const syncPayload = JSON.stringify({
45463
+ type: "sync",
45464
+ sender: senderOwnerDeviceId,
45465
+ text: messageText,
45466
+ ts: (/* @__PURE__ */ new Date()).toISOString()
45467
+ });
45468
+ for (const [siblingConvId, siblingSession] of this._sessions) {
45469
+ if (siblingConvId === sourceConvId) continue;
45470
+ if (!siblingSession.activated) continue;
45471
+ const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
45472
+ const syncTransport = encryptedMessageToTransport(syncEncrypted);
45473
+ this._ws.send(
45474
+ JSON.stringify({
45475
+ event: "message",
45476
+ data: {
45477
+ conversation_id: siblingConvId,
45478
+ header_blob: syncTransport.header_blob,
45479
+ ciphertext: syncTransport.ciphertext
45480
+ }
45481
+ })
45482
+ );
45483
+ }
45484
+ }
45485
+ /**
45486
+ * Send stored message history to a newly-activated session.
45487
+ * Batches all history into a single encrypted message.
45488
+ */
45489
+ async _replayHistoryToSession(convId) {
45490
+ const session = this._sessions.get(convId);
45491
+ if (!session || !session.activated || !this._ws) return;
45492
+ const history = this._persisted?.messageHistory ?? [];
45493
+ if (history.length === 0) {
45494
+ console.log(`[SecureChannel] No history to replay for ${convId.slice(0, 8)}...`);
45495
+ return;
45496
+ }
45497
+ console.log(
45498
+ `[SecureChannel] Replaying ${history.length} messages to session ${convId.slice(0, 8)}...`
45499
+ );
45500
+ const replayPayload = JSON.stringify({
45501
+ type: "history_replay",
45502
+ messages: history
45503
+ });
45504
+ const encrypted = session.ratchet.encrypt(replayPayload);
45505
+ const transport = encryptedMessageToTransport(encrypted);
45506
+ this._ws.send(
45507
+ JSON.stringify({
45508
+ event: "message",
45509
+ data: {
45510
+ conversation_id: convId,
45511
+ header_blob: transport.header_blob,
45512
+ ciphertext: transport.ciphertext
45513
+ }
45514
+ })
45515
+ );
45516
+ }
45517
+ /**
45518
+ * Handle a device_linked event: a new owner device has joined.
45519
+ * Fetches the new device's public keys, performs X3DH, and initializes
45520
+ * a new ratchet session.
45521
+ */
45522
+ async _handleDeviceLinked(event) {
45523
+ console.log(
45524
+ `[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
45525
+ );
45526
+ try {
45527
+ if (!event.owner_identity_public_key) {
45528
+ console.error(
45529
+ `[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`
45530
+ );
45531
+ return;
45532
+ }
45533
+ const identity = this._persisted.identityKeypair;
45534
+ const ephemeral = this._persisted.ephemeralKeypair;
45535
+ const sharedSecret = performX3DH({
45536
+ myIdentityPrivate: hexToBytes(identity.privateKey),
45537
+ myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45538
+ theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
45539
+ theirEphemeralPublic: hexToBytes(
45540
+ event.owner_ephemeral_public_key ?? event.owner_identity_public_key
45541
+ ),
45542
+ isInitiator: false
45543
+ });
45544
+ const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45545
+ publicKey: hexToBytes(identity.publicKey),
45546
+ privateKey: hexToBytes(identity.privateKey),
45547
+ keyType: "ed25519"
45548
+ });
45549
+ this._sessions.set(event.conversation_id, {
45550
+ ownerDeviceId: event.owner_device_id,
45551
+ ratchet,
45552
+ activated: false
45553
+ // Wait for owner's first message
45554
+ });
45555
+ this._persisted.sessions[event.conversation_id] = {
45556
+ ownerDeviceId: event.owner_device_id,
45557
+ ratchetState: ratchet.serialize(),
45558
+ activated: false
45559
+ };
45560
+ await this._persistState();
45561
+ console.log(
45562
+ `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`
45563
+ );
45564
+ } catch (err) {
45565
+ console.error(
45566
+ `[SecureChannel] Failed to handle device_linked:`,
45567
+ err
45568
+ );
45569
+ this.emit("error", err);
45570
+ }
45571
+ }
45572
+ /**
45573
+ * Sync missed messages across ALL sessions.
45574
+ * For each conversation, fetches messages since last sync and decrypts.
45575
+ */
45280
45576
  async _syncMissedMessages() {
45281
45577
  if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
45282
45578
  try {
@@ -45289,19 +45585,43 @@ var SecureChannel = class extends EventEmitter {
45289
45585
  const messages = await res.json();
45290
45586
  for (const msg of messages) {
45291
45587
  if (msg.sender_device_id === this._deviceId) continue;
45588
+ const session = this._sessions.get(msg.conversation_id);
45589
+ if (!session) {
45590
+ console.warn(
45591
+ `[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
45592
+ );
45593
+ continue;
45594
+ }
45292
45595
  try {
45293
45596
  const encrypted = transportToEncryptedMessage({
45294
45597
  header_blob: msg.header_blob,
45295
45598
  ciphertext: msg.ciphertext
45296
45599
  });
45297
- const plaintext = this._ratchet.decrypt(encrypted);
45298
- const metadata = {
45299
- messageId: msg.id,
45300
- conversationId: msg.conversation_id,
45301
- timestamp: msg.created_at
45302
- };
45303
- this.emit("message", plaintext, metadata);
45304
- this.config.onMessage?.(plaintext, metadata);
45600
+ const plaintext = session.ratchet.decrypt(encrypted);
45601
+ if (!session.activated) {
45602
+ session.activated = true;
45603
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
45604
+ }
45605
+ let messageText;
45606
+ let messageType;
45607
+ try {
45608
+ const parsed = JSON.parse(plaintext);
45609
+ messageType = parsed.type || "message";
45610
+ messageText = parsed.text || plaintext;
45611
+ } catch {
45612
+ messageType = "message";
45613
+ messageText = plaintext;
45614
+ }
45615
+ if (messageType === "message") {
45616
+ this._appendHistory("owner", messageText);
45617
+ const metadata = {
45618
+ messageId: msg.id,
45619
+ conversationId: msg.conversation_id,
45620
+ timestamp: msg.created_at
45621
+ };
45622
+ this.emit("message", messageText, metadata);
45623
+ this.config.onMessage?.(messageText, metadata);
45624
+ }
45305
45625
  this._persisted.lastMessageTimestamp = msg.created_at;
45306
45626
  } catch (err) {
45307
45627
  this.emit("error", err);
@@ -45335,9 +45655,19 @@ var SecureChannel = class extends EventEmitter {
45335
45655
  this._setState("error");
45336
45656
  this.emit("error", err);
45337
45657
  }
45658
+ /**
45659
+ * Persist all ratchet session states to disk.
45660
+ * Syncs live ratchet states back into the persisted sessions map.
45661
+ */
45338
45662
  async _persistState() {
45339
- if (!this._persisted || !this._ratchet) return;
45340
- this._persisted.ratchetState = this._ratchet.serialize();
45663
+ if (!this._persisted || this._sessions.size === 0) return;
45664
+ for (const [convId, session] of this._sessions) {
45665
+ this._persisted.sessions[convId] = {
45666
+ ownerDeviceId: session.ownerDeviceId,
45667
+ ratchetState: session.ratchet.serialize(),
45668
+ activated: session.activated
45669
+ };
45670
+ }
45341
45671
  await saveState(this.config.dataDir, this._persisted);
45342
45672
  }
45343
45673
  };