@agentvault/secure-channel 0.6.15 → 0.6.16

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);
@@ -45569,6 +45596,7 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45569
45596
  this._reconnectAttempt = 0;
45570
45597
  this._startPing(ws);
45571
45598
  await this._syncMissedMessages();
45599
+ await this._flushOutboundQueue();
45572
45600
  this._setState("ready");
45573
45601
  this.emit("ready");
45574
45602
  });
@@ -45612,6 +45640,9 @@ var SecureChannel = class _SecureChannel extends EventEmitter {
45612
45640
  */
45613
45641
  async _handleIncomingMessage(msgData) {
45614
45642
  if (msgData.sender_device_id === this._deviceId) return;
45643
+ if (this._syncMessageIds?.has(msgData.message_id)) {
45644
+ return;
45645
+ }
45615
45646
  const convId = msgData.conversation_id;
45616
45647
  const session = this._sessions.get(convId);
45617
45648
  if (!session) {
@@ -45936,67 +45967,87 @@ ${messageText}`;
45936
45967
  * Sync missed messages across ALL sessions.
45937
45968
  * For each conversation, fetches messages since last sync and decrypts.
45938
45969
  */
45970
+ /**
45971
+ * Paginated sync: fetch missed messages in pages of 200, up to 5 pages (1000 messages).
45972
+ * Tracks message IDs in _syncMessageIds to prevent duplicate processing from concurrent WS messages.
45973
+ */
45939
45974
  async _syncMissedMessages() {
45940
45975
  if (!this._persisted?.lastMessageTimestamp || !this._deviceJwt) return;
45976
+ this._syncMessageIds = /* @__PURE__ */ new Set();
45977
+ const MAX_PAGES = 5;
45978
+ const PAGE_SIZE = 200;
45979
+ let since = this._persisted.lastMessageTimestamp;
45980
+ let totalProcessed = 0;
45941
45981
  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`);
45982
+ for (let page = 0; page < MAX_PAGES; page++) {
45983
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=${PAGE_SIZE}`;
45984
+ const res = await fetch(url, {
45985
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
45986
+ });
45987
+ if (!res.ok) break;
45988
+ const messages = await res.json();
45989
+ if (messages.length === 0) break;
45990
+ for (const msg of messages) {
45991
+ if (msg.sender_device_id === this._deviceId) continue;
45992
+ if (this._syncMessageIds.has(msg.id)) continue;
45993
+ this._syncMessageIds.add(msg.id);
45994
+ const session = this._sessions.get(msg.conversation_id);
45995
+ if (!session) {
45996
+ console.warn(
45997
+ `[SecureChannel] No session for conversation ${msg.conversation_id} during sync, skipping`
45998
+ );
45999
+ continue;
45968
46000
  }
45969
- let messageText;
45970
- let messageType;
45971
46001
  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);
46002
+ const encrypted = transportToEncryptedMessage({
46003
+ header_blob: msg.header_blob,
46004
+ ciphertext: msg.ciphertext
46005
+ });
46006
+ const plaintext = session.ratchet.decrypt(encrypted);
46007
+ this._sendAck(msg.id);
46008
+ if (!session.activated) {
46009
+ session.activated = true;
46010
+ console.log(`[SecureChannel] Session ${msg.conversation_id.slice(0, 8)}... activated during sync`);
46011
+ }
46012
+ let messageText;
46013
+ let messageType;
46014
+ try {
46015
+ const parsed = JSON.parse(plaintext);
46016
+ messageType = parsed.type || "message";
46017
+ messageText = parsed.text || plaintext;
46018
+ } catch {
46019
+ messageType = "message";
46020
+ messageText = plaintext;
46021
+ }
46022
+ if (messageType === "message") {
46023
+ const topicId = msg.topic_id;
46024
+ this._appendHistory("owner", messageText, topicId);
46025
+ const metadata = {
46026
+ messageId: msg.id,
46027
+ conversationId: msg.conversation_id,
46028
+ timestamp: msg.created_at,
46029
+ topicId
46030
+ };
46031
+ this.emit("message", messageText, metadata);
46032
+ this.config.onMessage?.(messageText, metadata);
46033
+ }
46034
+ this._persisted.lastMessageTimestamp = msg.created_at;
46035
+ since = msg.created_at;
46036
+ totalProcessed++;
46037
+ } catch (err) {
46038
+ this.emit("error", err);
46039
+ break;
45990
46040
  }
45991
- this._persisted.lastMessageTimestamp = msg.created_at;
45992
- } catch (err) {
45993
- this.emit("error", err);
45994
- break;
45995
46041
  }
46042
+ await this._persistState();
46043
+ if (messages.length < PAGE_SIZE) break;
46044
+ }
46045
+ if (totalProcessed > 0) {
46046
+ console.log(`[SecureChannel] Synced ${totalProcessed} missed messages`);
45996
46047
  }
45997
- await this._persistState();
45998
46048
  } catch {
45999
46049
  }
46050
+ this._syncMessageIds = null;
46000
46051
  }
46001
46052
  _sendAck(messageId) {
46002
46053
  this._pendingAcks.push(messageId);
@@ -46008,6 +46059,36 @@ ${messageText}`;
46008
46059
  const batch = this._pendingAcks.splice(0, 50);
46009
46060
  this._ws.send(JSON.stringify({ event: "ack", data: { message_ids: batch } }));
46010
46061
  }
46062
+ async _flushOutboundQueue() {
46063
+ const queue = this._persisted?.outboundQueue;
46064
+ if (!queue || queue.length === 0 || !this._ws) return;
46065
+ console.log(`[SecureChannel] Flushing ${queue.length} queued outbound messages`);
46066
+ const messages = queue.splice(0);
46067
+ for (const msg of messages) {
46068
+ try {
46069
+ this._ws.send(
46070
+ JSON.stringify({
46071
+ event: "message",
46072
+ data: {
46073
+ conversation_id: msg.convId,
46074
+ header_blob: msg.headerBlob,
46075
+ ciphertext: msg.ciphertext,
46076
+ message_group_id: msg.messageGroupId,
46077
+ topic_id: msg.topicId
46078
+ }
46079
+ })
46080
+ );
46081
+ } catch (err) {
46082
+ if (!this._persisted.outboundQueue) {
46083
+ this._persisted.outboundQueue = [];
46084
+ }
46085
+ this._persisted.outboundQueue.push(msg);
46086
+ console.warn(`[SecureChannel] Failed to flush message, re-queued: ${err}`);
46087
+ break;
46088
+ }
46089
+ }
46090
+ await this._persistState();
46091
+ }
46011
46092
  _startPing(ws) {
46012
46093
  this._stopPing();
46013
46094
  this._pingTimer = setInterval(() => {
@@ -46054,6 +46135,97 @@ ${messageText}`;
46054
46135
  this._state = newState;
46055
46136
  this.emit("state", newState);
46056
46137
  this.config.onStateChange?.(newState);
46138
+ if (newState === "disconnected" && !this._pollFallbackTimer && this._deviceJwt) {
46139
+ this._startPollFallback();
46140
+ }
46141
+ if (newState === "ready" || newState === "error" || this._stopped) {
46142
+ this._stopPollFallback();
46143
+ }
46144
+ }
46145
+ _startPollFallback() {
46146
+ this._stopPollFallback();
46147
+ console.log("[SecureChannel] Starting HTTP poll fallback (WS is down)");
46148
+ let interval = _SecureChannel.POLL_FALLBACK_INTERVAL_MS;
46149
+ const poll = async () => {
46150
+ if (this._state === "ready" || this._stopped) {
46151
+ this._stopPollFallback();
46152
+ return;
46153
+ }
46154
+ try {
46155
+ const since = this._persisted?.lastMessageTimestamp;
46156
+ if (!since || !this._deviceJwt) return;
46157
+ const url = `${this.config.apiUrl}/api/v1/devices/${this._deviceId}/messages?since=${encodeURIComponent(since)}&limit=200`;
46158
+ const res = await fetch(url, {
46159
+ headers: { Authorization: `Bearer ${this._deviceJwt}` }
46160
+ });
46161
+ if (!res.ok) return;
46162
+ const messages = await res.json();
46163
+ let foundMessages = false;
46164
+ for (const msg of messages) {
46165
+ if (msg.sender_device_id === this._deviceId) continue;
46166
+ const session = this._sessions.get(msg.conversation_id);
46167
+ if (!session) continue;
46168
+ try {
46169
+ const encrypted = transportToEncryptedMessage({
46170
+ header_blob: msg.header_blob,
46171
+ ciphertext: msg.ciphertext
46172
+ });
46173
+ const plaintext = session.ratchet.decrypt(encrypted);
46174
+ if (!session.activated) {
46175
+ session.activated = true;
46176
+ }
46177
+ let messageText;
46178
+ let messageType;
46179
+ try {
46180
+ const parsed = JSON.parse(plaintext);
46181
+ messageType = parsed.type || "message";
46182
+ messageText = parsed.text || plaintext;
46183
+ } catch {
46184
+ messageType = "message";
46185
+ messageText = plaintext;
46186
+ }
46187
+ if (messageType === "message") {
46188
+ const topicId = msg.topic_id;
46189
+ this._appendHistory("owner", messageText, topicId);
46190
+ const metadata = {
46191
+ messageId: msg.id,
46192
+ conversationId: msg.conversation_id,
46193
+ timestamp: msg.created_at,
46194
+ topicId
46195
+ };
46196
+ this.emit("message", messageText, metadata);
46197
+ this.config.onMessage?.(messageText, metadata);
46198
+ foundMessages = true;
46199
+ }
46200
+ this._persisted.lastMessageTimestamp = msg.created_at;
46201
+ } catch (err) {
46202
+ this.emit("error", err);
46203
+ break;
46204
+ }
46205
+ }
46206
+ if (messages.length > 0) {
46207
+ await this._persistState();
46208
+ }
46209
+ const newInterval = foundMessages ? _SecureChannel.POLL_FALLBACK_INTERVAL_MS : _SecureChannel.POLL_FALLBACK_IDLE_MS;
46210
+ if (newInterval !== interval) {
46211
+ interval = newInterval;
46212
+ if (this._pollFallbackTimer) {
46213
+ clearInterval(this._pollFallbackTimer);
46214
+ this._pollFallbackTimer = setInterval(poll, interval);
46215
+ }
46216
+ }
46217
+ } catch {
46218
+ }
46219
+ };
46220
+ setTimeout(poll, 1e3);
46221
+ this._pollFallbackTimer = setInterval(poll, interval);
46222
+ }
46223
+ _stopPollFallback() {
46224
+ if (this._pollFallbackTimer) {
46225
+ clearInterval(this._pollFallbackTimer);
46226
+ this._pollFallbackTimer = null;
46227
+ console.log("[SecureChannel] Stopped HTTP poll fallback");
46228
+ }
46057
46229
  }
46058
46230
  _handleError(err) {
46059
46231
  this._setState("error");