@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,555 @@
1
+ import type { OutgoingHttpHeaders } from "node:http";
2
+
3
+ import { normalizeHttpPublicBaseUrl } from "@vellumai/service-contracts/ingress";
4
+
5
+ import type { GatewayConfig } from "../config.js";
6
+ import type { ConfigFileCache } from "../config-file-cache.js";
7
+ import type { CredentialCache } from "../credential-cache.js";
8
+ import { credentialKey } from "../credential-key.js";
9
+ import { mutateConfigFile } from "../config-file-utils.js";
10
+ import { getLogger } from "../logger.js";
11
+ import { bridgeVelayHttpRequest } from "./http-bridge.js";
12
+ import { closeWebSocket } from "./bridge-utils.js";
13
+ import {
14
+ VELAY_FRAME_TYPES,
15
+ VELAY_TUNNEL_SUBPROTOCOL,
16
+ parseVelayFrame,
17
+ type VelayFrame,
18
+ type VelayHttpRequestFrame,
19
+ type VelayRegisteredFrame,
20
+ } from "./protocol.js";
21
+ import { VelayWebSocketBridge } from "./websocket-bridge.js";
22
+
23
+ const log = getLogger("velay-client");
24
+
25
+ const BASE_RECONNECT_DELAY_MS = 500;
26
+ const MAX_RECONNECT_DELAY_MS = 30_000;
27
+ const RECONNECT_JITTER_RATIO = 0.5;
28
+ const VELAY_POLICY_CLOSE_CODE = 4008;
29
+
30
+ export type WebSocketConstructorWithOptions = {
31
+ new (
32
+ url: string | URL,
33
+ options?: {
34
+ protocols?: string | string[];
35
+ headers?: OutgoingHttpHeaders;
36
+ },
37
+ ): WebSocket;
38
+ };
39
+
40
+ export type VelayTunnelClientOptions = {
41
+ velayBaseUrl: string;
42
+ gatewayLoopbackBaseUrl: string;
43
+ credentials: CredentialCache;
44
+ configFile: ConfigFileCache;
45
+ webSocketConstructor?: WebSocketConstructorWithOptions;
46
+ httpBridge?: typeof bridgeVelayHttpRequest;
47
+ webSocketBridgeFactory?: (
48
+ gatewayLoopbackBaseUrl: string,
49
+ sendFrame: (frame: VelayFrame) => void,
50
+ ) => VelayWebSocketBridge;
51
+ reconnect?: {
52
+ baseDelayMs?: number;
53
+ maxDelayMs?: number;
54
+ jitterRatio?: number;
55
+ random?: () => number;
56
+ };
57
+ timerApi?: TimerApi;
58
+ };
59
+
60
+ export type TimerApi = {
61
+ setTimeout: (fn: () => void, delayMs: number) => unknown;
62
+ clearTimeout: (timer: unknown) => void;
63
+ };
64
+
65
+ export class VelayTunnelClient {
66
+ private readonly webSocketConstructor: WebSocketConstructorWithOptions;
67
+ private readonly httpBridge: typeof bridgeVelayHttpRequest;
68
+ private readonly webSocketBridge: VelayWebSocketBridge;
69
+ private readonly baseReconnectDelayMs: number;
70
+ private readonly maxReconnectDelayMs: number;
71
+ private readonly reconnectJitterRatio: number;
72
+ private readonly random: () => number;
73
+ private readonly timerApi: TimerApi;
74
+ private ws: WebSocket | null = null;
75
+ private running = false;
76
+ private connecting = false;
77
+ private reconnectAttempt = 0;
78
+ private reconnectTimer: unknown = null;
79
+ private publishedTwilioPublicBaseUrl: string | undefined;
80
+ private unsubscribeConfigInvalidation: (() => void) | undefined;
81
+
82
+ constructor(private readonly options: VelayTunnelClientOptions) {
83
+ this.webSocketConstructor =
84
+ options.webSocketConstructor ??
85
+ (WebSocket as unknown as WebSocketConstructorWithOptions);
86
+ this.httpBridge = options.httpBridge ?? bridgeVelayHttpRequest;
87
+ this.webSocketBridge = (
88
+ options.webSocketBridgeFactory ?? defaultWebSocketBridgeFactory
89
+ )(options.gatewayLoopbackBaseUrl, (frame) => this.sendFrame(frame));
90
+ this.baseReconnectDelayMs =
91
+ options.reconnect?.baseDelayMs ?? BASE_RECONNECT_DELAY_MS;
92
+ this.maxReconnectDelayMs =
93
+ options.reconnect?.maxDelayMs ?? MAX_RECONNECT_DELAY_MS;
94
+ this.reconnectJitterRatio =
95
+ options.reconnect?.jitterRatio ?? RECONNECT_JITTER_RATIO;
96
+ this.random = options.reconnect?.random ?? Math.random;
97
+ this.timerApi = options.timerApi ?? defaultTimerApi;
98
+ }
99
+
100
+ start(): void {
101
+ if (this.running) return;
102
+ this.running = true;
103
+ this.unsubscribeConfigInvalidation ??= this.options.configFile.onInvalidate(
104
+ () => {
105
+ this.handleConfigInvalidated();
106
+ },
107
+ );
108
+ this.startAsync().catch((err) => {
109
+ this.connecting = false;
110
+ log.error({ err }, "Failed to start Velay tunnel client");
111
+ this.scheduleReconnect();
112
+ });
113
+ }
114
+
115
+ async stop(): Promise<void> {
116
+ this.running = false;
117
+ this.connecting = false;
118
+ this.unsubscribeConfigInvalidation?.();
119
+ this.unsubscribeConfigInvalidation = undefined;
120
+ if (this.reconnectTimer) {
121
+ this.timerApi.clearTimeout(this.reconnectTimer);
122
+ this.reconnectTimer = null;
123
+ }
124
+ const ws = this.ws;
125
+ this.ws = null;
126
+ this.webSocketBridge.closeAll();
127
+ await this.clearPublishedTwilioPublicBaseUrl();
128
+ if (ws) {
129
+ closeWebSocket(ws, 1000, "gateway shutdown");
130
+ }
131
+ }
132
+
133
+ private async startAsync(): Promise<void> {
134
+ await clearManagedPublicBaseUrl(this.options.configFile);
135
+ await this.connect();
136
+ }
137
+
138
+ private async connect(): Promise<void> {
139
+ if (!this.running || this.connecting) return;
140
+ this.connecting = true;
141
+
142
+ if (this.isPublicIngressDisabled()) {
143
+ this.connecting = false;
144
+ await this.clearPublishedTwilioPublicBaseUrl();
145
+ log.info("Velay tunnel waiting because public ingress is disabled");
146
+ this.scheduleReconnect();
147
+ return;
148
+ }
149
+
150
+ let apiKeyRaw: string | undefined;
151
+ let platformAssistantIdRaw: string | undefined;
152
+ try {
153
+ [apiKeyRaw, platformAssistantIdRaw] = await Promise.all([
154
+ this.options.credentials.get(
155
+ credentialKey("vellum", "assistant_api_key"),
156
+ ),
157
+ this.options.credentials.get(
158
+ credentialKey("vellum", "platform_assistant_id"),
159
+ ),
160
+ ]);
161
+ } catch (err) {
162
+ this.connecting = false;
163
+ log.warn({ err }, "Failed to read Velay tunnel credentials");
164
+ this.scheduleReconnect();
165
+ return;
166
+ }
167
+
168
+ if (!this.running) {
169
+ this.connecting = false;
170
+ return;
171
+ }
172
+
173
+ const apiKey = apiKeyRaw?.trim();
174
+ const platformAssistantId = platformAssistantIdRaw?.trim() || undefined;
175
+ if (!apiKey) {
176
+ this.connecting = false;
177
+ log.info("Velay tunnel waiting for assistant API key");
178
+ this.scheduleReconnect();
179
+ return;
180
+ }
181
+ const expectedAssistantId = platformAssistantId;
182
+
183
+ let registerUrl: string;
184
+ try {
185
+ registerUrl = buildRegisterWebSocketUrl(this.options.velayBaseUrl);
186
+ } catch (err) {
187
+ this.connecting = false;
188
+ log.error({ err }, "Invalid Velay base URL");
189
+ this.scheduleReconnect();
190
+ return;
191
+ }
192
+
193
+ try {
194
+ const ws = new this.webSocketConstructor(registerUrl, {
195
+ protocols: [VELAY_TUNNEL_SUBPROTOCOL],
196
+ headers: { Authorization: `Api-Key ${apiKey}` },
197
+ });
198
+ ws.binaryType = "arraybuffer";
199
+ this.ws = ws;
200
+ this.connecting = false;
201
+
202
+ ws.addEventListener("open", () => {
203
+ if (this.ws !== ws || !this.running) return;
204
+ log.info("Velay tunnel connected");
205
+ });
206
+
207
+ ws.addEventListener("message", (event) => {
208
+ void this.handleMessage(event.data, ws, expectedAssistantId).catch(
209
+ (err) => {
210
+ log.error({ err }, "Failed to handle Velay frame");
211
+ },
212
+ );
213
+ });
214
+
215
+ ws.addEventListener("close", (event) => {
216
+ this.handleClose(ws, event);
217
+ });
218
+
219
+ ws.addEventListener("error", (event) => {
220
+ if (this.ws !== ws || !this.running) return;
221
+ log.warn({ error: String(event) }, "Velay tunnel WebSocket error");
222
+ if (ws.readyState === WebSocket.CONNECTING) {
223
+ this.disconnectActiveWebSocket(ws);
224
+ }
225
+ });
226
+ } catch (err) {
227
+ this.ws = null;
228
+ this.connecting = false;
229
+ log.warn({ err }, "Failed to connect Velay tunnel");
230
+ this.scheduleReconnect();
231
+ }
232
+ }
233
+
234
+ private async handleMessage(
235
+ data: unknown,
236
+ originWs: WebSocket,
237
+ platformAssistantId: string | undefined,
238
+ ): Promise<void> {
239
+ if (this.ws !== originWs || !this.running) return;
240
+
241
+ const frame = parseVelayFrame(data);
242
+ if (!frame) {
243
+ log.warn("Ignoring malformed Velay frame");
244
+ return;
245
+ }
246
+
247
+ switch (frame.type) {
248
+ case VELAY_FRAME_TYPES.registered:
249
+ await this.handleRegisteredFrame(frame, originWs, platformAssistantId);
250
+ return;
251
+ case VELAY_FRAME_TYPES.httpRequest:
252
+ await this.handleHttpRequestFrame(frame, originWs);
253
+ return;
254
+ case VELAY_FRAME_TYPES.websocketOpen:
255
+ case VELAY_FRAME_TYPES.websocketMessage:
256
+ case VELAY_FRAME_TYPES.websocketClose:
257
+ this.webSocketBridge.handleFrame(frame);
258
+ return;
259
+ default:
260
+ log.debug({ type: frame.type }, "Ignoring unsupported Velay frame");
261
+ }
262
+ }
263
+
264
+ private async handleRegisteredFrame(
265
+ frame: VelayRegisteredFrame,
266
+ originWs: WebSocket,
267
+ platformAssistantId: string | undefined,
268
+ ): Promise<void> {
269
+ if (platformAssistantId && frame.assistant_id !== platformAssistantId) {
270
+ log.error(
271
+ {
272
+ expectedAssistantId: platformAssistantId,
273
+ receivedAssistantId: frame.assistant_id,
274
+ },
275
+ "Velay registered assistant ID mismatch",
276
+ );
277
+ this.disconnectActiveWebSocket(
278
+ originWs,
279
+ VELAY_POLICY_CLOSE_CODE,
280
+ "assistant ID mismatch",
281
+ );
282
+ return;
283
+ }
284
+
285
+ const publicUrl = normalizeHttpPublicBaseUrl(frame.public_url);
286
+ if (!publicUrl) {
287
+ log.error(
288
+ { publicUrl: frame.public_url },
289
+ "Velay registered invalid Twilio public URL",
290
+ );
291
+ this.disconnectActiveWebSocket(
292
+ originWs,
293
+ VELAY_POLICY_CLOSE_CODE,
294
+ "invalid public URL",
295
+ );
296
+ return;
297
+ }
298
+
299
+ if (this.isPublicIngressDisabled()) {
300
+ log.info(
301
+ { publicUrl },
302
+ "Skipping Velay Twilio public URL publish because public ingress is disabled",
303
+ );
304
+ this.disconnectActiveWebSocket(originWs, 1000, "public ingress disabled");
305
+ return;
306
+ }
307
+
308
+ await writeManagedPublicBaseUrl(publicUrl, this.options.configFile);
309
+ this.publishedTwilioPublicBaseUrl = publicUrl;
310
+ this.reconnectAttempt = 0;
311
+ log.info({ publicUrl }, "Velay tunnel registered");
312
+ }
313
+
314
+ private async handleHttpRequestFrame(
315
+ frame: VelayHttpRequestFrame,
316
+ originWs: WebSocket,
317
+ ): Promise<void> {
318
+ const response = await this.httpBridge(
319
+ frame,
320
+ this.options.gatewayLoopbackBaseUrl,
321
+ );
322
+ if (this.ws !== originWs || !this.running) return;
323
+ this.sendFrame(response);
324
+ }
325
+
326
+ private handleClose(ws: WebSocket, event: CloseEvent): void {
327
+ if (this.ws !== ws) return;
328
+ this.ws = null;
329
+ this.connecting = false;
330
+ this.webSocketBridge.closeAll();
331
+ log.info(
332
+ { code: event.code, reason: event.reason },
333
+ "Velay tunnel disconnected",
334
+ );
335
+ this.clearPublishedTwilioPublicBaseUrlThenReconnect();
336
+ }
337
+
338
+ private disconnectActiveWebSocket(
339
+ ws: WebSocket,
340
+ code?: number,
341
+ reason?: string,
342
+ ): void {
343
+ if (this.ws !== ws) return;
344
+ this.ws = null;
345
+ this.connecting = false;
346
+ this.webSocketBridge.closeAll();
347
+ closeWebSocket(ws, code, reason);
348
+ this.clearPublishedTwilioPublicBaseUrlThenReconnect();
349
+ }
350
+
351
+ private async clearPublishedTwilioPublicBaseUrl(): Promise<void> {
352
+ const publicUrl = this.publishedTwilioPublicBaseUrl;
353
+ if (!publicUrl) return;
354
+ this.publishedTwilioPublicBaseUrl = undefined;
355
+ try {
356
+ await clearManagedPublicBaseUrl(this.options.configFile, publicUrl);
357
+ } catch (err) {
358
+ log.error({ err }, "Failed to clear Velay Twilio public URL");
359
+ }
360
+ }
361
+
362
+ private clearPublishedTwilioPublicBaseUrlThenReconnect(): void {
363
+ void this.clearPublishedTwilioPublicBaseUrl()
364
+ .catch((err) => {
365
+ log.error({ err }, "Failed to clear Velay Twilio public URL");
366
+ })
367
+ .finally(() => {
368
+ this.scheduleReconnect();
369
+ });
370
+ }
371
+
372
+ private sendFrame(frame: VelayFrame): void {
373
+ const ws = this.ws;
374
+ if (!this.running || !ws || ws.readyState !== WebSocket.OPEN) return;
375
+ ws.send(JSON.stringify(frame));
376
+ }
377
+
378
+ private scheduleReconnect(): void {
379
+ if (!this.running || this.reconnectTimer) return;
380
+
381
+ const backoff = Math.min(
382
+ this.baseReconnectDelayMs * Math.pow(2, this.reconnectAttempt),
383
+ this.maxReconnectDelayMs,
384
+ );
385
+ const jitter = backoff * this.reconnectJitterRatio * this.random();
386
+ const delay = Math.round(backoff + jitter);
387
+ this.reconnectAttempt++;
388
+
389
+ this.reconnectTimer = this.timerApi.setTimeout(() => {
390
+ this.reconnectTimer = null;
391
+ this.connect().catch((err) => {
392
+ this.connecting = false;
393
+ log.error({ err }, "Velay reconnect failed");
394
+ this.scheduleReconnect();
395
+ });
396
+ }, delay);
397
+ }
398
+
399
+ private handleConfigInvalidated(): void {
400
+ const ws = this.ws;
401
+ if (!ws || !this.isPublicIngressDisabled()) return;
402
+
403
+ log.info("Closing Velay tunnel because public ingress is disabled");
404
+ this.disconnectActiveWebSocket(ws, 1000, "public ingress disabled");
405
+ }
406
+
407
+ private isPublicIngressDisabled(): boolean {
408
+ return (
409
+ this.options.configFile.getBoolean("ingress", "enabled", {
410
+ force: true,
411
+ }) === false
412
+ );
413
+ }
414
+ }
415
+
416
+ export function createVelayTunnelClient(
417
+ config: GatewayConfig,
418
+ deps: {
419
+ credentials: CredentialCache;
420
+ configFile: ConfigFileCache;
421
+ },
422
+ ): VelayTunnelClient | undefined {
423
+ if (!config.velayBaseUrl) {
424
+ const isPlatform =
425
+ process.env.IS_PLATFORM?.trim().toLowerCase() === "true" ||
426
+ process.env.IS_PLATFORM?.trim() === "1";
427
+ if (isPlatform) {
428
+ log.warn(
429
+ "VELAY_BASE_URL is not configured on a platform pod — the assistant tunnel will not be established and inbound webhook delivery will fail",
430
+ );
431
+ }
432
+ void clearManagedPublicBaseUrl(deps.configFile).catch((err) => {
433
+ log.error({ err }, "Failed to clear disabled Velay Twilio public URL");
434
+ });
435
+ return undefined;
436
+ }
437
+ return new VelayTunnelClient({
438
+ velayBaseUrl: config.velayBaseUrl,
439
+ gatewayLoopbackBaseUrl: config.gatewayInternalBaseUrl,
440
+ credentials: deps.credentials,
441
+ configFile: deps.configFile,
442
+ });
443
+ }
444
+
445
+ const defaultTimerApi: TimerApi = {
446
+ setTimeout: (fn, delayMs) => setTimeout(fn, delayMs),
447
+ clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
448
+ };
449
+
450
+ function defaultWebSocketBridgeFactory(
451
+ gatewayLoopbackBaseUrl: string,
452
+ sendFrame: (frame: VelayFrame) => void,
453
+ ): VelayWebSocketBridge {
454
+ return new VelayWebSocketBridge(gatewayLoopbackBaseUrl, sendFrame);
455
+ }
456
+
457
+ async function mutateGatewayConfigFile(
458
+ configFile: ConfigFileCache,
459
+ malformedLogMessage: string,
460
+ mutate: (data: Record<string, unknown>) => boolean,
461
+ ): Promise<void> {
462
+ const result = await mutateConfigFile(mutate, {
463
+ shouldWrite: (changed) => changed,
464
+ onWritten: () => {
465
+ configFile.invalidate();
466
+ },
467
+ });
468
+ if (!result.ok) {
469
+ log.error({ detail: result.detail }, malformedLogMessage);
470
+ }
471
+ }
472
+
473
+ const VELAY_MANAGED_BY = "velay";
474
+
475
+ async function writeManagedPublicBaseUrl(
476
+ publicUrl: string,
477
+ configFile: ConfigFileCache,
478
+ ): Promise<void> {
479
+ return mutateGatewayConfigFile(
480
+ configFile,
481
+ "Cannot publish Velay public URL because config.json is malformed",
482
+ (data) => {
483
+ const ingress = getMutableIngress(data);
484
+ if (
485
+ ingress.publicBaseUrl === publicUrl &&
486
+ ingress.publicBaseUrlManagedBy === VELAY_MANAGED_BY
487
+ ) {
488
+ return false;
489
+ }
490
+
491
+ ingress.publicBaseUrl = publicUrl;
492
+ ingress.publicBaseUrlManagedBy = VELAY_MANAGED_BY;
493
+ data.ingress = ingress;
494
+ return true;
495
+ },
496
+ );
497
+ }
498
+
499
+ async function clearManagedPublicBaseUrl(
500
+ configFile: ConfigFileCache,
501
+ expectedPublicUrl?: string,
502
+ ): Promise<void> {
503
+ return mutateGatewayConfigFile(
504
+ configFile,
505
+ "Cannot clear Velay public URL because config.json is malformed",
506
+ (data) => {
507
+ if (
508
+ !data.ingress ||
509
+ typeof data.ingress !== "object" ||
510
+ Array.isArray(data.ingress)
511
+ ) {
512
+ return false;
513
+ }
514
+
515
+ const ingress = { ...(data.ingress as Record<string, unknown>) };
516
+ if (ingress.publicBaseUrlManagedBy !== VELAY_MANAGED_BY) {
517
+ return false;
518
+ }
519
+ if (
520
+ expectedPublicUrl !== undefined &&
521
+ ingress.publicBaseUrl !== expectedPublicUrl
522
+ ) {
523
+ delete ingress.publicBaseUrlManagedBy;
524
+ data.ingress = ingress;
525
+ return true;
526
+ }
527
+
528
+ delete ingress.publicBaseUrl;
529
+ delete ingress.publicBaseUrlManagedBy;
530
+ data.ingress = ingress;
531
+ return true;
532
+ },
533
+ );
534
+ }
535
+
536
+ function getMutableIngress(
537
+ data: Record<string, unknown>,
538
+ ): Record<string, unknown> {
539
+ return data.ingress &&
540
+ typeof data.ingress === "object" &&
541
+ !Array.isArray(data.ingress)
542
+ ? { ...(data.ingress as Record<string, unknown>) }
543
+ : {};
544
+ }
545
+
546
+ function buildRegisterWebSocketUrl(baseUrl: string): string {
547
+ const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
548
+ const url = new URL("v1/register", normalizedBaseUrl);
549
+ if (url.protocol === "http:") url.protocol = "ws:";
550
+ if (url.protocol === "https:") url.protocol = "wss:";
551
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") {
552
+ throw new Error("VELAY_BASE_URL must use http, https, ws, or wss");
553
+ }
554
+ return url.toString();
555
+ }