@agentvault/secure-channel 0.6.15 → 0.6.17

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.js CHANGED
@@ -45097,7 +45097,7 @@ function migratePersistedState(raw) {
45097
45097
  };
45098
45098
  }
45099
45099
  var SecureChannel = class _SecureChannel extends EventEmitter {
45100
- // Treat as dead if no pong within 10s
45100
+ // 60s when idle
45101
45101
  constructor(config) {
45102
45102
  super();
45103
45103
  this.config = config;
@@ -45119,9 +45119,15 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45119
45119
  _stopped = false;
45120
45120
  _persisted = null;
45121
45121
  _httpServer = null;
45122
+ _pollFallbackTimer = null;
45123
+ _syncMessageIds = null;
45122
45124
  static PING_INTERVAL_MS = 3e4;
45123
45125
  // Send ping every 30s
45124
45126
  static PING_TIMEOUT_MS = 1e4;
45127
+ // Treat as dead if no pong within 10s
45128
+ static POLL_FALLBACK_INTERVAL_MS = 3e4;
45129
+ // 30s when messages found
45130
+ static POLL_FALLBACK_IDLE_MS = 6e4;
45125
45131
  get state() {
45126
45132
  return this._state;
45127
45133
  }
@@ -45200,30 +45206,50 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45200
45206
  * Each session gets the same plaintext encrypted independently.
45201
45207
  */
45202
45208
  async send(plaintext, options) {
45203
- if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45209
+ if (this._state === "error" || this._state === "idle") {
45204
45210
  throw new Error("Channel is not ready");
45205
45211
  }
45212
+ if (this._sessions.size === 0) {
45213
+ throw new Error("No active sessions");
45214
+ }
45206
45215
  const topicId = options?.topicId ?? this._persisted?.defaultTopicId;
45207
45216
  this._appendHistory("agent", plaintext, topicId);
45208
45217
  const messageGroupId = randomUUID();
45209
45218
  for (const [convId, session] of this._sessions) {
45210
- if (!session.activated) {
45211
- continue;
45212
- }
45219
+ if (!session.activated) continue;
45213
45220
  const encrypted = session.ratchet.encrypt(plaintext);
45214
45221
  const transport = encryptedMessageToTransport(encrypted);
45215
- this._ws.send(
45216
- JSON.stringify({
45217
- event: "message",
45218
- data: {
45219
- conversation_id: convId,
45220
- header_blob: transport.header_blob,
45221
- ciphertext: transport.ciphertext,
45222
- message_group_id: messageGroupId,
45223
- topic_id: topicId
45224
- }
45225
- })
45226
- );
45222
+ const msg = {
45223
+ convId,
45224
+ headerBlob: transport.header_blob,
45225
+ ciphertext: transport.ciphertext,
45226
+ messageGroupId,
45227
+ topicId
45228
+ };
45229
+ if (this._state === "ready" && this._ws) {
45230
+ this._ws.send(
45231
+ JSON.stringify({
45232
+ event: "message",
45233
+ data: {
45234
+ conversation_id: msg.convId,
45235
+ header_blob: msg.headerBlob,
45236
+ ciphertext: msg.ciphertext,
45237
+ message_group_id: msg.messageGroupId,
45238
+ topic_id: msg.topicId
45239
+ }
45240
+ })
45241
+ );
45242
+ } else {
45243
+ if (!this._persisted.outboundQueue) {
45244
+ this._persisted.outboundQueue = [];
45245
+ }
45246
+ if (this._persisted.outboundQueue.length >= 50) {
45247
+ this._persisted.outboundQueue.shift();
45248
+ console.warn("[SecureChannel] Outbound queue full, dropping oldest message");
45249
+ }
45250
+ this._persisted.outboundQueue.push(msg);
45251
+ console.log(`[SecureChannel] Message queued (state=${this._state}, queue=${this._persisted.outboundQueue.length})`);
45252
+ }
45227
45253
  }
45228
45254
  await this._persistState();
45229
45255
  }
@@ -45231,6 +45257,7 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45231
45257
  this._stopped = true;
45232
45258
  this._flushAcks();
45233
45259
  this._stopPing();
45260
+ this._stopPollFallback();
45234
45261
  this._stopHttpServer();
45235
45262
  if (this._ackTimer) {
45236
45263
  clearTimeout(this._ackTimer);
@@ -45552,6 +45579,10 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45552
45579
  }
45553
45580
  _connect() {
45554
45581
  if (this._stopped) return;
45582
+ if (this._reconnectTimer) {
45583
+ clearTimeout(this._reconnectTimer);
45584
+ this._reconnectTimer = null;
45585
+ }
45555
45586
  if (this._ws) {
45556
45587
  this._ws.removeAllListeners();
45557
45588
  try {
@@ -45569,6 +45600,7 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45569
45600
  this._reconnectAttempt = 0;
45570
45601
  this._startPing(ws);
45571
45602
  await this._syncMissedMessages();
45603
+ await this._flushOutboundQueue();
45572
45604
  this._setState("ready");
45573
45605
  this.emit("ready");
45574
45606
  });
@@ -45612,6 +45644,9 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45612
45644
  */
45613
45645
  async _handleIncomingMessage(msgData) {
45614
45646
  if (msgData.sender_device_id === this._deviceId) return;
45647
+ if (this._syncMessageIds?.has(msgData.message_id)) {
45648
+ return;
45649
+ }
45615
45650
  const convId = msgData.conversation_id;
45616
45651
  const session = this._sessions.get(convId);
45617
45652
  if (!session) {
@@ -45936,67 +45971,87 @@ ${messageText}`;
45936
45971
  * Sync missed messages across ALL sessions.
45937
45972
  * For each conversation, fetches messages since last sync and decrypts.
45938
45973
  */
45974
+ /**
45975
+ * Paginated sync: fetch missed messages in pages of 200, up to 5 pages (1000 messages).
45976
+ * Tracks message IDs in _syncMessageIds to prevent duplicate processing from concurrent WS messages.
45977
+ */
45939
45978
  async _syncMissedMessages() {
45940
45979
  if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
45980
+ this._syncMessageIds = /* @__PURE__ */ new Set();
45981
+ const MAX_PAGES = 5;
45982
+ const PAGE_SIZE = 200;
45983
+ let since = this._persisted.lastMessageTimestamp;
45984
+ let totalProcessed = 0;
45941
45985
  try {
45942
- const since = encodeURIComponent(this._persisted.lastMessageTimestamp);
45943
- const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${since}&limit=200`;
45944
- const res = await fetch(url, {
45945
- headers: { Authorization: `Bearer ${this._deviceJwt}` }
45946
- });
45947
- if (!res.ok) return;
45948
- const messages = await res.json();
45949
- for (const msg of messages) {
45950
- if (msg.sender_device_id === this._deviceId) continue;
45951
- const session = this._sessions.get(msg.conversation_id);
45952
- if (!session) {
45953
- console.warn(
45954
- `[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
45955
- );
45956
- continue;
45957
- }
45958
- try {
45959
- const encrypted = transportToEncryptedMessage({
45960
- header_blob: msg.header_blob,
45961
- ciphertext: msg.ciphertext
45962
- });
45963
- const plaintext = session.ratchet.decrypt(encrypted);
45964
- this._sendAck(msg.id);
45965
- if (!session.activated) {
45966
- session.activated = true;
45967
- console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
45986
+ for (let page = 0; page < MAX_PAGES; page++) {
45987
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=${PAGE_SIZE}`;
45988
+ const res = await fetch(url, {
45989
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
45990
+ });
45991
+ if (!res.ok) break;
45992
+ const messages = await res.json();
45993
+ if (messages.length === 0) break;
45994
+ for (const msg of messages) {
45995
+ if (msg.sender_device_id === this._deviceId) continue;
45996
+ if (this._syncMessageIds.has(msg.id)) continue;
45997
+ this._syncMessageIds.add(msg.id);
45998
+ const session = this._sessions.get(msg.conversation_id);
45999
+ if (!session) {
46000
+ console.warn(
46001
+ `[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
46002
+ );
46003
+ continue;
45968
46004
  }
45969
- let messageText;
45970
- let messageType;
45971
46005
  try {
45972
- const parsed = JSON.parse(plaintext);
45973
- messageType = parsed.type || "message";
45974
- messageText = parsed.text || plaintext;
45975
- } catch {
45976
- messageType = "message";
45977
- messageText = plaintext;
45978
- }
45979
- if (messageType === "message") {
45980
- const topicId = msg.topic_id;
45981
- this._appendHistory("owner", messageText, topicId);
45982
- const metadata = {
45983
- messageId: msg.id,
45984
- conversationId: msg.conversation_id,
45985
- timestamp: msg.created_at,
45986
- topicId
45987
- };
45988
- this.emit("message", messageText, metadata);
45989
- this.config.onMessage?.(messageText, metadata);
46006
+ const encrypted = transportToEncryptedMessage({
46007
+ header_blob: msg.header_blob,
46008
+ ciphertext: msg.ciphertext
46009
+ });
46010
+ const plaintext = session.ratchet.decrypt(encrypted);
46011
+ this._sendAck(msg.id);
46012
+ if (!session.activated) {
46013
+ session.activated = true;
46014
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
46015
+ }
46016
+ let messageText;
46017
+ let messageType;
46018
+ try {
46019
+ const parsed = JSON.parse(plaintext);
46020
+ messageType = parsed.type || "message";
46021
+ messageText = parsed.text || plaintext;
46022
+ } catch {
46023
+ messageType = "message";
46024
+ messageText = plaintext;
46025
+ }
46026
+ if (messageType === "message") {
46027
+ const topicId = msg.topic_id;
46028
+ this._appendHistory("owner", messageText, topicId);
46029
+ const metadata = {
46030
+ messageId: msg.id,
46031
+ conversationId: msg.conversation_id,
46032
+ timestamp: msg.created_at,
46033
+ topicId
46034
+ };
46035
+ this.emit("message", messageText, metadata);
46036
+ this.config.onMessage?.(messageText, metadata);
46037
+ }
46038
+ this._persisted.lastMessageTimestamp = msg.created_at;
46039
+ since = msg.created_at;
46040
+ totalProcessed++;
46041
+ } catch (err) {
46042
+ this.emit("error", err);
46043
+ break;
45990
46044
  }
45991
- this._persisted.lastMessageTimestamp = msg.created_at;
45992
- } catch (err) {
45993
- this.emit("error", err);
45994
- break;
45995
46045
  }
46046
+ await this._persistState();
46047
+ if (messages.length < PAGE_SIZE) break;
46048
+ }
46049
+ if (totalProcessed > 0) {
46050
+ console.log(`[SecureChannel] Synced ${totalProcessed} missed messages`);
45996
46051
  }
45997
- await this._persistState();
45998
46052
  } catch {
45999
46053
  }
46054
+ this._syncMessageIds = null;
46000
46055
  }
46001
46056
  _sendAck(messageId) {
46002
46057
  this._pendingAcks.push(messageId);
@@ -46008,6 +46063,36 @@ ${messageText}`;
46008
46063
  const batch = this._pendingAcks.splice(0, 50);
46009
46064
  this._ws.send(JSON.stringify({ event: "ack", data: { message_ids: batch } }));
46010
46065
  }
46066
+ async _flushOutboundQueue() {
46067
+ const queue = this._persisted?.outboundQueue;
46068
+ if (!queue || queue.length === 0 || !this._ws) return;
46069
+ console.log(`[SecureChannel] Flushing ${queue.length} queued outbound messages`);
46070
+ const messages = queue.splice(0);
46071
+ for (const msg of messages) {
46072
+ try {
46073
+ this._ws.send(
46074
+ JSON.stringify({
46075
+ event: "message",
46076
+ data: {
46077
+ conversation_id: msg.convId,
46078
+ header_blob: msg.headerBlob,
46079
+ ciphertext: msg.ciphertext,
46080
+ message_group_id: msg.messageGroupId,
46081
+ topic_id: msg.topicId
46082
+ }
46083
+ })
46084
+ );
46085
+ } catch (err) {
46086
+ if (!this._persisted.outboundQueue) {
46087
+ this._persisted.outboundQueue = [];
46088
+ }
46089
+ this._persisted.outboundQueue.push(msg);
46090
+ console.warn(`[SecureChannel] Failed to flush message, re-queued: ${err}`);
46091
+ break;
46092
+ }
46093
+ }
46094
+ await this._persistState();
46095
+ }
46011
46096
  _startPing(ws) {
46012
46097
  this._stopPing();
46013
46098
  this._pingTimer = setInterval(() => {
@@ -46043,7 +46128,9 @@ ${messageText}`;
46043
46128
  RECONNECT_MAX_MS
46044
46129
  );
46045
46130
  this._reconnectAttempt++;
46131
+ console.log(`[SecureChannel] Scheduling reconnect in ${delay}ms (attempt ${this._reconnectAttempt})`);
46046
46132
  this._reconnectTimer = setTimeout(() => {
46133
+ this._reconnectTimer = null;
46047
46134
  if (!this._stopped) {
46048
46135
  this._connect();
46049
46136
  }
@@ -46054,6 +46141,97 @@ ${messageText}`;
46054
46141
  this._state = newState;
46055
46142
  this.emit("state", newState);
46056
46143
  this.config.onStateChange?.(newState);
46144
+ if (newState === "disconnected" && !this._pollFallbackTimer && this._deviceJwt) {
46145
+ this._startPollFallback();
46146
+ }
46147
+ if (newState === "ready" || newState === "error" || this._stopped) {
46148
+ this._stopPollFallback();
46149
+ }
46150
+ }
46151
+ _startPollFallback() {
46152
+ this._stopPollFallback();
46153
+ console.log("[SecureChannel] Starting HTTP poll fallback (WS is down)");
46154
+ let interval = _SecureChannel.POLL_FALLBACK_INTERVAL_MS;
46155
+ const poll = async () => {
46156
+ if (this._state === "ready" || this._stopped) {
46157
+ this._stopPollFallback();
46158
+ return;
46159
+ }
46160
+ try {
46161
+ const since = this._persisted?.lastMessageTimestamp;
46162
+ if (!since || !this._deviceJwt) return;
46163
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=200`;
46164
+ const res = await fetch(url, {
46165
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
46166
+ });
46167
+ if (!res.ok) return;
46168
+ const messages = await res.json();
46169
+ let foundMessages = false;
46170
+ for (const msg of messages) {
46171
+ if (msg.sender_device_id === this._deviceId) continue;
46172
+ const session = this._sessions.get(msg.conversation_id);
46173
+ if (!session) continue;
46174
+ try {
46175
+ const encrypted = transportToEncryptedMessage({
46176
+ header_blob: msg.header_blob,
46177
+ ciphertext: msg.ciphertext
46178
+ });
46179
+ const plaintext = session.ratchet.decrypt(encrypted);
46180
+ if (!session.activated) {
46181
+ session.activated = true;
46182
+ }
46183
+ let messageText;
46184
+ let messageType;
46185
+ try {
46186
+ const parsed = JSON.parse(plaintext);
46187
+ messageType = parsed.type || "message";
46188
+ messageText = parsed.text || plaintext;
46189
+ } catch {
46190
+ messageType = "message";
46191
+ messageText = plaintext;
46192
+ }
46193
+ if (messageType === "message") {
46194
+ const topicId = msg.topic_id;
46195
+ this._appendHistory("owner", messageText, topicId);
46196
+ const metadata = {
46197
+ messageId: msg.id,
46198
+ conversationId: msg.conversation_id,
46199
+ timestamp: msg.created_at,
46200
+ topicId
46201
+ };
46202
+ this.emit("message", messageText, metadata);
46203
+ this.config.onMessage?.(messageText, metadata);
46204
+ foundMessages = true;
46205
+ }
46206
+ this._persisted.lastMessageTimestamp = msg.created_at;
46207
+ } catch (err) {
46208
+ this.emit("error", err);
46209
+ break;
46210
+ }
46211
+ }
46212
+ if (messages.length > 0) {
46213
+ await this._persistState();
46214
+ }
46215
+ const newInterval = foundMessages ? _SecureChannel.POLL_FALLBACK_INTERVAL_MS : _SecureChannel.POLL_FALLBACK_IDLE_MS;
46216
+ if (newInterval !== interval) {
46217
+ interval = newInterval;
46218
+ if (this._pollFallbackTimer) {
46219
+ clearInterval(this._pollFallbackTimer);
46220
+ this._pollFallbackTimer = setInterval(poll, interval);
46221
+ }
46222
+ }
46223
+ } catch {
46224
+ }
46225
+ };
46226
+ setTimeout(poll, 1e3);
46227
+ this._pollFallbackTimer = setInterval(poll, interval);
46228
+ }
46229
+ _stopPollFallback() {
46230
+ if (this._pollFallbackTimer) {
46231
+ clearInterval(this._pollFallbackTimer);
46232
+ this._pollFallbackTimer = null;
46233
+ console.log("[SecureChannel] Stopped HTTP poll fallback");
46234
+ }
46057
46235
  }
46058
46236
  _handleError(err) {
46059
46237
  this._setState("error");