@vellumai/vellum-gateway 0.4.55 → 0.4.57

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.
Files changed (70) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +8 -8
  3. package/Dockerfile +1 -1
  4. package/README.md +6 -6
  5. package/package.json +1 -1
  6. package/src/__tests__/browser-relay-websocket.test.ts +6 -1
  7. package/src/__tests__/channel-verification-session-proxy.test.ts +6 -1
  8. package/src/__tests__/config-file-cache.test.ts +1 -1
  9. package/src/__tests__/config.test.ts +6 -1
  10. package/src/__tests__/contacts-control-plane-proxy.test.ts +6 -1
  11. package/src/__tests__/credential-reader.test.ts +107 -14
  12. package/src/__tests__/credential-watcher.test.ts +138 -28
  13. package/src/__tests__/feature-flags-route.test.ts +15 -44
  14. package/src/__tests__/guardian-init-lockfile.test.ts +199 -0
  15. package/src/__tests__/load-guards.test.ts +6 -1
  16. package/src/__tests__/oauth-callback.test.ts +6 -1
  17. package/src/__tests__/resolve-assistant.test.ts +6 -1
  18. package/src/__tests__/runtime-client.test.ts +45 -1
  19. package/src/__tests__/runtime-health-proxy.test.ts +6 -1
  20. package/src/__tests__/runtime-proxy-auth.test.ts +6 -1
  21. package/src/__tests__/runtime-proxy.test.ts +6 -1
  22. package/src/__tests__/slack-control-plane-proxy.test.ts +6 -1
  23. package/src/__tests__/slack-deliver-ratelimit.test.ts +6 -1
  24. package/src/__tests__/slack-deliver.test.ts +14 -2
  25. package/src/__tests__/slack-display-name.test.ts +6 -1
  26. package/src/__tests__/slack-normalize.test.ts +6 -1
  27. package/src/__tests__/slack-reaction-normalize.test.ts +6 -1
  28. package/src/__tests__/sleep-wake-detector.test.ts +83 -0
  29. package/src/__tests__/telegram-control-plane-proxy.test.ts +6 -1
  30. package/src/__tests__/telegram-deliver-auth.test.ts +6 -1
  31. package/src/__tests__/telegram-send-attachments.test.ts +22 -3
  32. package/src/__tests__/telegram-webhook-handler.test.ts +41 -1
  33. package/src/__tests__/twilio-relay-websocket.test.ts +6 -1
  34. package/src/__tests__/twilio-webhooks.test.ts +6 -1
  35. package/src/__tests__/whatsapp-download.test.ts +6 -1
  36. package/src/__tests__/whatsapp-webhook.test.ts +6 -1
  37. package/src/auth/subject.ts +7 -7
  38. package/src/auth/token-exchange.ts +1 -1
  39. package/src/auth/types.ts +1 -1
  40. package/src/channels/inbound-event.ts +1 -1
  41. package/src/config-file-cache.ts +1 -1
  42. package/src/config.ts +16 -4
  43. package/src/credential-reader.ts +55 -11
  44. package/src/credential-watcher.ts +36 -47
  45. package/src/feature-flag-registry.json +185 -33
  46. package/src/http/middleware/auth.ts +17 -2
  47. package/src/http/router.ts +11 -3
  48. package/src/http/routes/channel-verification-session-proxy.ts +52 -2
  49. package/src/http/routes/config-file-utils.ts +73 -0
  50. package/src/http/routes/contacts-control-plane-route-match.ts +9 -1
  51. package/src/http/routes/feature-flags.ts +6 -62
  52. package/src/http/routes/privacy-config.ts +101 -0
  53. package/src/http/routes/slack-deliver.ts +6 -2
  54. package/src/http/routes/telegram-deliver.test.ts +6 -1
  55. package/src/http/routes/telegram-webhook.test.ts +6 -1
  56. package/src/http/routes/telegram-webhook.ts +6 -2
  57. package/src/http/routes/twilio-voice-webhook.test.ts +6 -1
  58. package/src/http/routes/whatsapp-deliver.test.ts +6 -1
  59. package/src/http/routes/whatsapp-webhook.test.ts +6 -1
  60. package/src/http/routes/whatsapp-webhook.ts +6 -2
  61. package/src/index.ts +129 -3
  62. package/src/runtime/client.ts +59 -3
  63. package/src/schema.ts +248 -0
  64. package/src/slack/socket-mode.ts +110 -16
  65. package/src/sleep-wake-detector.ts +44 -0
  66. package/src/telegram/send.test.ts +6 -1
  67. package/src/telegram/send.ts +8 -2
  68. package/src/whatsapp/download.ts +5 -2
  69. package/src/whatsapp/send.ts +8 -2
  70. package/workspace/config.json +1 -3
package/AGENTS.md CHANGED
@@ -35,7 +35,7 @@ All assistant API requests from clients, CLI, skills, and user-facing tooling **
35
35
 
36
36
  Gateway inbound events use a channel-discriminated union model (`GatewayInboundEvent`) with explicit identity fields:
37
37
 
38
- - **`conversationExternalId`**: Delivery/thread address (e.g., Telegram chat ID, phone number). Used for conversation binding and message routing. **Not** used for trust classification.
38
+ - **`conversationExternalId`**: Delivery/conversation address (e.g., Telegram chat ID, phone number). Used for conversation binding and message routing. **Not** used for trust classification.
39
39
  - **`actorExternalId`**: Sender identity (e.g., Telegram user ID, WhatsApp phone number). Used for trust classification, guardian binding, and ACL enforcement. **Required** for all public channel ingress.
40
40
  - **"conversation"** is canonical vocabulary for delivery addresses. "thread" is reserved for provider-specific fields (Slack `thread_ts`, email thread IDs).
41
41
  - **"actor"** is canonical vocabulary for sender identity.
package/ARCHITECTURE.md CHANGED
@@ -215,13 +215,13 @@ Channel readiness endpoints are exposed directly by the gateway and forwarded to
215
215
 
216
216
  ### Channel Binding Lifecycle (Lane Separation)
217
217
 
218
- Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop thread list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering sessions during desktop session restoration.
218
+ Each channel (desktop, Telegram, etc.) operates in its own **lane**: conversations created by an external channel are never displayed in the desktop conversation list, and desktop conversations are never exposed to external channels. The `channelBinding` metadata on a conversation is used solely for routing inbound/outbound messages within that lane and for filtering conversations during desktop conversation restoration.
219
219
 
220
220
  Channel bindings follow a three-phase lifecycle:
221
221
 
222
222
  1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
223
223
 
224
- 2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound sessions during session restoration (`ThreadSessionRestorer`) so they never appear in the desktop thread list.
224
+ 2. **Route** — Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
225
225
 
226
226
  3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
227
227
 
@@ -741,7 +741,7 @@ sequenceDiagram
741
741
  alt ASK_GUARDIAN pattern detected
742
742
  Ctrl->>CallStore: createPendingQuestion()
743
743
  Ctrl->>GuardianDispatch: dispatchGuardianQuestion()
744
- GuardianDispatch->>Mac: notification_thread_created SSE
744
+ GuardianDispatch->>Mac: notification_conversation_created SSE
745
745
  GuardianDispatch->>TG: POST /deliver/{channel}
746
746
  Note over Mac,TG: First channel to respond wins
747
747
  Mac/TG->>Routes: guardian answer
@@ -923,9 +923,9 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
923
923
  1. **Request creation**: A `guardian_action_request` row is created with a unique 6-character hex request code, the question text, a `pending` status, and an expiry timestamp.
924
924
 
925
925
  2. **Delivery fan-out via notification pipeline**: The guardian dispatch calls `emitNotificationSignal()` and uses the same notification decision + broadcaster path as every other producer.
926
- - **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `notification_thread_created` event surfaces the thread in the desktop UI.
926
+ - **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `notification_conversation_created` event surfaces the conversation in the desktop UI.
927
927
  - **Telegram**: Delivery is handled by channel adapters selected by the notification decision and guarded by configured bindings.
928
- - Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `onThreadCreated` callback so vellum delivery rows are created as soon as thread pairing occurs (without waiting for slower channels).
928
+ - Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `onConversationCreated` callback so vellum delivery rows are created as soon as conversation pairing occurs (without waiting for slower channels).
929
929
 
930
930
  3. **Answer resolution**: The first channel to respond wins. Answer resolution uses an atomic `WHERE status = 'pending'` check on the `guardian_action_requests` table -- only the first writer succeeds in transitioning the request to `answered` status. The winning answer text and responding channel are recorded on the request row.
931
931
 
@@ -939,7 +939,7 @@ When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controlle
939
939
 
940
940
  #### macOS Notification + Deep-Link Flow
941
941
 
942
- When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification from the generic `notification_intent` payload (`NOTIFICATION_INTENT` category). The deep-link metadata includes the paired conversation ID, so tapping the notification routes directly to the guardian thread.
942
+ When a guardian question is dispatched while the macOS app is backgrounded, the Swift client posts a native `UNUserNotificationCenter` notification from the generic `notification_intent` payload (`NOTIFICATION_INTENT` category). The deep-link metadata includes the paired conversation ID, so tapping the notification routes directly to the guardian conversation.
943
943
 
944
944
  ### SQLite Tables
945
945
 
@@ -994,8 +994,8 @@ This makes ingress URL updates smoother in local tunnel workflows because Twilio
994
994
  | POST | `/v1/internal/twilio/voice-webhook` | Internal voice webhook used by gateway; accepts JSON `{ params, originalUrl, assistantId? }`, creates inbound session or returns TwiML |
995
995
  | GET | `/v1/calls/:callSessionId` | Get call status, including any pending question |
996
996
  | POST | `/v1/calls/:callSessionId/cancel` | Cancel an active call |
997
- | POST | `/v1/calls/:callSessionId/answer` | Answer a pending question via HTTP (alternative to in-thread bridge) |
998
- | POST | `/v1/calls/:callSessionId/instruction` | Relay a steering instruction to an active call's controller (alternative to in-thread bridge) |
997
+ | POST | `/v1/calls/:callSessionId/answer` | Answer a pending question via HTTP (alternative to in-conversation bridge) |
998
+ | POST | `/v1/calls/:callSessionId/instruction` | Relay a steering instruction to an active call's controller (alternative to in-conversation bridge) |
999
999
  | POST | `/v1/internal/twilio/status` | Internal status callback used by gateway; accepts JSON `{ params }` |
1000
1000
  | POST | `/v1/internal/twilio/connect-action` | Internal connect action callback used by gateway; accepts JSON `{ params }` |
1001
1001
  | WS | `/v1/calls/relay` | ConversationRelay WebSocket (bidirectional: prompt/interrupt/dtmf from Twilio, text tokens/end to Twilio) |
package/Dockerfile CHANGED
@@ -18,7 +18,7 @@ FROM debian:trixie-slim@sha256:1d3c811171a08a5adaa4a163fbafd96b61b87aa871bbc7aa1
18
18
 
19
19
  WORKDIR /app
20
20
 
21
- RUN apt-get update && apt-get install -y \
21
+ RUN apt-get update && apt-get upgrade -y && apt-get install -y \
22
22
  ca-certificates \
23
23
  && rm -rf /var/lib/apt/lists/*
24
24
 
package/README.md CHANGED
@@ -26,11 +26,11 @@ bun run dev
26
26
 
27
27
  ## Configuration
28
28
 
29
- | Variable | Required | Default | Description |
30
- | ------------------------- | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
- | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: keychain broker first (UDS to the assistant daemon), then the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (daemon not running or non-macOS), the encrypted store is used directly. |
32
- | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
- | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
29
+ | Variable | Required | Default | Description |
30
+ | ------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: keychain broker first (UDS to the assistant process), then the encrypted file store (`~/.vellum/protected/keys.enc`). When the broker is unavailable (assistant not running or non-macOS), the encrypted store is used directly. |
32
+ | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
+ | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
34
34
 
35
35
  Most gateway behavior is now configured via hardcoded defaults or workspace config (`~/.vellum/workspace/config.json`) rather than environment variables. Channel operational settings (Telegram API base URL, timeouts, deliver auth bypass flags, runtime base URL, routing, proxy settings, attachment limits, shutdown drain) are managed via `workspace/config.json` through `ConfigFileCache`. See the channel-specific sections in `ARCHITECTURE.md` for details.
36
36
 
@@ -185,7 +185,7 @@ Use this flow when you are changing files under `gateway/` and need to validate
185
185
  ```bash
186
186
  # Terminal 1: restart assistant runtime HTTP server
187
187
  cd assistant
188
- bun run daemon:restart:http
188
+ bun run assistant:restart:http
189
189
 
190
190
  # Terminal 2: run gateway from local source with runtime proxy enabled
191
191
  cd gateway
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.55",
3
+ "version": "0.4.57",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -41,7 +41,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
41
41
  runtimeInitialBackoffMs: 500,
42
42
  maxWebhookPayloadBytes: 1048576,
43
43
  logFile: { dir: undefined, retentionDays: 30 },
44
- maxAttachmentBytes: 20971520,
44
+ maxAttachmentBytes: {
45
+ telegram: 50 * 1024 * 1024,
46
+ slack: 100 * 1024 * 1024,
47
+ whatsapp: 16 * 1024 * 1024,
48
+ default: 50 * 1024 * 1024,
49
+ },
45
50
  maxAttachmentConcurrency: 3,
46
51
  gatewayInternalBaseUrl: "http://127.0.0.1:7830",
47
52
  trustProxy: false,
@@ -35,7 +35,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
35
35
  runtimeInitialBackoffMs: 500,
36
36
  maxWebhookPayloadBytes: 1048576,
37
37
  logFile: { dir: undefined, retentionDays: 30 },
38
- maxAttachmentBytes: 20971520,
38
+ maxAttachmentBytes: {
39
+ telegram: 50 * 1024 * 1024,
40
+ slack: 100 * 1024 * 1024,
41
+ whatsapp: 16 * 1024 * 1024,
42
+ default: 50 * 1024 * 1024,
43
+ },
39
44
  maxAttachmentConcurrency: 3,
40
45
  gatewayInternalBaseUrl: "http://127.0.0.1:7830",
41
46
  trustProxy: false,
@@ -44,7 +44,7 @@ describe("ConfigFileCache: getString", () => {
44
44
  expect(cache.getString("email", "address")).toBe("a@b.com");
45
45
  });
46
46
 
47
- test("returns undefined for empty string", () => {
47
+ test("returns undefined for empty string value", () => {
48
48
  writeConfig({ email: { address: "" } });
49
49
  const cache = new ConfigFileCache();
50
50
  expect(cache.getString("email", "address")).toBeUndefined();
@@ -9,7 +9,12 @@ describe("config: hardcoded defaults", () => {
9
9
  expect(config.runtimeMaxRetries).toBe(2);
10
10
  expect(config.runtimeInitialBackoffMs).toBe(500);
11
11
  expect(config.maxWebhookPayloadBytes).toBe(1024 * 1024);
12
- expect(config.maxAttachmentBytes).toBe(20 * 1024 * 1024);
12
+ expect(config.maxAttachmentBytes).toEqual({
13
+ telegram: 20 * 1024 * 1024,
14
+ slack: 100 * 1024 * 1024,
15
+ whatsapp: 16 * 1024 * 1024,
16
+ default: 100 * 1024 * 1024,
17
+ });
13
18
  expect(config.maxAttachmentConcurrency).toBe(3);
14
19
  expect(config.runtimeProxyEnabled).toBe(false);
15
20
  expect(config.runtimeProxyRequireAuth).toBe(true);
@@ -35,7 +35,12 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
35
35
  runtimeInitialBackoffMs: 500,
36
36
  maxWebhookPayloadBytes: 1048576,
37
37
  logFile: { dir: undefined, retentionDays: 30 },
38
- maxAttachmentBytes: 20971520,
38
+ maxAttachmentBytes: {
39
+ telegram: 50 * 1024 * 1024,
40
+ slack: 100 * 1024 * 1024,
41
+ whatsapp: 16 * 1024 * 1024,
42
+ default: 50 * 1024 * 1024,
43
+ },
39
44
  maxAttachmentConcurrency: 3,
40
45
  gatewayInternalBaseUrl: "http://127.0.0.1:7830",
41
46
  trustProxy: false,
@@ -78,24 +78,16 @@ function getMachineEntropy(): string {
78
78
  return parts.join(":");
79
79
  }
80
80
 
81
- function writeEncryptedStore(entries: Record<string, string>): void {
82
- const storePath = join(testDir, ".vellum", "protected", "keys.enc");
83
- mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
84
-
85
- const salt = randomBytes(16);
86
- const key = pbkdf2Sync(
87
- getMachineEntropy(),
88
- salt,
89
- PBKDF2_ITERATIONS,
90
- KEY_LENGTH,
91
- "sha512",
92
- );
81
+ function encryptEntries(
82
+ entries: Record<string, string>,
83
+ key: Buffer,
84
+ ): Record<string, { iv: string; tag: string; data: string }> {
93
85
  const encryptedEntries: Record<
94
86
  string,
95
87
  { iv: string; tag: string; data: string }
96
88
  > = {};
97
89
  for (const [account, value] of Object.entries(entries)) {
98
- const iv = randomBytes(12);
90
+ const iv = randomBytes(16);
99
91
  const cipher = createCipheriv(ALGORITHM, key, iv, {
100
92
  authTagLength: AUTH_TAG_LENGTH,
101
93
  });
@@ -110,15 +102,48 @@ function writeEncryptedStore(entries: Record<string, string>): void {
110
102
  data: encrypted.toString("hex"),
111
103
  };
112
104
  }
105
+ return encryptedEntries;
106
+ }
107
+
108
+ function writeEncryptedStore(entries: Record<string, string>): void {
109
+ const storePath = join(testDir, ".vellum", "protected", "keys.enc");
110
+ mkdirSync(join(testDir, ".vellum", "protected"), { recursive: true });
111
+
112
+ const salt = randomBytes(16);
113
+ const key = pbkdf2Sync(
114
+ getMachineEntropy(),
115
+ salt,
116
+ PBKDF2_ITERATIONS,
117
+ KEY_LENGTH,
118
+ "sha512",
119
+ );
113
120
 
114
121
  const store = {
115
122
  version: 1,
116
123
  salt: salt.toString("hex"),
117
- entries: encryptedEntries,
124
+ entries: encryptEntries(entries, key),
118
125
  };
119
126
  writeFileSync(storePath, JSON.stringify(store));
120
127
  }
121
128
 
129
+ /**
130
+ * Write a v2 encrypted store with a random store.key file.
131
+ * The store.key is used directly as the AES-256-GCM key (no PBKDF2).
132
+ */
133
+ function writeEncryptedStoreV2(entries: Record<string, string>): void {
134
+ const protectedDir = join(testDir, ".vellum", "protected");
135
+ mkdirSync(protectedDir, { recursive: true });
136
+
137
+ const storeKey = randomBytes(KEY_LENGTH);
138
+ writeFileSync(join(protectedDir, "store.key"), storeKey);
139
+
140
+ const store = {
141
+ version: 2,
142
+ entries: encryptEntries(entries, storeKey),
143
+ };
144
+ writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
145
+ }
146
+
122
147
  // ---------------------------------------------------------------------------
123
148
  // Broker test helpers — mock UDS server
124
149
  // ---------------------------------------------------------------------------
@@ -264,6 +289,74 @@ describe("readTelegramCredentials", () => {
264
289
  });
265
290
  });
266
291
 
292
+ // ---------------------------------------------------------------------------
293
+ // Tests: v2 encrypted store (store.key)
294
+ // ---------------------------------------------------------------------------
295
+
296
+ describe("v2 encrypted store with store.key", () => {
297
+ test("reads credential from v2 store when store.key exists", async () => {
298
+ writeEncryptedStoreV2({
299
+ [credentialKey("test", "key")]: "v2-secret-value",
300
+ });
301
+
302
+ const result = await readCredential(credentialKey("test", "key"));
303
+ expect(result).toBe("v2-secret-value");
304
+ });
305
+
306
+ test("returns undefined for v2 store when store.key is missing", async () => {
307
+ // Write a v2 store but without the store.key file
308
+ const protectedDir = join(testDir, ".vellum", "protected");
309
+ mkdirSync(protectedDir, { recursive: true });
310
+
311
+ const storeKey = randomBytes(KEY_LENGTH);
312
+ const store = {
313
+ version: 2,
314
+ entries: encryptEntries(
315
+ { [credentialKey("test", "key")]: "v2-secret-value" },
316
+ storeKey,
317
+ ),
318
+ };
319
+ writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
320
+ // Deliberately do NOT write store.key
321
+
322
+ const result = await readCredential(credentialKey("test", "key"));
323
+ expect(result).toBeUndefined();
324
+ });
325
+
326
+ test("returns Telegram credentials from v2 store", async () => {
327
+ writeMetadata([
328
+ { service: "telegram", field: "bot_token" },
329
+ { service: "telegram", field: "webhook_secret" },
330
+ ]);
331
+
332
+ writeEncryptedStoreV2({
333
+ [credentialKey("telegram", "bot_token")]: "v2-bot-token",
334
+ [credentialKey("telegram", "webhook_secret")]: "v2-webhook-secret",
335
+ });
336
+
337
+ const result = await readTelegramCredentials();
338
+ expect(result).toEqual({
339
+ botToken: "v2-bot-token",
340
+ webhookSecret: "v2-webhook-secret",
341
+ });
342
+ });
343
+ });
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // Tests: v1 encrypted store backward compatibility
347
+ // ---------------------------------------------------------------------------
348
+
349
+ describe("v1 encrypted store backward compatibility", () => {
350
+ test("v1 store continues to work with entropy-based key derivation", async () => {
351
+ writeEncryptedStore({
352
+ [credentialKey("test", "key")]: "v1-secret-value",
353
+ });
354
+
355
+ const result = await readCredential(credentialKey("test", "key"));
356
+ expect(result).toBe("v1-secret-value");
357
+ });
358
+ });
359
+
267
360
  // ---------------------------------------------------------------------------
268
361
  // Tests: broker credential reading
269
362
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import {
14
14
  pbkdf2Sync,
15
15
  randomBytes as cryptoRandomBytes,
16
16
  } from "node:crypto";
17
- import { mkdirSync, writeFileSync, rmSync } from "node:fs";
17
+ import { mkdirSync, renameSync, writeFileSync, rmSync } from "node:fs";
18
18
  import { hostname, tmpdir, userInfo } from "node:os";
19
19
  import { dirname, join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
@@ -68,7 +68,7 @@ function encrypt(
68
68
  value: string,
69
69
  key: Buffer,
70
70
  ): { iv: string; tag: string; data: string } {
71
- const iv = cryptoRandomBytes(12);
71
+ const iv = cryptoRandomBytes(16);
72
72
  const cipher = createCipheriv(ALGORITHM, key, iv, {
73
73
  authTagLength: AUTH_TAG_LENGTH,
74
74
  });
@@ -115,38 +115,71 @@ function writeEncryptedStore(botToken: string, webhookSecret: string): void {
115
115
  }
116
116
 
117
117
  /**
118
- * Write credential metadata so readTelegramCredentials() knows to look
119
- * for bot_token and webhook_secret.
118
+ * Write Telegram credentials into a v2 encrypted store using a random
119
+ * store.key file (no PBKDF2 derivation).
120
120
  */
121
- function writeCredentialMetadata(): void {
121
+ function writeEncryptedStoreV2(
122
+ botToken: string,
123
+ webhookSecret: string,
124
+ ): void {
125
+ const protectedDir = join(testDir, ".vellum", "protected");
126
+ mkdirSync(protectedDir, { recursive: true });
127
+
128
+ const storeKey = cryptoRandomBytes(KEY_LENGTH);
129
+ writeFileSync(join(protectedDir, "store.key"), storeKey);
130
+
131
+ const store = {
132
+ version: 2,
133
+ entries: {
134
+ "credential/telegram/bot_token": encrypt(botToken, storeKey),
135
+ "credential/telegram/webhook_secret": encrypt(webhookSecret, storeKey),
136
+ },
137
+ };
138
+
139
+ writeFileSync(join(protectedDir, "keys.enc"), JSON.stringify(store));
140
+ }
141
+
142
+ function metadataRecord(
143
+ credentialId: string,
144
+ service: string,
145
+ field: string,
146
+ ): Record<string, unknown> {
147
+ return {
148
+ credentialId,
149
+ service,
150
+ field,
151
+ allowedTools: [],
152
+ allowedDomains: [],
153
+ createdAt: Date.now(),
154
+ updatedAt: Date.now(),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Write credential metadata using the same atomic rename pattern as the
160
+ * production metadata store.
161
+ */
162
+ function writeCredentialMetadata(
163
+ credentials: Record<string, unknown>[] = [
164
+ metadataRecord("test-bt", "telegram", "bot_token"),
165
+ metadataRecord("test-ws", "telegram", "webhook_secret"),
166
+ ],
167
+ ): void {
122
168
  const dir = join(testDir, ".vellum", "workspace", "data", "credentials");
123
169
  mkdirSync(dir, { recursive: true });
170
+ const metadataPath = join(dir, "metadata.json");
171
+ const tmpPath = join(
172
+ dir,
173
+ `.tmp-${cryptoRandomBytes(4).toString("hex")}-metadata.json`,
174
+ );
124
175
  writeFileSync(
125
- join(dir, "metadata.json"),
176
+ tmpPath,
126
177
  JSON.stringify({
127
178
  version: 2,
128
- credentials: [
129
- {
130
- credentialId: "test-bt",
131
- service: "telegram",
132
- field: "bot_token",
133
- allowedTools: [],
134
- allowedDomains: [],
135
- createdAt: Date.now(),
136
- updatedAt: Date.now(),
137
- },
138
- {
139
- credentialId: "test-ws",
140
- service: "telegram",
141
- field: "webhook_secret",
142
- allowedTools: [],
143
- allowedDomains: [],
144
- createdAt: Date.now(),
145
- updatedAt: Date.now(),
146
- },
147
- ],
179
+ credentials,
148
180
  }),
149
181
  );
182
+ renameSync(tmpPath, metadataPath);
150
183
  }
151
184
 
152
185
  // ---------------------------------------------------------------------------
@@ -157,11 +190,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
157
190
  const gatewayRoot = join(__dirname, "..", "..");
158
191
  const gatewayEntry = join(gatewayRoot, "src", "index.ts");
159
192
 
160
- const port = 49152 + Math.floor(Math.random() * 16383);
161
-
162
193
  let gatewayProc: ChildProcess | null = null;
194
+ let port = 0;
163
195
 
164
196
  async function startGateway(): Promise<void> {
197
+ port = 49152 + Math.floor(Math.random() * 16383);
165
198
  gatewayProc = spawn("bun", ["run", gatewayEntry], {
166
199
  env: {
167
200
  ...process.env,
@@ -238,4 +271,81 @@ describe("gateway telegram hot-reload (e2e)", () => {
238
271
  });
239
272
  expect(after.status).toBe(401);
240
273
  }, 15_000);
274
+
275
+ test("gateway keeps reloading credentials after multiple atomic metadata rewrites when metadata.json already existed at startup", async () => {
276
+ mkdirSync(testDir, { recursive: true });
277
+
278
+ // Start in file-watch mode by creating metadata.json before boot, but
279
+ // omit Telegram entries so the integration is initially unconfigured.
280
+ writeCredentialMetadata([metadataRecord("baseline", "github", "token")]);
281
+ writeEncryptedStore("fake-bot-token:ABC123", "fake-webhook-secret");
282
+
283
+ await startGateway();
284
+
285
+ const base = `http://localhost:${port}`;
286
+
287
+ const before = await fetch(`${base}/webhooks/telegram`, {
288
+ method: "POST",
289
+ });
290
+ expect(before.status).toBe(503);
291
+
292
+ // First rewrite after startup stales a file-scoped fs.watch() subscription
293
+ // on macOS when metadata.json is atomically replaced.
294
+ writeCredentialMetadata([
295
+ metadataRecord("baseline", "github", "token"),
296
+ metadataRecord("other", "openai", "api_key"),
297
+ ]);
298
+
299
+ await new Promise((resolve) => setTimeout(resolve, 1200));
300
+
301
+ // Second rewrite adds Telegram credentials. The gateway must still see
302
+ // this update without requiring a restart.
303
+ writeCredentialMetadata([
304
+ metadataRecord("baseline", "github", "token"),
305
+ metadataRecord("other", "openai", "api_key"),
306
+ metadataRecord("test-bt", "telegram", "bot_token"),
307
+ metadataRecord("test-ws", "telegram", "webhook_secret"),
308
+ ]);
309
+
310
+ await new Promise((resolve) => setTimeout(resolve, 2000));
311
+
312
+ const after = await fetch(`${base}/webhooks/telegram`, {
313
+ method: "POST",
314
+ });
315
+ expect(after.status).toBe(401);
316
+ }, 15_000);
317
+
318
+ test("gateway hot-reloads v2 encrypted store credentials written after startup", async () => {
319
+ // --- Setup: no credentials directory exists (fresh hatch) ---
320
+ mkdirSync(testDir, { recursive: true });
321
+
322
+ // Start the real gateway process
323
+ await startGateway();
324
+
325
+ const base = `http://localhost:${port}`;
326
+
327
+ // --- Step 1: confirm Telegram is NOT configured ---
328
+ const before = await fetch(`${base}/webhooks/telegram`, {
329
+ method: "POST",
330
+ });
331
+ expect(before.status).toBe(503);
332
+ const beforeBody = (await before.json()) as { error: string };
333
+ expect(beforeBody.error).toBe("Telegram integration not configured");
334
+
335
+ // --- Step 2: simulate daemon writing v2 credentials ---
336
+ writeEncryptedStoreV2("fake-v2-bot-token:XYZ", "fake-v2-webhook-secret");
337
+ writeCredentialMetadata();
338
+
339
+ // Wait for credential watcher debounce (500ms) + generous margin
340
+ await new Promise((resolve) => setTimeout(resolve, 2000));
341
+
342
+ // --- Step 3: query again — gateway should now recognize Telegram is configured.
343
+ // We expect 401 (webhook secret verification failed) rather than 503
344
+ // (not configured). Getting past the 503 gate proves the gateway
345
+ // hot-reloaded the v2 credentials from the credential store.
346
+ const after = await fetch(`${base}/webhooks/telegram`, {
347
+ method: "POST",
348
+ });
349
+ expect(after.status).toBe(401);
350
+ }, 15_000);
241
351
  });