@agentvault/secure-channel 0.4.0 → 0.4.2
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 +229 -0
- package/dist/channel.d.ts +21 -1
- package/dist/channel.d.ts.map +1 -1
- package/dist/cli.js +108 -13
- package/dist/cli.js.map +3 -3
- package/dist/index.js +108 -13
- package/dist/index.js.map +3 -3
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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
|
@@ -33,8 +33,28 @@ export declare class SecureChannel extends EventEmitter {
|
|
|
33
33
|
* Encrypt and send a message to ALL owner devices (fanout).
|
|
34
34
|
* Each session gets the same plaintext encrypted independently.
|
|
35
35
|
*/
|
|
36
|
-
send(plaintext: string
|
|
36
|
+
send(plaintext: string, options?: {
|
|
37
|
+
topicId?: string;
|
|
38
|
+
}): Promise<void>;
|
|
37
39
|
stop(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Create a new topic within the conversation group.
|
|
42
|
+
* Requires the channel to be initialized with a groupId (from activation).
|
|
43
|
+
*/
|
|
44
|
+
createTopic(name: string): Promise<{
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
isDefault: boolean;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* List all topics in the conversation group.
|
|
51
|
+
* Requires the channel to be initialized with a groupId (from activation).
|
|
52
|
+
*/
|
|
53
|
+
listTopics(): Promise<Array<{
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
isDefault: boolean;
|
|
57
|
+
}>>;
|
|
38
58
|
private _enroll;
|
|
39
59
|
private _poll;
|
|
40
60
|
private _activate;
|
package/dist/channel.d.ts.map
CHANGED
|
@@ -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,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;
|
|
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;IAuBtB;;;OAGG;IACG,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAyCtE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB3B;;;OAGG;IACG,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;IAsC1F;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;YAiCtE,OAAO;IAgDrB,OAAO,CAAC,KAAK;YAgCC,SAAS;IAyIvB,OAAO,CAAC,QAAQ;IAsEhB;;;;OAIG;YACW,sBAAsB;IAwFpC;;;OAGG;YACW,oBAAoB;IAqClC;;;OAGG;YACW,uBAAuB;IAkCrC;;;;OAIG;YACW,mBAAmB;IAkEjC;;;OAGG;YACW,mBAAmB;IA4FjC,OAAO,CAAC,kBAAkB;IAiB1B,OAAO,CAAC,SAAS;IAOjB,OAAO,CAAC,YAAY;IAKpB;;;OAGG;YACW,aAAa;CAc5B"}
|
package/dist/cli.js
CHANGED
|
@@ -45129,17 +45129,21 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45129
45129
|
/**
|
|
45130
45130
|
* Append a message to persistent history for cross-device replay.
|
|
45131
45131
|
*/
|
|
45132
|
-
_appendHistory(sender, text) {
|
|
45132
|
+
_appendHistory(sender, text, topicId) {
|
|
45133
45133
|
if (!this._persisted) return;
|
|
45134
45134
|
if (!this._persisted.messageHistory) {
|
|
45135
45135
|
this._persisted.messageHistory = [];
|
|
45136
45136
|
}
|
|
45137
45137
|
const maxSize = this.config.maxHistorySize ?? 500;
|
|
45138
|
-
|
|
45138
|
+
const entry = {
|
|
45139
45139
|
sender,
|
|
45140
45140
|
text,
|
|
45141
45141
|
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
45142
|
-
}
|
|
45142
|
+
};
|
|
45143
|
+
if (topicId) {
|
|
45144
|
+
entry.topicId = topicId;
|
|
45145
|
+
}
|
|
45146
|
+
this._persisted.messageHistory.push(entry);
|
|
45143
45147
|
if (this._persisted.messageHistory.length > maxSize) {
|
|
45144
45148
|
this._persisted.messageHistory = this._persisted.messageHistory.slice(-maxSize);
|
|
45145
45149
|
}
|
|
@@ -45148,11 +45152,12 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45148
45152
|
* Encrypt and send a message to ALL owner devices (fanout).
|
|
45149
45153
|
* Each session gets the same plaintext encrypted independently.
|
|
45150
45154
|
*/
|
|
45151
|
-
async send(plaintext) {
|
|
45155
|
+
async send(plaintext, options) {
|
|
45152
45156
|
if (this._state !== "ready" || this._sessions.size === 0 || !this._ws) {
|
|
45153
45157
|
throw new Error("Channel is not ready");
|
|
45154
45158
|
}
|
|
45155
|
-
this.
|
|
45159
|
+
const topicId = options?.topicId ?? this._persisted?.defaultTopicId;
|
|
45160
|
+
this._appendHistory("agent", plaintext, topicId);
|
|
45156
45161
|
const messageGroupId = randomUUID();
|
|
45157
45162
|
for (const [convId, session] of this._sessions) {
|
|
45158
45163
|
if (!session.activated) {
|
|
@@ -45167,7 +45172,8 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45167
45172
|
conversation_id: convId,
|
|
45168
45173
|
header_blob: transport.header_blob,
|
|
45169
45174
|
ciphertext: transport.ciphertext,
|
|
45170
|
-
message_group_id: messageGroupId
|
|
45175
|
+
message_group_id: messageGroupId,
|
|
45176
|
+
topic_id: topicId
|
|
45171
45177
|
}
|
|
45172
45178
|
})
|
|
45173
45179
|
);
|
|
@@ -45191,6 +45197,72 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45191
45197
|
}
|
|
45192
45198
|
this._setState("disconnected");
|
|
45193
45199
|
}
|
|
45200
|
+
// --- Topic management ---
|
|
45201
|
+
/**
|
|
45202
|
+
* Create a new topic within the conversation group.
|
|
45203
|
+
* Requires the channel to be initialized with a groupId (from activation).
|
|
45204
|
+
*/
|
|
45205
|
+
async createTopic(name2) {
|
|
45206
|
+
if (!this._persisted?.groupId) {
|
|
45207
|
+
throw new Error("Channel not initialized or groupId unknown");
|
|
45208
|
+
}
|
|
45209
|
+
if (!this._deviceJwt) {
|
|
45210
|
+
throw new Error("Channel not authenticated");
|
|
45211
|
+
}
|
|
45212
|
+
const res = await fetch(`${this.config.apiUrl}/api/v1/topics`, {
|
|
45213
|
+
method: "POST",
|
|
45214
|
+
headers: {
|
|
45215
|
+
"Content-Type": "application/json",
|
|
45216
|
+
Authorization: `Bearer ${this._deviceJwt}`
|
|
45217
|
+
},
|
|
45218
|
+
body: JSON.stringify({
|
|
45219
|
+
group_id: this._persisted.groupId,
|
|
45220
|
+
name: name2,
|
|
45221
|
+
creator_device_id: this._persisted.deviceId
|
|
45222
|
+
})
|
|
45223
|
+
});
|
|
45224
|
+
if (!res.ok) {
|
|
45225
|
+
const detail = await res.text();
|
|
45226
|
+
throw new Error(`Create topic failed (${res.status}): ${detail}`);
|
|
45227
|
+
}
|
|
45228
|
+
const resp = await res.json();
|
|
45229
|
+
const topic = { id: resp.id, name: resp.name, isDefault: resp.is_default };
|
|
45230
|
+
if (!this._persisted.topics) {
|
|
45231
|
+
this._persisted.topics = [];
|
|
45232
|
+
}
|
|
45233
|
+
this._persisted.topics.push(topic);
|
|
45234
|
+
await this._persistState();
|
|
45235
|
+
return topic;
|
|
45236
|
+
}
|
|
45237
|
+
/**
|
|
45238
|
+
* List all topics in the conversation group.
|
|
45239
|
+
* Requires the channel to be initialized with a groupId (from activation).
|
|
45240
|
+
*/
|
|
45241
|
+
async listTopics() {
|
|
45242
|
+
if (!this._persisted?.groupId) {
|
|
45243
|
+
throw new Error("Channel not initialized or groupId unknown");
|
|
45244
|
+
}
|
|
45245
|
+
if (!this._deviceJwt) {
|
|
45246
|
+
throw new Error("Channel not authenticated");
|
|
45247
|
+
}
|
|
45248
|
+
const res = await fetch(
|
|
45249
|
+
`${this.config.apiUrl}/api/v1/topics?group_id=${encodeURIComponent(this._persisted.groupId)}`,
|
|
45250
|
+
{
|
|
45251
|
+
headers: {
|
|
45252
|
+
Authorization: `Bearer ${this._deviceJwt}`
|
|
45253
|
+
}
|
|
45254
|
+
}
|
|
45255
|
+
);
|
|
45256
|
+
if (!res.ok) {
|
|
45257
|
+
const detail = await res.text();
|
|
45258
|
+
throw new Error(`List topics failed (${res.status}): ${detail}`);
|
|
45259
|
+
}
|
|
45260
|
+
const resp = await res.json();
|
|
45261
|
+
const topics = resp.map((t2) => ({ id: t2.id, name: t2.name, isDefault: t2.is_default }));
|
|
45262
|
+
this._persisted.topics = topics;
|
|
45263
|
+
await this._persistState();
|
|
45264
|
+
return topics;
|
|
45265
|
+
}
|
|
45194
45266
|
// --- Internal lifecycle ---
|
|
45195
45267
|
async _enroll() {
|
|
45196
45268
|
this._setState("enrolling");
|
|
@@ -45317,6 +45389,15 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45317
45389
|
sessions,
|
|
45318
45390
|
messageHistory: this._persisted.messageHistory ?? []
|
|
45319
45391
|
};
|
|
45392
|
+
if (conversations.length > 0) {
|
|
45393
|
+
const firstConv = conversations[0];
|
|
45394
|
+
if (firstConv.group_id) {
|
|
45395
|
+
this._persisted.groupId = firstConv.group_id;
|
|
45396
|
+
}
|
|
45397
|
+
if (firstConv.default_topic_id) {
|
|
45398
|
+
this._persisted.defaultTopicId = firstConv.default_topic_id;
|
|
45399
|
+
}
|
|
45400
|
+
}
|
|
45320
45401
|
await saveState(this.config.dataDir, this._persisted);
|
|
45321
45402
|
if (this.config.webhookUrl) {
|
|
45322
45403
|
try {
|
|
@@ -45354,6 +45435,14 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45354
45435
|
}
|
|
45355
45436
|
_connect() {
|
|
45356
45437
|
if (this._stopped) return;
|
|
45438
|
+
if (this._ws) {
|
|
45439
|
+
this._ws.removeAllListeners();
|
|
45440
|
+
try {
|
|
45441
|
+
this._ws.close();
|
|
45442
|
+
} catch {
|
|
45443
|
+
}
|
|
45444
|
+
this._ws = null;
|
|
45445
|
+
}
|
|
45357
45446
|
this._setState("connecting");
|
|
45358
45447
|
const wsUrl = this.config.apiUrl.replace(/^http/, "ws");
|
|
45359
45448
|
const url = `${wsUrl}/api/v1/ws?token=${encodeURIComponent(this._deviceJwt)}&device_id=${this._deviceId}`;
|
|
@@ -45438,15 +45527,17 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45438
45527
|
return;
|
|
45439
45528
|
}
|
|
45440
45529
|
if (messageType === "message") {
|
|
45441
|
-
|
|
45530
|
+
const topicId = msgData.topic_id;
|
|
45531
|
+
this._appendHistory("owner", messageText, topicId);
|
|
45442
45532
|
const metadata = {
|
|
45443
45533
|
messageId: msgData.message_id,
|
|
45444
45534
|
conversationId: convId,
|
|
45445
|
-
timestamp: msgData.created_at
|
|
45535
|
+
timestamp: msgData.created_at,
|
|
45536
|
+
topicId
|
|
45446
45537
|
};
|
|
45447
45538
|
this.emit("message", messageText, metadata);
|
|
45448
45539
|
this.config.onMessage?.(messageText, metadata);
|
|
45449
|
-
await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText);
|
|
45540
|
+
await this._relaySyncToSiblings(convId, session.ownerDeviceId, messageText, topicId);
|
|
45450
45541
|
}
|
|
45451
45542
|
if (this._persisted) {
|
|
45452
45543
|
this._persisted.lastMessageTimestamp = msgData.created_at;
|
|
@@ -45457,13 +45548,14 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45457
45548
|
* Relay an owner's message to all sibling sessions as encrypted sync messages.
|
|
45458
45549
|
* This allows all owner devices to see messages from any single device.
|
|
45459
45550
|
*/
|
|
45460
|
-
async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText) {
|
|
45551
|
+
async _relaySyncToSiblings(sourceConvId, senderOwnerDeviceId, messageText, topicId) {
|
|
45461
45552
|
if (!this._ws || this._sessions.size <= 1) return;
|
|
45462
45553
|
const syncPayload = JSON.stringify({
|
|
45463
45554
|
type: "sync",
|
|
45464
45555
|
sender: senderOwnerDeviceId,
|
|
45465
45556
|
text: messageText,
|
|
45466
|
-
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
45557
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
45558
|
+
topicId
|
|
45467
45559
|
});
|
|
45468
45560
|
for (const [siblingConvId, siblingSession] of this._sessions) {
|
|
45469
45561
|
if (siblingConvId === sourceConvId) continue;
|
|
@@ -45613,11 +45705,13 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45613
45705
|
messageText = plaintext;
|
|
45614
45706
|
}
|
|
45615
45707
|
if (messageType === "message") {
|
|
45616
|
-
|
|
45708
|
+
const topicId = msg.topic_id;
|
|
45709
|
+
this._appendHistory("owner", messageText, topicId);
|
|
45617
45710
|
const metadata = {
|
|
45618
45711
|
messageId: msg.id,
|
|
45619
45712
|
conversationId: msg.conversation_id,
|
|
45620
|
-
timestamp: msg.created_at
|
|
45713
|
+
timestamp: msg.created_at,
|
|
45714
|
+
topicId
|
|
45621
45715
|
};
|
|
45622
45716
|
this.emit("message", messageText, metadata);
|
|
45623
45717
|
this.config.onMessage?.(messageText, metadata);
|
|
@@ -45634,6 +45728,7 @@ var SecureChannel = class extends EventEmitter {
|
|
|
45634
45728
|
}
|
|
45635
45729
|
_scheduleReconnect() {
|
|
45636
45730
|
if (this._stopped) return;
|
|
45731
|
+
if (this._reconnectTimer) return;
|
|
45637
45732
|
const delay = Math.min(
|
|
45638
45733
|
RECONNECT_BASE_MS * Math.pow(2, this._reconnectAttempt),
|
|
45639
45734
|
RECONNECT_MAX_MS
|