@vellumai/vellum-gateway 0.7.0 → 0.7.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.
Files changed (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -0,0 +1,939 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
+
12
+ import type { GatewayConfig } from "../config.js";
13
+ import type { ConfigFileCache } from "../config-file-cache.js";
14
+ import type { CredentialCache } from "../credential-cache.js";
15
+ import { credentialKey } from "../credential-key.js";
16
+ import {
17
+ FakeWebSocket,
18
+ makeFakeWebSocketConstructor,
19
+ } from "./test-fake-websocket.js";
20
+ import {
21
+ VELAY_FRAME_TYPES,
22
+ VELAY_TUNNEL_SUBPROTOCOL,
23
+ VELAY_WEBSOCKET_MESSAGE_TYPES,
24
+ type VelayFrame,
25
+ type VelayHttpRequestFrame,
26
+ type VelayHttpResponseFrame,
27
+ type VelayWebSocketInboundFrame,
28
+ } from "./protocol.js";
29
+
30
+ let workspaceDir = "";
31
+
32
+ mock.module("../credential-reader.js", () => ({
33
+ getWorkspaceDir: () => workspaceDir,
34
+ readCredential: async () => undefined,
35
+ }));
36
+
37
+ const { VelayTunnelClient, createVelayTunnelClient } =
38
+ await import("./client.js");
39
+
40
+ const WS_OPEN = WebSocket.OPEN;
41
+ const WS_CLOSED = WebSocket.CLOSED;
42
+
43
+ function makeCredentials(values: Record<string, string | undefined>) {
44
+ return {
45
+ get: async (key: string) => values[key],
46
+ } as unknown as CredentialCache;
47
+ }
48
+
49
+ function makeConfigFileCache(invalidations: { count: number }) {
50
+ const invalidateCallbacks = new Set<() => void>();
51
+ return {
52
+ getBoolean: (section: string, key: string) => {
53
+ let sectionValue: unknown;
54
+ try {
55
+ sectionValue = readConfig()[section];
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ if (
60
+ !sectionValue ||
61
+ typeof sectionValue !== "object" ||
62
+ Array.isArray(sectionValue)
63
+ ) {
64
+ return undefined;
65
+ }
66
+ const value = (sectionValue as Record<string, unknown>)[key];
67
+ return typeof value === "boolean" ? value : undefined;
68
+ },
69
+ invalidate: () => {
70
+ invalidations.count++;
71
+ for (const callback of invalidateCallbacks) {
72
+ callback();
73
+ }
74
+ },
75
+ onInvalidate: (callback: () => void) => {
76
+ invalidateCallbacks.add(callback);
77
+ return () => {
78
+ invalidateCallbacks.delete(callback);
79
+ };
80
+ },
81
+ } as unknown as ConfigFileCache;
82
+ }
83
+
84
+ function makeTimerApi(delays: number[]) {
85
+ return {
86
+ setTimeout: (_fn: () => void, delayMs: number) => {
87
+ delays.push(delayMs);
88
+ return { delayMs };
89
+ },
90
+ clearTimeout: () => {},
91
+ };
92
+ }
93
+
94
+ function makeManualTimerApi(delays: number[], callbacks: Array<() => void>) {
95
+ return {
96
+ setTimeout: (fn: () => void, delayMs: number) => {
97
+ delays.push(delayMs);
98
+ callbacks.push(fn);
99
+ return fn;
100
+ },
101
+ clearTimeout: () => {},
102
+ };
103
+ }
104
+
105
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
106
+ return {
107
+ assistantRuntimeBaseUrl: "http://localhost:7821",
108
+ defaultAssistantId: undefined,
109
+ gatewayInternalBaseUrl: "http://127.0.0.1:7830",
110
+ logFile: { dir: join(workspaceDir, "logs"), retentionDays: 30 },
111
+ maxAttachmentBytes: {
112
+ telegram: 1,
113
+ slack: 1,
114
+ whatsapp: 1,
115
+ default: 1,
116
+ },
117
+ maxAttachmentConcurrency: 1,
118
+ maxWebhookPayloadBytes: 1,
119
+ port: 7830,
120
+ routingEntries: [],
121
+ runtimeInitialBackoffMs: 1,
122
+ runtimeMaxRetries: 0,
123
+ runtimeProxyRequireAuth: true,
124
+ runtimeTimeoutMs: 1,
125
+ shutdownDrainMs: 1,
126
+ unmappedPolicy: "reject",
127
+ trustProxy: false,
128
+ ...overrides,
129
+ };
130
+ }
131
+
132
+ function writeConfig(data: Record<string, unknown>): void {
133
+ mkdirSync(workspaceDir, { recursive: true });
134
+ writeFileSync(
135
+ join(workspaceDir, "config.json"),
136
+ JSON.stringify(data),
137
+ "utf-8",
138
+ );
139
+ }
140
+
141
+ function readConfig(): Record<string, unknown> {
142
+ return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
143
+ }
144
+
145
+ function makeClient(
146
+ overrides: {
147
+ credentials?: CredentialCache;
148
+ configFile?: ConfigFileCache;
149
+ sockets?: FakeWebSocket[];
150
+ httpBridge?: (
151
+ frame: VelayHttpRequestFrame,
152
+ gatewayLoopbackBaseUrl: string,
153
+ ) => Promise<VelayHttpResponseFrame>;
154
+ websocketFrames?: VelayWebSocketInboundFrame[];
155
+ reconnectDelays?: number[];
156
+ } = {},
157
+ ) {
158
+ const sockets = overrides.sockets ?? [];
159
+ const reconnectDelays = overrides.reconnectDelays ?? [];
160
+ return new VelayTunnelClient({
161
+ velayBaseUrl: "http://velay.example.test",
162
+ gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
163
+ credentials:
164
+ overrides.credentials ??
165
+ makeCredentials({
166
+ [credentialKey("vellum", "assistant_api_key")]: "api-key-123",
167
+ [credentialKey("vellum", "platform_assistant_id")]: "asst-123",
168
+ }),
169
+ configFile: overrides.configFile ?? makeConfigFileCache({ count: 0 }),
170
+ webSocketConstructor: makeFakeWebSocketConstructor(sockets),
171
+ httpBridge: overrides.httpBridge,
172
+ webSocketBridgeFactory:
173
+ overrides.websocketFrames === undefined
174
+ ? undefined
175
+ : () =>
176
+ ({
177
+ handleFrame: (frame: VelayWebSocketInboundFrame) => {
178
+ overrides.websocketFrames?.push(frame);
179
+ },
180
+ closeAll: () => {},
181
+ }) as never,
182
+ reconnect: { baseDelayMs: 10, maxDelayMs: 10, jitterRatio: 0 },
183
+ timerApi: makeTimerApi(reconnectDelays),
184
+ });
185
+ }
186
+
187
+ function sendFrame(ws: FakeWebSocket, frame: VelayFrame): void {
188
+ ws.emit("message", { data: JSON.stringify(frame) });
189
+ }
190
+
191
+ async function flushPromises(): Promise<void> {
192
+ for (let i = 0; i < 6; i++) {
193
+ await Promise.resolve();
194
+ }
195
+ }
196
+
197
+ beforeEach(() => {
198
+ workspaceDir = mkdtempSync(join(tmpdir(), "velay-client-"));
199
+ });
200
+
201
+ afterEach(() => {
202
+ rmSync(workspaceDir, { recursive: true, force: true });
203
+ });
204
+
205
+ describe("VelayTunnelClient", () => {
206
+ test("stays disabled when VELAY_BASE_URL is unset", async () => {
207
+ const client = createVelayTunnelClient(makeConfig(), {
208
+ credentials: makeCredentials({}),
209
+ configFile: makeConfigFileCache({ count: 0 }),
210
+ });
211
+
212
+ expect(client).toBeUndefined();
213
+ await flushPromises();
214
+ });
215
+
216
+ test("retries without opening a socket when the assistant API key is missing", async () => {
217
+ const sockets: FakeWebSocket[] = [];
218
+ const reconnectDelays: number[] = [];
219
+ const client = makeClient({
220
+ sockets,
221
+ reconnectDelays,
222
+ credentials: makeCredentials({
223
+ [credentialKey("vellum", "platform_assistant_id")]: "asst-123",
224
+ }),
225
+ });
226
+
227
+ client.start();
228
+ await flushPromises();
229
+
230
+ expect(sockets).toHaveLength(0);
231
+ expect(reconnectDelays).toEqual([10]);
232
+ await client.stop();
233
+ });
234
+
235
+ test("opens a socket and registers when the platform assistant ID is missing", async () => {
236
+ const sockets: FakeWebSocket[] = [];
237
+ const reconnectDelays: number[] = [];
238
+ writeConfig({
239
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
240
+ });
241
+ const client = makeClient({
242
+ sockets,
243
+ reconnectDelays,
244
+ credentials: makeCredentials({
245
+ [credentialKey("vellum", "assistant_api_key")]: "api-key-123",
246
+ }),
247
+ });
248
+
249
+ client.start();
250
+ await flushPromises();
251
+
252
+ expect(sockets).toHaveLength(1);
253
+ sockets[0].readyState = WS_OPEN;
254
+ sendFrame(sockets[0], {
255
+ type: VELAY_FRAME_TYPES.registered,
256
+ assistant_id: "asst-from-velay",
257
+ public_url: "https://velay-public.example.test",
258
+ });
259
+ await flushPromises();
260
+
261
+ expect(sockets[0].closes).toEqual([]);
262
+ expect(reconnectDelays).toEqual([]);
263
+ expect(readConfig()).toEqual({
264
+ ingress: {
265
+ publicBaseUrl: "https://velay-public.example.test/",
266
+ publicBaseUrlManagedBy: "velay",
267
+ },
268
+ });
269
+ await client.stop();
270
+ });
271
+
272
+ test("registers with Velay and publishes the Twilio public URL", async () => {
273
+ const sockets: FakeWebSocket[] = [];
274
+ const invalidations = { count: 0 };
275
+ writeConfig({
276
+ ingress: {
277
+ publicBaseUrl: "https://ngrok.example.test",
278
+ },
279
+ existing: { preserved: true },
280
+ });
281
+ const client = makeClient({
282
+ sockets,
283
+ configFile: makeConfigFileCache(invalidations),
284
+ });
285
+
286
+ client.start();
287
+ await flushPromises();
288
+
289
+ expect(sockets).toHaveLength(1);
290
+ expect(sockets[0].url).toBe("ws://velay.example.test/v1/register");
291
+ expect(sockets[0].options).toEqual({
292
+ protocols: [VELAY_TUNNEL_SUBPROTOCOL],
293
+ headers: { Authorization: "Api-Key api-key-123" },
294
+ });
295
+
296
+ sockets[0].readyState = WS_OPEN;
297
+ sockets[0].emit("open");
298
+ sendFrame(sockets[0], {
299
+ type: VELAY_FRAME_TYPES.registered,
300
+ assistant_id: "asst-123",
301
+ public_url: "https://velay-public.example.test",
302
+ });
303
+ await flushPromises();
304
+
305
+ expect(readConfig()).toEqual({
306
+ ingress: {
307
+ publicBaseUrl: "https://velay-public.example.test/",
308
+ publicBaseUrlManagedBy: "velay",
309
+ },
310
+ existing: { preserved: true },
311
+ });
312
+ expect(invalidations.count).toBe(1);
313
+ });
314
+
315
+ test("waits without connecting when public ingress is disabled", async () => {
316
+ const sockets: FakeWebSocket[] = [];
317
+ const reconnectDelays: number[] = [];
318
+ const invalidations = { count: 0 };
319
+ writeConfig({
320
+ ingress: {
321
+ enabled: false,
322
+ publicBaseUrl: "https://stale-velay.example.test",
323
+ publicBaseUrlManagedBy: "velay",
324
+ },
325
+ });
326
+ const client = makeClient({
327
+ sockets,
328
+ reconnectDelays,
329
+ configFile: makeConfigFileCache(invalidations),
330
+ });
331
+
332
+ client.start();
333
+ await flushPromises();
334
+
335
+ expect(sockets).toHaveLength(0);
336
+ expect(reconnectDelays).toEqual([10]);
337
+ expect(readConfig()).toEqual({
338
+ ingress: {
339
+ enabled: false,
340
+ },
341
+ });
342
+ expect(invalidations.count).toBe(1);
343
+ await client.stop();
344
+ });
345
+
346
+ test("closes without publishing when public ingress is disabled after connecting", async () => {
347
+ const sockets: FakeWebSocket[] = [];
348
+ const reconnectDelays: number[] = [];
349
+ const invalidations = { count: 0 };
350
+ writeConfig({
351
+ ingress: {
352
+ enabled: true,
353
+ publicBaseUrl: "https://ngrok.example.test",
354
+ },
355
+ });
356
+ const client = makeClient({
357
+ sockets,
358
+ reconnectDelays,
359
+ configFile: makeConfigFileCache(invalidations),
360
+ });
361
+
362
+ client.start();
363
+ await flushPromises();
364
+ expect(sockets).toHaveLength(1);
365
+
366
+ writeConfig({
367
+ ingress: {
368
+ enabled: false,
369
+ publicBaseUrl: "https://ngrok.example.test",
370
+ },
371
+ });
372
+ sockets[0].readyState = WS_OPEN;
373
+ sendFrame(sockets[0], {
374
+ type: VELAY_FRAME_TYPES.registered,
375
+ assistant_id: "asst-123",
376
+ public_url: "https://velay-public.example.test",
377
+ });
378
+ await flushPromises();
379
+
380
+ expect(sockets[0].closes).toEqual([
381
+ { code: 1000, reason: "public ingress disabled" },
382
+ ]);
383
+ expect(reconnectDelays).toEqual([10]);
384
+ expect(readConfig()).toEqual({
385
+ ingress: {
386
+ enabled: false,
387
+ publicBaseUrl: "https://ngrok.example.test",
388
+ },
389
+ });
390
+ expect(invalidations.count).toBe(0);
391
+ });
392
+
393
+ test("closes and clears a published URL when public ingress is disabled while connected", async () => {
394
+ const sockets: FakeWebSocket[] = [];
395
+ const reconnectDelays: number[] = [];
396
+ const invalidations = { count: 0 };
397
+ const configFile = makeConfigFileCache(invalidations);
398
+ writeConfig({
399
+ ingress: {
400
+ enabled: true,
401
+ publicBaseUrl: "https://ngrok.example.test",
402
+ },
403
+ });
404
+ const client = makeClient({
405
+ sockets,
406
+ reconnectDelays,
407
+ configFile,
408
+ });
409
+
410
+ client.start();
411
+ await flushPromises();
412
+ sockets[0].readyState = WS_OPEN;
413
+ sendFrame(sockets[0], {
414
+ type: VELAY_FRAME_TYPES.registered,
415
+ assistant_id: "asst-123",
416
+ public_url: "https://velay-public.example.test",
417
+ });
418
+ await flushPromises();
419
+
420
+ writeConfig({
421
+ ingress: {
422
+ enabled: false,
423
+ publicBaseUrl: "https://velay-public.example.test/",
424
+ publicBaseUrlManagedBy: "velay",
425
+ },
426
+ });
427
+ configFile.invalidate();
428
+ await flushPromises();
429
+
430
+ expect(sockets[0].closes).toEqual([
431
+ { code: 1000, reason: "public ingress disabled" },
432
+ ]);
433
+ expect(reconnectDelays).toEqual([10]);
434
+ expect(readConfig()).toEqual({
435
+ ingress: {
436
+ enabled: false,
437
+ },
438
+ });
439
+ expect(invalidations.count).toBe(3);
440
+ });
441
+
442
+ test("rejects registration when Velay returns a different assistant ID", async () => {
443
+ const sockets: FakeWebSocket[] = [];
444
+ writeConfig({
445
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
446
+ });
447
+ const client = makeClient({ sockets });
448
+
449
+ client.start();
450
+ await flushPromises();
451
+ sockets[0].readyState = WS_OPEN;
452
+ sendFrame(sockets[0], {
453
+ type: VELAY_FRAME_TYPES.registered,
454
+ assistant_id: "asst-other",
455
+ public_url: "https://velay-public.example.test",
456
+ });
457
+ await flushPromises();
458
+
459
+ expect(sockets[0].closes).toEqual([
460
+ { code: 4008, reason: "assistant ID mismatch" },
461
+ ]);
462
+ expect(readConfig()).toEqual({
463
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
464
+ });
465
+ });
466
+
467
+ test("backs off repeated open-then-close failures until registration succeeds", async () => {
468
+ const sockets: FakeWebSocket[] = [];
469
+ const reconnectDelays: number[] = [];
470
+ const reconnectCallbacks: Array<() => void> = [];
471
+ writeConfig({
472
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
473
+ });
474
+ const client = new VelayTunnelClient({
475
+ velayBaseUrl: "http://velay.example.test",
476
+ gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
477
+ credentials: makeCredentials({
478
+ [credentialKey("vellum", "assistant_api_key")]: "api-key-123",
479
+ [credentialKey("vellum", "platform_assistant_id")]: "asst-123",
480
+ }),
481
+ configFile: makeConfigFileCache({ count: 0 }),
482
+ webSocketConstructor: makeFakeWebSocketConstructor(sockets),
483
+ reconnect: { baseDelayMs: 10, maxDelayMs: 80, jitterRatio: 0 },
484
+ timerApi: makeManualTimerApi(reconnectDelays, reconnectCallbacks),
485
+ });
486
+
487
+ client.start();
488
+ await flushPromises();
489
+
490
+ sockets[0].readyState = WS_OPEN;
491
+ sockets[0].emit("open");
492
+ sockets[0].readyState = WS_CLOSED;
493
+ sockets[0].emit("close", { code: 1006, reason: "" });
494
+ await flushPromises();
495
+ expect(reconnectDelays).toEqual([10]);
496
+
497
+ reconnectCallbacks.shift()?.();
498
+ await flushPromises();
499
+ sockets[1].readyState = WS_OPEN;
500
+ sockets[1].emit("open");
501
+ sockets[1].readyState = WS_CLOSED;
502
+ sockets[1].emit("close", { code: 1006, reason: "" });
503
+ await flushPromises();
504
+ expect(reconnectDelays).toEqual([10, 20]);
505
+
506
+ reconnectCallbacks.shift()?.();
507
+ await flushPromises();
508
+ sockets[2].readyState = WS_OPEN;
509
+ sockets[2].emit("open");
510
+ sendFrame(sockets[2], {
511
+ type: VELAY_FRAME_TYPES.registered,
512
+ assistant_id: "asst-123",
513
+ public_url: "https://velay-public.example.test",
514
+ });
515
+ await flushPromises();
516
+ sockets[2].readyState = WS_CLOSED;
517
+ sockets[2].emit("close", { code: 1006, reason: "" });
518
+ await flushPromises();
519
+
520
+ expect(reconnectDelays).toEqual([10, 20, 10]);
521
+ });
522
+
523
+ test("writes only ingress.publicBaseUrl when publishing a Velay URL", async () => {
524
+ const sockets: FakeWebSocket[] = [];
525
+ writeConfig({
526
+ ingress: {
527
+ publicBaseUrl: "https://ngrok.example.test",
528
+ otherIngressSetting: "keep-me",
529
+ },
530
+ gateway: {
531
+ runtimeProxyRequireAuth: false,
532
+ },
533
+ });
534
+ const client = makeClient({ sockets });
535
+
536
+ client.start();
537
+ await flushPromises();
538
+ sockets[0].readyState = WS_OPEN;
539
+ sendFrame(sockets[0], {
540
+ type: VELAY_FRAME_TYPES.registered,
541
+ assistant_id: "asst-123",
542
+ public_url: "https://velay-public.example.test",
543
+ });
544
+ await flushPromises();
545
+
546
+ expect(readConfig()).toEqual({
547
+ ingress: {
548
+ otherIngressSetting: "keep-me",
549
+ publicBaseUrl: "https://velay-public.example.test/",
550
+ publicBaseUrlManagedBy: "velay",
551
+ },
552
+ gateway: {
553
+ runtimeProxyRequireAuth: false,
554
+ },
555
+ });
556
+ });
557
+
558
+ test("normalizes a valid registered public URL before publishing", async () => {
559
+ const sockets: FakeWebSocket[] = [];
560
+ writeConfig({
561
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
562
+ });
563
+ const client = makeClient({ sockets });
564
+
565
+ client.start();
566
+ await flushPromises();
567
+ sockets[0].readyState = WS_OPEN;
568
+ sendFrame(sockets[0], {
569
+ type: VELAY_FRAME_TYPES.registered,
570
+ assistant_id: "asst-123",
571
+ public_url: " HTTPS://VELAY-PUBLIC.EXAMPLE.TEST/twilio/../twilio ",
572
+ });
573
+ await flushPromises();
574
+
575
+ expect(readConfig()).toEqual({
576
+ ingress: {
577
+ publicBaseUrl: "https://velay-public.example.test/twilio",
578
+ publicBaseUrlManagedBy: "velay",
579
+ },
580
+ });
581
+ });
582
+
583
+ test("rejects registration with an invalid public URL", async () => {
584
+ for (const publicUrl of ["", "notaurl", "https://", "ftp://example.test"]) {
585
+ const sockets: FakeWebSocket[] = [];
586
+ const reconnectDelays: number[] = [];
587
+ const invalidations = { count: 0 };
588
+ writeConfig({
589
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
590
+ });
591
+ const client = makeClient({
592
+ sockets,
593
+ reconnectDelays,
594
+ configFile: makeConfigFileCache(invalidations),
595
+ });
596
+
597
+ client.start();
598
+ await flushPromises();
599
+ sockets[0].readyState = WS_OPEN;
600
+ sendFrame(sockets[0], {
601
+ type: VELAY_FRAME_TYPES.registered,
602
+ assistant_id: "asst-123",
603
+ public_url: publicUrl,
604
+ });
605
+ await flushPromises();
606
+
607
+ expect(sockets[0].closes).toEqual([
608
+ { code: 4008, reason: "invalid public URL" },
609
+ ]);
610
+ expect(readConfig()).toEqual({
611
+ ingress: { publicBaseUrl: "https://ngrok.example.test" },
612
+ });
613
+ expect(invalidations.count).toBe(0);
614
+ expect(reconnectDelays).toEqual([10]);
615
+ await client.stop();
616
+ }
617
+ });
618
+
619
+ test("clears the published Twilio public URL when the tunnel disconnects", async () => {
620
+ const sockets: FakeWebSocket[] = [];
621
+ const reconnectDelays: number[] = [];
622
+ const invalidations = { count: 0 };
623
+ writeConfig({
624
+ ingress: {
625
+ publicBaseUrl: "https://ngrok.example.test",
626
+ },
627
+ });
628
+ const client = makeClient({
629
+ sockets,
630
+ reconnectDelays,
631
+ configFile: makeConfigFileCache(invalidations),
632
+ });
633
+
634
+ client.start();
635
+ await flushPromises();
636
+ sockets[0].readyState = WS_OPEN;
637
+ sendFrame(sockets[0], {
638
+ type: VELAY_FRAME_TYPES.registered,
639
+ assistant_id: "asst-123",
640
+ public_url: "https://velay-public.example.test",
641
+ });
642
+ await flushPromises();
643
+
644
+ sockets[0].readyState = WS_CLOSED;
645
+ sockets[0].emit("close", { code: 1006, reason: "" });
646
+ await flushPromises();
647
+
648
+ expect(readConfig()).toEqual({
649
+ ingress: {},
650
+ });
651
+ expect(invalidations.count).toBe(2);
652
+ expect(reconnectDelays).toEqual([10]);
653
+ });
654
+
655
+ test("waits for disconnect cleanup before scheduling reconnect", async () => {
656
+ const sockets: FakeWebSocket[] = [];
657
+ const reconnectDelays: number[] = [];
658
+ writeConfig({
659
+ ingress: {
660
+ publicBaseUrl: "https://ngrok.example.test",
661
+ },
662
+ });
663
+ const client = makeClient({
664
+ sockets,
665
+ reconnectDelays,
666
+ configFile: makeConfigFileCache({ count: 0 }),
667
+ });
668
+
669
+ client.start();
670
+ await flushPromises();
671
+ sockets[0].readyState = WS_OPEN;
672
+ sendFrame(sockets[0], {
673
+ type: VELAY_FRAME_TYPES.registered,
674
+ assistant_id: "asst-123",
675
+ public_url: "https://velay-public.example.test",
676
+ });
677
+ await flushPromises();
678
+
679
+ sockets[0].readyState = WS_CLOSED;
680
+ sockets[0].emit("close", { code: 1006, reason: "" });
681
+
682
+ expect(reconnectDelays).toEqual([]);
683
+
684
+ await flushPromises();
685
+
686
+ expect(reconnectDelays).toEqual([10]);
687
+ expect(readConfig()).toEqual({
688
+ ingress: {},
689
+ });
690
+ });
691
+
692
+ test("preserves a newer Twilio public URL and clears stale Velay ownership on stale tunnel close", async () => {
693
+ const sockets: FakeWebSocket[] = [];
694
+ const invalidations = { count: 0 };
695
+ writeConfig({
696
+ ingress: {
697
+ publicBaseUrl: "https://ngrok.example.test",
698
+ },
699
+ });
700
+ const client = makeClient({
701
+ sockets,
702
+ configFile: makeConfigFileCache(invalidations),
703
+ });
704
+
705
+ client.start();
706
+ await flushPromises();
707
+ sockets[0].readyState = WS_OPEN;
708
+ sendFrame(sockets[0], {
709
+ type: VELAY_FRAME_TYPES.registered,
710
+ assistant_id: "asst-123",
711
+ public_url: "https://velay-public-1.example.test",
712
+ });
713
+ await flushPromises();
714
+ writeConfig({
715
+ ingress: {
716
+ publicBaseUrl: "https://velay-public-2.example.test",
717
+ publicBaseUrlManagedBy: "velay",
718
+ },
719
+ });
720
+
721
+ sockets[0].readyState = WS_CLOSED;
722
+ sockets[0].emit("close", { code: 1006, reason: "" });
723
+ await flushPromises();
724
+
725
+ expect(readConfig()).toEqual({
726
+ ingress: {
727
+ publicBaseUrl: "https://velay-public-2.example.test",
728
+ },
729
+ });
730
+ expect(invalidations.count).toBe(2);
731
+
732
+ expect(
733
+ createVelayTunnelClient(makeConfig(), {
734
+ credentials: makeCredentials({}),
735
+ configFile: makeConfigFileCache(invalidations),
736
+ }),
737
+ ).toBeUndefined();
738
+ await flushPromises();
739
+
740
+ expect(readConfig()).toEqual({
741
+ ingress: {
742
+ publicBaseUrl: "https://velay-public-2.example.test",
743
+ },
744
+ });
745
+ expect(invalidations.count).toBe(2);
746
+ });
747
+
748
+ test("clears stale Velay-managed Twilio public URL on startup before connecting", async () => {
749
+ const sockets: FakeWebSocket[] = [];
750
+ const invalidations = { count: 0 };
751
+ writeConfig({
752
+ ingress: {
753
+ publicBaseUrl: "https://stale-velay.example.test",
754
+ publicBaseUrlManagedBy: "velay",
755
+ },
756
+ });
757
+ const client = makeClient({
758
+ sockets,
759
+ configFile: makeConfigFileCache(invalidations),
760
+ });
761
+
762
+ client.start();
763
+ await flushPromises();
764
+
765
+ expect(sockets).toHaveLength(1);
766
+ expect(readConfig()).toEqual({
767
+ ingress: {},
768
+ });
769
+ expect(invalidations.count).toBe(1);
770
+ await client.stop();
771
+ });
772
+
773
+ test("disabled Velay cleanup clears stale managed URL and preserves manual Twilio URL", async () => {
774
+ const invalidations = { count: 0 };
775
+ writeConfig({
776
+ ingress: {
777
+ publicBaseUrl: "https://stale-velay.example.test",
778
+ publicBaseUrlManagedBy: "velay",
779
+ },
780
+ });
781
+
782
+ expect(
783
+ createVelayTunnelClient(makeConfig(), {
784
+ credentials: makeCredentials({}),
785
+ configFile: makeConfigFileCache(invalidations),
786
+ }),
787
+ ).toBeUndefined();
788
+ await flushPromises();
789
+
790
+ expect(readConfig()).toEqual({
791
+ ingress: {},
792
+ });
793
+ expect(invalidations.count).toBe(1);
794
+
795
+ writeConfig({
796
+ ingress: {
797
+ publicBaseUrl: "https://manual.example.test",
798
+ },
799
+ });
800
+
801
+ expect(
802
+ createVelayTunnelClient(makeConfig(), {
803
+ credentials: makeCredentials({}),
804
+ configFile: makeConfigFileCache(invalidations),
805
+ }),
806
+ ).toBeUndefined();
807
+ await flushPromises();
808
+
809
+ expect(readConfig()).toEqual({
810
+ ingress: {
811
+ publicBaseUrl: "https://manual.example.test",
812
+ },
813
+ });
814
+ expect(invalidations.count).toBe(1);
815
+ });
816
+
817
+ test("dispatches HTTP and WebSocket frames to the loopback bridges", async () => {
818
+ const sockets: FakeWebSocket[] = [];
819
+ const websocketFrames: VelayWebSocketInboundFrame[] = [];
820
+ const httpBridge = mock(
821
+ async (
822
+ frame: VelayHttpRequestFrame,
823
+ gatewayLoopbackBaseUrl: string,
824
+ ): Promise<VelayHttpResponseFrame> => ({
825
+ type: VELAY_FRAME_TYPES.httpResponse,
826
+ request_id: frame.request_id,
827
+ status_code:
828
+ gatewayLoopbackBaseUrl === "http://127.0.0.1:7830" ? 204 : 500,
829
+ }),
830
+ );
831
+ const client = makeClient({ sockets, httpBridge, websocketFrames });
832
+
833
+ client.start();
834
+ await flushPromises();
835
+ sockets[0].readyState = WS_OPEN;
836
+
837
+ sendFrame(sockets[0], {
838
+ type: VELAY_FRAME_TYPES.httpRequest,
839
+ request_id: "req-123",
840
+ method: "POST",
841
+ path: "/webhooks/twilio/voice",
842
+ headers: {},
843
+ });
844
+ await flushPromises();
845
+
846
+ expect(httpBridge).toHaveBeenCalledTimes(1);
847
+ expect(sockets[0].sent.map((raw) => JSON.parse(raw as string))).toEqual([
848
+ {
849
+ type: VELAY_FRAME_TYPES.httpResponse,
850
+ request_id: "req-123",
851
+ status_code: 204,
852
+ },
853
+ ]);
854
+
855
+ sendFrame(sockets[0], {
856
+ type: VELAY_FRAME_TYPES.websocketOpen,
857
+ connection_id: "conn-123",
858
+ path: "/webhooks/twilio/relay",
859
+ headers: {},
860
+ });
861
+ sendFrame(sockets[0], {
862
+ type: VELAY_FRAME_TYPES.websocketMessage,
863
+ connection_id: "conn-123",
864
+ message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
865
+ body_base64: "",
866
+ });
867
+ sendFrame(sockets[0], {
868
+ type: VELAY_FRAME_TYPES.websocketClose,
869
+ connection_id: "conn-123",
870
+ code: 1000,
871
+ reason: "done",
872
+ });
873
+
874
+ expect(websocketFrames.map((frame) => frame.type)).toEqual([
875
+ VELAY_FRAME_TYPES.websocketOpen,
876
+ VELAY_FRAME_TYPES.websocketMessage,
877
+ VELAY_FRAME_TYPES.websocketClose,
878
+ ]);
879
+ });
880
+
881
+ test("ignores websocket messages with invalid message types", async () => {
882
+ const sockets: FakeWebSocket[] = [];
883
+ const websocketFrames: VelayWebSocketInboundFrame[] = [];
884
+ const client = makeClient({ sockets, websocketFrames });
885
+
886
+ client.start();
887
+ await flushPromises();
888
+ sockets[0].readyState = WS_OPEN;
889
+
890
+ sockets[0].emit("message", {
891
+ data: JSON.stringify({
892
+ type: VELAY_FRAME_TYPES.websocketMessage,
893
+ connection_id: "conn-123",
894
+ message_type: "json",
895
+ body_base64: "",
896
+ }),
897
+ });
898
+ await flushPromises();
899
+
900
+ expect(websocketFrames).toEqual([]);
901
+ });
902
+
903
+ test("stops reconnecting and closes bridged sockets on shutdown", async () => {
904
+ const sockets: FakeWebSocket[] = [];
905
+ const reconnectDelays: number[] = [];
906
+ let closeAllCount = 0;
907
+ const client = new VelayTunnelClient({
908
+ velayBaseUrl: "http://velay.example.test",
909
+ gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
910
+ credentials: makeCredentials({
911
+ [credentialKey("vellum", "assistant_api_key")]: "api-key-123",
912
+ [credentialKey("vellum", "platform_assistant_id")]: "asst-123",
913
+ }),
914
+ configFile: makeConfigFileCache({ count: 0 }),
915
+ webSocketConstructor: makeFakeWebSocketConstructor(sockets),
916
+ webSocketBridgeFactory: () =>
917
+ ({
918
+ handleFrame: () => {},
919
+ closeAll: () => {
920
+ closeAllCount++;
921
+ },
922
+ }) as never,
923
+ reconnect: { baseDelayMs: 10, maxDelayMs: 10, jitterRatio: 0 },
924
+ timerApi: makeTimerApi(reconnectDelays),
925
+ });
926
+
927
+ client.start();
928
+ await flushPromises();
929
+ await client.stop();
930
+ sockets[0].emit("close", { code: 1000, reason: "gateway shutdown" });
931
+ await flushPromises();
932
+
933
+ expect(sockets[0].closes).toEqual([
934
+ { code: 1000, reason: "gateway shutdown" },
935
+ ]);
936
+ expect(closeAllCount).toBe(1);
937
+ expect(reconnectDelays).toEqual([]);
938
+ });
939
+ });