@agentvault/secure-channel 0.2.0 → 0.4.1

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/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # @agentvault/secure-channel
2
+
3
+ End-to-end encrypted communication channel for AI agents on the [AgentVault](https://agentvault.chat) platform. Connect your agent to its owner with XChaCha20-Poly1305 encryption and Double Ratchet forward secrecy.
4
+
5
+ ## What's New in v0.4.0
6
+
7
+ **Webhook Notifications** — Your agent can now receive HTTP webhook callbacks when a new message arrives, even when it's not connected via WebSocket. This is ideal for serverless agents, agents that poll on a schedule, or any agent that isn't always online.
8
+
9
+ ### Upgrading from v0.3.x
10
+
11
+ ```bash
12
+ npm install @agentvault/secure-channel@latest
13
+ ```
14
+
15
+ Add `webhookUrl` to your config to enable:
16
+
17
+ ```js
18
+ const channel = new SecureChannel({
19
+ inviteToken: "your-token",
20
+ dataDir: "./agentvault-data",
21
+ apiUrl: "https://api.agentvault.chat",
22
+ webhookUrl: "https://your-server.com/webhook/agentvault", // NEW in 0.4.0
23
+ });
24
+ ```
25
+
26
+ No other code changes required — fully backward-compatible.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install @agentvault/secure-channel
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ### Option 1: CLI (Interactive)
39
+
40
+ Run directly with npx using the invite token from your AgentVault dashboard:
41
+
42
+ ```bash
43
+ npx @agentvault/secure-channel --token=YOUR_INVITE_TOKEN
44
+ ```
45
+
46
+ The CLI will:
47
+ 1. Enroll your agent with the server
48
+ 2. Display a fingerprint for the owner to verify
49
+ 3. Wait for owner approval
50
+ 4. Establish an encrypted channel
51
+ 5. Enter interactive mode where you can send/receive messages
52
+
53
+ **CLI Flags:**
54
+
55
+ | Flag | Default | Description |
56
+ |------|---------|-------------|
57
+ | `--token` | (required on first run) | Invite token from dashboard |
58
+ | `--name` | `"CLI Agent"` | Agent display name |
59
+ | `--data-dir` | `./agentvault-data` | Directory for persistent state |
60
+ | `--api-url` | `https://api.agentvault.chat` | API endpoint |
61
+
62
+ Environment variables (`AGENTVAULT_INVITE_TOKEN`, `AGENTVAULT_AGENT_NAME`, `AGENTVAULT_DATA_DIR`, `AGENTVAULT_API_URL`) work as alternatives to flags.
63
+
64
+ ### Option 2: SDK (Programmatic)
65
+
66
+ ```js
67
+ import { SecureChannel } from "@agentvault/secure-channel";
68
+
69
+ const channel = new SecureChannel({
70
+ inviteToken: "YOUR_INVITE_TOKEN",
71
+ dataDir: "./agentvault-data",
72
+ apiUrl: "https://api.agentvault.chat",
73
+ agentName: "My Agent",
74
+ });
75
+
76
+ channel.on("message", (text, metadata) => {
77
+ console.log(`Received: ${text}`);
78
+ // Echo back
79
+ channel.send(`You said: ${text}`);
80
+ });
81
+
82
+ channel.on("ready", () => {
83
+ console.log("Secure channel established!");
84
+ });
85
+
86
+ await channel.start();
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Webhook Notifications (v0.4.0+)
92
+
93
+ Enable webhook notifications so your agent gets an HTTP POST when a new message arrives — useful for agents that aren't always connected via WebSocket.
94
+
95
+ ### Setup
96
+
97
+ Add `webhookUrl` to your config:
98
+
99
+ ```js
100
+ const channel = new SecureChannel({
101
+ inviteToken: "YOUR_INVITE_TOKEN",
102
+ dataDir: "./agentvault-data",
103
+ apiUrl: "https://api.agentvault.chat",
104
+ webhookUrl: "https://your-server.com/webhook/agentvault",
105
+ });
106
+ ```
107
+
108
+ The webhook URL is registered automatically during device activation. The channel emits a `webhook_registered` event on success:
109
+
110
+ ```js
111
+ channel.on("webhook_registered", ({ url, secret }) => {
112
+ console.log(`Webhook registered at ${url}`);
113
+ // Save the secret to verify incoming webhooks
114
+ });
115
+ ```
116
+
117
+ ### Webhook Payload
118
+
119
+ When the owner sends a message, your webhook endpoint receives:
120
+
121
+ ```http
122
+ POST /webhook/agentvault HTTP/1.1
123
+ Content-Type: application/json
124
+ X-AgentVault-Event: new_message
125
+ X-AgentVault-Signature: sha256=<hmac-hex>
126
+
127
+ {
128
+ "event": "new_message",
129
+ "conversation_id": "uuid",
130
+ "sender_device_id": "uuid",
131
+ "message_id": "uuid",
132
+ "timestamp": "2026-02-17T12:00:00Z"
133
+ }
134
+ ```
135
+
136
+ ### Verifying Webhook Signatures
137
+
138
+ Each webhook includes an HMAC-SHA256 signature in the `X-AgentVault-Signature` header. Verify it using the secret from the `webhook_registered` event:
139
+
140
+ ```js
141
+ import crypto from "crypto";
142
+
143
+ function verifyWebhook(body, signature, secret) {
144
+ const expected = "sha256=" +
145
+ crypto.createHmac("sha256", secret).update(body).digest("hex");
146
+ return crypto.timingSafeEqual(
147
+ Buffer.from(signature),
148
+ Buffer.from(expected),
149
+ );
150
+ }
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Configuration Reference
156
+
157
+ ```ts
158
+ interface SecureChannelConfig {
159
+ // Required
160
+ inviteToken: string; // Invite token from the AgentVault dashboard
161
+ dataDir: string; // Directory for persistent state files
162
+ apiUrl: string; // API endpoint (e.g., "https://api.agentvault.chat")
163
+
164
+ // Optional
165
+ agentName?: string; // Display name (default: "CLI Agent")
166
+ platform?: string; // Platform identifier (e.g., "node")
167
+ maxHistorySize?: number; // Max stored messages for cross-device replay (default: 500)
168
+ webhookUrl?: string; // Webhook URL for new message notifications (v0.4.0+)
169
+
170
+ // Callbacks (alternative to event listeners)
171
+ onMessage?: (text: string, metadata: MessageMetadata) => void;
172
+ onStateChange?: (state: ChannelState) => void;
173
+ }
174
+ ```
175
+
176
+ ## Events
177
+
178
+ | Event | Payload | Description |
179
+ |-------|---------|-------------|
180
+ | `message` | `(text: string, metadata: MessageMetadata)` | Owner sent a message |
181
+ | `ready` | none | WebSocket connected, channel operational |
182
+ | `state` | `(state: ChannelState)` | State transition occurred |
183
+ | `error` | `(error: Error)` | Fatal error |
184
+ | `webhook_registered` | `({ url: string, secret: string })` | Webhook registered (v0.4.0+) |
185
+
186
+ ## Channel States
187
+
188
+ `idle` → `enrolling` → `polling` → `activating` → `connecting` → `ready`
189
+
190
+ If disconnected: `ready` → `disconnected` → `connecting` → `ready` (auto-reconnect)
191
+
192
+ ## API
193
+
194
+ | Method | Description |
195
+ |--------|-------------|
196
+ | `start()` | Initialize, enroll, and connect |
197
+ | `send(text)` | Encrypt and send message to all owner devices |
198
+ | `stop()` | Gracefully disconnect |
199
+
200
+ | Property | Description |
201
+ |----------|-------------|
202
+ | `state` | Current channel state |
203
+ | `deviceId` | Agent's device ID (after enrollment) |
204
+ | `fingerprint` | Device fingerprint for verification |
205
+ | `conversationId` | Primary conversation ID |
206
+ | `conversationIds` | All active conversation IDs (multi-device) |
207
+ | `sessionCount` | Number of active encrypted sessions |
208
+
209
+ ## Multi-Device Support
210
+
211
+ AgentVault supports multiple owner devices (e.g., desktop + mobile). The channel automatically:
212
+ - Maintains independent encrypted sessions per owner device
213
+ - Fans out `send()` to all active sessions
214
+ - Stores message history for cross-device replay (up to `maxHistorySize`)
215
+ - Replays history when a new device connects
216
+
217
+ No additional configuration needed — multi-device is handled transparently.
218
+
219
+ ## Security
220
+
221
+ - **XChaCha20-Poly1305** symmetric encryption (192-bit nonces)
222
+ - **X3DH** key agreement (Ed25519 + X25519)
223
+ - **Double Ratchet** for forward secrecy — old keys deleted after use
224
+ - **Zero-knowledge server** — the server never sees plaintext
225
+ - **HMAC-SHA256** webhook signatures for authenticity verification
226
+
227
+ ## License
228
+
229
+ MIT
package/dist/channel.d.ts CHANGED
@@ -25,6 +25,10 @@ export declare class SecureChannel extends EventEmitter {
25
25
  /** Returns the number of active sessions. */
26
26
  get sessionCount(): number;
27
27
  start(): Promise<void>;
28
+ /**
29
+ * Append a message to persistent history for cross-device replay.
30
+ */
31
+ private _appendHistory;
28
32
  /**
29
33
  * Encrypt and send a message to ALL owner devices (fanout).
30
34
  * Each session gets the same plaintext encrypted independently.
@@ -46,6 +50,11 @@ export declare class SecureChannel extends EventEmitter {
46
50
  * This allows all owner devices to see messages from any single device.
47
51
  */
48
52
  private _relaySyncToSiblings;
53
+ /**
54
+ * Send stored message history to a newly-activated session.
55
+ * Batches all history into a single encrypted message.
56
+ */
57
+ private _replayHistoryToSession;
49
58
  /**
50
59
  * Handle a device_linked event: a new owner device has joined.
51
60
  * Fetches the new device's public keys, performs X3DH, and initializes
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAa3C,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,EAIb,MAAM,YAAY,CAAC;AAgDpB,qBAAa,aAAc,SAAQ,YAAY;IAiBjC,OAAO,CAAC,MAAM;IAhB1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,sBAAsB,CAAc;IAC5C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAGH;IACd,OAAO,CAAC,GAAG,CAA0B;IACrC,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,eAAe,CAA8C;IACrE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAA+B;gBAE7B,MAAM,EAAE,mBAAmB;IAI/C,IAAI,KAAK,IAAI,YAAY,CAExB;IAED,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,iEAAiE;IACjE,IAAI,cAAc,IAAI,MAAM,GAAG,IAAI,CAElC;IAED,2CAA2C;IAC3C,IAAI,eAAe,IAAI,MAAM,EAAE,CAE9B;IAED,6CAA6C;IAC7C,IAAI,YAAY,IAAI,MAAM,CAEzB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkC5B;;;OAGG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA4BtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBb,OAAO;IA+CrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IAyEvB,OAAO,CAAC,QAAQ;IA4DhB;;;;OAIG;YACW,sBAAsB;IAmEpC;;;OAGG;YACW,oBAAoB;IAiClC;;;;OAIG;YACW,mBAAmB;IAuEjC;;;OAGG;YACW,mBAAmB;IA+EjC,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAKpB;;;OAGG;YACW,aAAa;CAa5B"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAa3C,OAAO,KAAK,EACV,mBAAmB,EACnB,YAAY,EAKb,MAAM,YAAY,CAAC;AAiDpB,qBAAa,aAAc,SAAQ,YAAY;IAiBjC,OAAO,CAAC,MAAM;IAhB1B,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,sBAAsB,CAAc;IAC5C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,SAAS,CAGH;IACd,OAAO,CAAC,GAAG,CAA0B;IACrC,OAAO,CAAC,UAAU,CAA8C;IAChE,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,eAAe,CAA8C;IACrE,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAA+B;gBAE7B,MAAM,EAAE,mBAAmB;IAI/C,IAAI,KAAK,IAAI,YAAY,CAExB;IAED,IAAI,QAAQ,IAAI,MAAM,GAAG,IAAI,CAE5B;IAED,IAAI,WAAW,IAAI,MAAM,GAAG,IAAI,CAE/B;IAED,iEAAiE;IACjE,IAAI,cAAc,IAAI,MAAM,GAAG,IAAI,CAElC;IAED,2CAA2C;IAC3C,IAAI,eAAe,IAAI,MAAM,EAAE,CAE9B;IAED,6CAA6C;IAC7C,IAAI,YAAY,IAAI,MAAM,CAEzB;IAEK,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC5B;;OAEG;IACH,OAAO,CAAC,cAAc;IAmBtB;;;OAGG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsCtC,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;YAoBb,OAAO;IAgDrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IA2HvB,OAAO,CAAC,QAAQ;IA4DhB;;;;OAIG;YACW,sBAAsB;IAoFpC;;;OAGG;YACW,oBAAoB;IAmClC;;;OAGG;YACW,uBAAuB;IAkCrC;;;;OAIG;YACW,mBAAmB;IAkEjC;;;OAGG;YACW,mBAAmB;IAwFjC,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAKpB;;;OAGG;YACW,aAAa;CAc5B"}
package/dist/cli.js CHANGED
@@ -45054,7 +45054,8 @@ function migratePersistedState(raw) {
45054
45054
  identityKeypair: legacy.identityKeypair,
45055
45055
  ephemeralKeypair: legacy.ephemeralKeypair,
45056
45056
  fingerprint: legacy.fingerprint,
45057
- lastMessageTimestamp: legacy.lastMessageTimestamp
45057
+ lastMessageTimestamp: legacy.lastMessageTimestamp,
45058
+ messageHistory: []
45058
45059
  };
45059
45060
  }
45060
45061
  var SecureChannel = class extends EventEmitter {
@@ -45101,6 +45102,9 @@ var SecureChannel = class extends EventEmitter {
45101
45102
  const raw = await loadState(this.config.dataDir);
45102
45103
  if (raw) {
45103
45104
  this._persisted = migratePersistedState(raw);
45105
+ if (!this._persisted.messageHistory) {
45106
+ this._persisted.messageHistory = [];
45107
+ }
45104
45108
  this._deviceId = this._persisted.deviceId;
45105
45109
  this._deviceJwt = this._persisted.deviceJwt;
45106
45110
  this._primaryConversationId = this._persisted.primaryConversationId;
@@ -45112,7 +45116,8 @@ var SecureChannel = class extends EventEmitter {
45112
45116
  const ratchet = DoubleRatchet.deserialize(sessionData.ratchetState);
45113
45117
  this._sessions.set(convId, {
45114
45118
  ownerDeviceId: sessionData.ownerDeviceId,
45115
- ratchet
45119
+ ratchet,
45120
+ activated: sessionData.activated ?? false
45116
45121
  });
45117
45122
  }
45118
45123
  }
@@ -45121,6 +45126,24 @@ var SecureChannel = class extends EventEmitter {
45121
45126
  }
45122
45127
  await this._enroll();
45123
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
+ }
45124
45147
  /**
45125
45148
  * Encrypt and send a message to ALL owner devices (fanout).
45126
45149
  * Each session gets the same plaintext encrypted independently.
@@ -45129,8 +45152,12 @@ var SecureChannel = class extends EventEmitter {
45129
45152
  if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
45130
45153
  throw new Error("Channel is not ready");
45131
45154
  }
45155
+ this._appendHistory("agent", plaintext);
45132
45156
  const messageGroupId = randomUUID();
45133
45157
  for (const [convId, session] of this._sessions) {
45158
+ if (!session.activated) {
45159
+ continue;
45160
+ }
45134
45161
  const encrypted = session.ratchet.encrypt(plaintext);
45135
45162
  const transport = encryptedMessageToTransport(encrypted);
45136
45163
  this._ws.send(
@@ -45201,7 +45228,8 @@ var SecureChannel = class extends EventEmitter {
45201
45228
  publicKey: bytesToHex(ephemeral.publicKey),
45202
45229
  privateKey: bytesToHex(ephemeral.privateKey)
45203
45230
  },
45204
- fingerprint: result.fingerprint
45231
+ fingerprint: result.fingerprint,
45232
+ messageHistory: []
45205
45233
  };
45206
45234
  this._poll();
45207
45235
  } catch (err) {
@@ -45252,36 +45280,73 @@ var SecureChannel = class extends EventEmitter {
45252
45280
  this._deviceJwt = result.device_jwt;
45253
45281
  const identity = this._persisted.identityKeypair;
45254
45282
  const ephemeral = this._persisted.ephemeralKeypair;
45255
- const sharedSecret = performX3DH({
45256
- myIdentityPrivate: hexToBytes(identity.privateKey),
45257
- myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45258
- theirIdentityPublic: hexToBytes(result.owner_identity_public_key),
45259
- theirEphemeralPublic: hexToBytes(
45260
- result.owner_ephemeral_public_key ?? result.owner_identity_public_key
45261
- ),
45262
- isInitiator: false
45263
- });
45264
- const ratchet = DoubleRatchet.initReceiver(sharedSecret, {
45265
- publicKey: hexToBytes(identity.publicKey),
45266
- privateKey: hexToBytes(identity.privateKey),
45267
- keyType: "ed25519"
45268
- });
45269
- this._sessions.set(primary.conversation_id, {
45270
- ownerDeviceId: primary.owner_device_id,
45271
- ratchet
45272
- });
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
+ }
45273
45313
  this._persisted = {
45274
45314
  ...this._persisted,
45275
45315
  deviceJwt: result.device_jwt,
45276
45316
  primaryConversationId: primary.conversation_id,
45277
- sessions: {
45278
- [primary.conversation_id]: {
45279
- ownerDeviceId: primary.owner_device_id,
45280
- ratchetState: ratchet.serialize()
45281
- }
45282
- }
45317
+ sessions,
45318
+ messageHistory: this._persisted.messageHistory ?? []
45283
45319
  };
45284
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
+ }
45285
45350
  this._connect();
45286
45351
  } catch (err) {
45287
45352
  this._handleError(err);
@@ -45352,6 +45417,10 @@ var SecureChannel = class extends EventEmitter {
45352
45417
  ciphertext: msgData.ciphertext
45353
45418
  });
45354
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
+ }
45355
45424
  let messageText;
45356
45425
  let messageType;
45357
45426
  try {
@@ -45362,7 +45431,14 @@ var SecureChannel = class extends EventEmitter {
45362
45431
  messageType = "message";
45363
45432
  messageText = plaintext;
45364
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
+ }
45365
45440
  if (messageType === "message") {
45441
+ this._appendHistory("owner", messageText);
45366
45442
  const metadata = {
45367
45443
  messageId: msgData.message_id,
45368
45444
  conversationId: convId,
@@ -45391,6 +45467,7 @@ var SecureChannel = class extends EventEmitter {
45391
45467
  });
45392
45468
  for (const [siblingConvId, siblingSession] of this._sessions) {
45393
45469
  if (siblingConvId === sourceConvId) continue;
45470
+ if (!siblingSession.activated) continue;
45394
45471
  const syncEncrypted = siblingSession.ratchet.encrypt(syncPayload);
45395
45472
  const syncTransport = encryptedMessageToTransport(syncEncrypted);
45396
45473
  this._ws.send(
@@ -45405,6 +45482,38 @@ var SecureChannel = class extends EventEmitter {
45405
45482
  );
45406
45483
  }
45407
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
+ }
45408
45517
  /**
45409
45518
  * Handle a device_linked event: a new owner device has joined.
45410
45519
  * Fetches the new device's public keys, performs X3DH, and initializes
@@ -45415,27 +45524,20 @@ var SecureChannel = class extends EventEmitter {
45415
45524
  `[SecureChannel] New owner device linked: ${event.owner_device_id.slice(0, 8)}...`
45416
45525
  );
45417
45526
  try {
45418
- const keysRes = await fetch(
45419
- `${this.config.apiUrl}/api/v1/conversations/${event.conversation_id}/keys`,
45420
- {
45421
- headers: { Authorization: `Bearer ${this._deviceJwt}` }
45422
- }
45423
- );
45424
- if (!keysRes.ok) {
45527
+ if (!event.owner_identity_public_key) {
45425
45528
  console.error(
45426
- `[SecureChannel] Failed to fetch keys for linked device: ${keysRes.status}`
45529
+ `[SecureChannel] device_linked event missing owner keys for conv ${event.conversation_id.slice(0, 8)}...`
45427
45530
  );
45428
45531
  return;
45429
45532
  }
45430
- const keys = await keysRes.json();
45431
45533
  const identity = this._persisted.identityKeypair;
45432
45534
  const ephemeral = this._persisted.ephemeralKeypair;
45433
45535
  const sharedSecret = performX3DH({
45434
45536
  myIdentityPrivate: hexToBytes(identity.privateKey),
45435
45537
  myEphemeralPrivate: hexToBytes(ephemeral.privateKey),
45436
- theirIdentityPublic: hexToBytes(keys.identity_public_key),
45538
+ theirIdentityPublic: hexToBytes(event.owner_identity_public_key),
45437
45539
  theirEphemeralPublic: hexToBytes(
45438
- keys.ephemeral_public_key ?? keys.identity_public_key
45540
+ event.owner_ephemeral_public_key ?? event.owner_identity_public_key
45439
45541
  ),
45440
45542
  isInitiator: false
45441
45543
  });
@@ -45446,15 +45548,18 @@ var SecureChannel = class extends EventEmitter {
45446
45548
  });
45447
45549
  this._sessions.set(event.conversation_id, {
45448
45550
  ownerDeviceId: event.owner_device_id,
45449
- ratchet
45551
+ ratchet,
45552
+ activated: false
45553
+ // Wait for owner's first message
45450
45554
  });
45451
45555
  this._persisted.sessions[event.conversation_id] = {
45452
45556
  ownerDeviceId: event.owner_device_id,
45453
- ratchetState: ratchet.serialize()
45557
+ ratchetState: ratchet.serialize(),
45558
+ activated: false
45454
45559
  };
45455
45560
  await this._persistState();
45456
45561
  console.log(
45457
- `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}...`
45562
+ `[SecureChannel] Session initialized for device ${event.owner_device_id.slice(0, 8)}... (conv ${event.conversation_id.slice(0, 8)}...)`
45458
45563
  );
45459
45564
  } catch (err) {
45460
45565
  console.error(
@@ -45493,6 +45598,10 @@ var SecureChannel = class extends EventEmitter {
45493
45598
  ciphertext: msg.ciphertext
45494
45599
  });
45495
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
+ }
45496
45605
  let messageText;
45497
45606
  let messageType;
45498
45607
  try {
@@ -45504,6 +45613,7 @@ var SecureChannel = class extends EventEmitter {
45504
45613
  messageText = plaintext;
45505
45614
  }
45506
45615
  if (messageType === "message") {
45616
+ this._appendHistory("owner", messageText);
45507
45617
  const metadata = {
45508
45618
  messageId: msg.id,
45509
45619
  conversationId: msg.conversation_id,
@@ -45554,7 +45664,8 @@ var SecureChannel = class extends EventEmitter {
45554
45664
  for (const [convId, session] of this._sessions) {
45555
45665
  this._persisted.sessions[convId] = {
45556
45666
  ownerDeviceId: session.ownerDeviceId,
45557
- ratchetState: session.ratchet.serialize()
45667
+ ratchetState: session.ratchet.serialize(),
45668
+ activated: session.activated
45558
45669
  };
45559
45670
  }
45560
45671
  await saveState(this.config.dataDir, this._persisted);