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