@vellumai/vellum-gateway 0.3.16 → 0.3.18

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/browser-relay-websocket.test.ts +257 -0
  3. package/src/__tests__/load-guards.test.ts +3 -0
  4. package/src/__tests__/oauth-callback.test.ts +3 -0
  5. package/src/__tests__/resolve-assistant.test.ts +3 -0
  6. package/src/__tests__/runtime-client.test.ts +3 -0
  7. package/src/__tests__/runtime-proxy-auth.test.ts +3 -0
  8. package/src/__tests__/runtime-proxy.test.ts +3 -0
  9. package/src/__tests__/sms-ingress-guard.test.ts +3 -0
  10. package/src/__tests__/telegram-api-redaction.test.ts +3 -0
  11. package/src/__tests__/telegram-deliver-auth.test.ts +3 -0
  12. package/src/__tests__/telegram-reconcile-route.test.ts +3 -0
  13. package/src/__tests__/telegram-send-attachments.test.ts +3 -0
  14. package/src/__tests__/telegram-webhook-handler.test.ts +3 -0
  15. package/src/__tests__/telegram-webhook-manager.test.ts +3 -0
  16. package/src/__tests__/twilio-relay-websocket.test.ts +3 -0
  17. package/src/__tests__/twilio-webhooks.test.ts +3 -0
  18. package/src/http/routes/browser-relay-websocket.ts +195 -0
  19. package/src/http/routes/slack-deliver.ts +1 -1
  20. package/src/http/routes/sms-deliver.test.ts +3 -0
  21. package/src/http/routes/telegram-deliver.test.ts +3 -0
  22. package/src/http/routes/telegram-webhook.test.ts +3 -0
  23. package/src/http/routes/twilio-relay-websocket.ts +1 -1
  24. package/src/http/routes/twilio-sms-webhook.test.ts +3 -0
  25. package/src/http/routes/twilio-voice-webhook.test.ts +3 -0
  26. package/src/http/routes/whatsapp-deliver.test.ts +3 -0
  27. package/src/http/routes/whatsapp-webhook.test.ts +3 -0
  28. package/src/index.ts +43 -1
  29. package/src/telegram/send.test.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun run --watch src/index.ts",
@@ -0,0 +1,257 @@
1
+ import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test";
2
+ import type { GatewayConfig } from "../config.js";
3
+ import {
4
+ createBrowserRelayWebsocketHandler,
5
+ getBrowserRelayWebsocketHandlers,
6
+ } from "../http/routes/browser-relay-websocket.js";
7
+
8
+ const WS_CONNECTING = WebSocket.CONNECTING; // 0
9
+ const WS_OPEN = WebSocket.OPEN; // 1
10
+ const WS_CLOSED = WebSocket.CLOSED; // 3
11
+
12
+ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
13
+ const merged: GatewayConfig = {
14
+ telegramBotToken: "tok",
15
+ telegramWebhookSecret: "wh-ver",
16
+ telegramApiBaseUrl: "https://api.telegram.org",
17
+ assistantRuntimeBaseUrl: "http://localhost:7821",
18
+ routingEntries: [],
19
+ defaultAssistantId: undefined,
20
+ unmappedPolicy: "reject",
21
+ port: 7830,
22
+ runtimeBearerToken: undefined,
23
+ runtimeGatewayOriginSecret: undefined,
24
+ runtimeProxyEnabled: false,
25
+ runtimeProxyRequireAuth: true,
26
+ runtimeProxyBearerToken: undefined,
27
+ shutdownDrainMs: 5000,
28
+ runtimeTimeoutMs: 30000,
29
+ runtimeMaxRetries: 2,
30
+ runtimeInitialBackoffMs: 500,
31
+ telegramDeliverAuthBypass: false,
32
+ telegramInitialBackoffMs: 1000,
33
+ telegramMaxRetries: 3,
34
+ telegramTimeoutMs: 15000,
35
+ maxWebhookPayloadBytes: 1048576,
36
+ logFile: { dir: undefined, retentionDays: 30 },
37
+ maxAttachmentBytes: 20971520,
38
+ maxAttachmentConcurrency: 3,
39
+ twilioAuthToken: undefined,
40
+ twilioAccountSid: undefined,
41
+ twilioPhoneNumber: undefined,
42
+ smsDeliverAuthBypass: false,
43
+ ingressPublicBaseUrl: undefined,
44
+ gatewayInternalBaseUrl: "http://127.0.0.1:7830",
45
+ whatsappPhoneNumberId: undefined,
46
+ whatsappAccessToken: undefined,
47
+ whatsappAppSecret: undefined,
48
+ whatsappWebhookVerifyToken: undefined,
49
+ whatsappDeliverAuthBypass: false,
50
+ whatsappTimeoutMs: 15000,
51
+ whatsappMaxRetries: 3,
52
+ whatsappInitialBackoffMs: 1000,
53
+ slackChannelBotToken: undefined,
54
+ slackChannelAppToken: undefined,
55
+ slackDeliverAuthBypass: false,
56
+ trustProxy: false,
57
+ ...overrides,
58
+ };
59
+ if (merged.runtimeGatewayOriginSecret === undefined) {
60
+ merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
61
+ }
62
+ return merged;
63
+ }
64
+
65
+ function createFakeDownstreamWs(data: Record<string, unknown> = {}) {
66
+ const sent: (string | Uint8Array)[] = [];
67
+ const closes: { code: number; reason: string }[] = [];
68
+ return {
69
+ data,
70
+ sent,
71
+ closes,
72
+ send: mock((msg: string | Uint8Array) => {
73
+ sent.push(msg);
74
+ }),
75
+ close: mock((code?: number, reason?: string) => {
76
+ closes.push({ code: code ?? 1000, reason: reason ?? "" });
77
+ }),
78
+ };
79
+ }
80
+
81
+ function createFakeUpstreamWs() {
82
+ const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
83
+ const sent: unknown[] = [];
84
+ return {
85
+ readyState: WS_CONNECTING as number,
86
+ sent,
87
+ listeners,
88
+ addEventListener: mock((event: string, cb: (...args: unknown[]) => void) => {
89
+ (listeners[event] ??= []).push(cb);
90
+ }),
91
+ send: mock((msg: unknown) => {
92
+ sent.push(msg);
93
+ }),
94
+ close: mock(() => {}),
95
+ emit(event: string, detail: unknown = {}) {
96
+ for (const cb of listeners[event] ?? []) {
97
+ cb(detail);
98
+ }
99
+ },
100
+ };
101
+ }
102
+
103
+ describe("createBrowserRelayWebsocketHandler", () => {
104
+ const TEST_TOKEN = "relay-token-abc123";
105
+
106
+ test("upgrades when token query parameter is valid", () => {
107
+ const config = makeConfig({ runtimeProxyBearerToken: TEST_TOKEN });
108
+ const handler = createBrowserRelayWebsocketHandler(config);
109
+ const req = new Request(
110
+ `http://localhost:7830/v1/browser-relay?token=${TEST_TOKEN}`,
111
+ { headers: { upgrade: "websocket" } },
112
+ );
113
+ const fakeServer = {
114
+ requestIP: mock(() => ({ address: "127.0.0.1", family: "IPv4", port: 54000 })),
115
+ upgrade: mock(() => true),
116
+ } as unknown as import("bun").Server<any>;
117
+ const res = handler(req, fakeServer);
118
+
119
+ expect(res).toBeUndefined();
120
+ expect(fakeServer.upgrade).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ test("returns 401 when token is missing", () => {
124
+ const config = makeConfig({ runtimeProxyBearerToken: TEST_TOKEN });
125
+ const handler = createBrowserRelayWebsocketHandler(config);
126
+ const req = new Request("http://localhost:7830/v1/browser-relay", {
127
+ headers: { upgrade: "websocket" },
128
+ });
129
+ const fakeServer = {
130
+ requestIP: mock(() => ({ address: "127.0.0.1", family: "IPv4", port: 54000 })),
131
+ upgrade: mock(() => true),
132
+ } as unknown as import("bun").Server<any>;
133
+ const res = handler(req, fakeServer);
134
+
135
+ expect(res).toBeInstanceOf(Response);
136
+ expect(res!.status).toBe(401);
137
+ expect(fakeServer.upgrade).not.toHaveBeenCalled();
138
+ });
139
+
140
+ test("allows unauthenticated upgrade when runtime proxy auth is disabled", () => {
141
+ const config = makeConfig({ runtimeProxyRequireAuth: false, runtimeProxyBearerToken: undefined });
142
+ const handler = createBrowserRelayWebsocketHandler(config);
143
+ const req = new Request("http://localhost:7830/v1/browser-relay", {
144
+ headers: { upgrade: "websocket" },
145
+ });
146
+ const fakeServer = {
147
+ requestIP: mock(() => ({ address: "127.0.0.1", family: "IPv4", port: 54000 })),
148
+ upgrade: mock(() => true),
149
+ } as unknown as import("bun").Server<any>;
150
+ const res = handler(req, fakeServer);
151
+
152
+ expect(res).toBeUndefined();
153
+ expect(fakeServer.upgrade).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ test("returns 403 when non-loopback host is requested from a public peer", () => {
157
+ const config = makeConfig({ runtimeProxyBearerToken: TEST_TOKEN });
158
+ const handler = createBrowserRelayWebsocketHandler(config);
159
+ const req = new Request(
160
+ `http://gateway.example.com:7830/v1/browser-relay?token=${TEST_TOKEN}`,
161
+ { headers: { upgrade: "websocket" } },
162
+ );
163
+ const fakeServer = {
164
+ requestIP: mock(() => ({ address: "8.8.8.8", family: "IPv4", port: 54000 })),
165
+ upgrade: mock(() => true),
166
+ } as unknown as import("bun").Server<any>;
167
+
168
+ const res = handler(req, fakeServer);
169
+
170
+ expect(res).toBeInstanceOf(Response);
171
+ expect(res!.status).toBe(403);
172
+ expect(fakeServer.upgrade).not.toHaveBeenCalled();
173
+ });
174
+
175
+ test("returns 403 for localhost host when peer is public (host spoof prevention)", () => {
176
+ const config = makeConfig({ runtimeProxyBearerToken: TEST_TOKEN });
177
+ const handler = createBrowserRelayWebsocketHandler(config);
178
+ const req = new Request(
179
+ `http://localhost:7830/v1/browser-relay?token=${TEST_TOKEN}`,
180
+ { headers: { upgrade: "websocket" } },
181
+ );
182
+ const fakeServer = {
183
+ requestIP: mock(() => ({ address: "8.8.8.8", family: "IPv4", port: 54000 })),
184
+ upgrade: mock(() => true),
185
+ } as unknown as import("bun").Server<any>;
186
+
187
+ const res = handler(req, fakeServer);
188
+
189
+ expect(res).toBeInstanceOf(Response);
190
+ expect(res!.status).toBe(403);
191
+ expect(fakeServer.upgrade).not.toHaveBeenCalled();
192
+ });
193
+
194
+ test("allows non-loopback host when peer is private network", () => {
195
+ const config = makeConfig({ runtimeProxyBearerToken: TEST_TOKEN });
196
+ const handler = createBrowserRelayWebsocketHandler(config);
197
+ const req = new Request(
198
+ `http://gateway.example.com:7830/v1/browser-relay?token=${TEST_TOKEN}`,
199
+ { headers: { upgrade: "websocket" } },
200
+ );
201
+ const fakeServer = {
202
+ requestIP: mock(() => ({ address: "10.42.0.8", family: "IPv4", port: 54000 })),
203
+ upgrade: mock(() => true),
204
+ } as unknown as import("bun").Server<any>;
205
+
206
+ const res = handler(req, fakeServer);
207
+
208
+ expect(res).toBeUndefined();
209
+ expect(fakeServer.upgrade).toHaveBeenCalledTimes(1);
210
+ });
211
+ });
212
+
213
+ describe("getBrowserRelayWebsocketHandlers", () => {
214
+ const OriginalWebSocket = globalThis.WebSocket;
215
+ let fakeUpstream: ReturnType<typeof createFakeUpstreamWs>;
216
+ let handlers: ReturnType<typeof getBrowserRelayWebsocketHandlers>;
217
+
218
+ beforeEach(() => {
219
+ fakeUpstream = createFakeUpstreamWs();
220
+ const MockWS = mock(() => fakeUpstream);
221
+ Object.assign(MockWS, {
222
+ CONNECTING: WS_CONNECTING,
223
+ OPEN: WS_OPEN,
224
+ CLOSING: 2,
225
+ CLOSED: WS_CLOSED,
226
+ });
227
+ globalThis.WebSocket = MockWS as unknown as typeof WebSocket;
228
+ handlers = getBrowserRelayWebsocketHandlers();
229
+ });
230
+
231
+ afterAll(() => {
232
+ globalThis.WebSocket = OriginalWebSocket;
233
+ });
234
+
235
+ test("open targets runtime browser-relay websocket and flushes buffered messages", () => {
236
+ const ws = createFakeDownstreamWs({
237
+ wsType: "browser-relay",
238
+ config: makeConfig({
239
+ assistantRuntimeBaseUrl: "http://runtime.internal:7821",
240
+ runtimeBearerToken: "runtime-token",
241
+ }),
242
+ });
243
+
244
+ handlers.open(ws as never);
245
+ handlers.message(ws as never, "hello-before-open");
246
+
247
+ const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
248
+ expect(MockWS).toHaveBeenCalledWith("ws://runtime.internal:7821/v1/browser-relay?token=runtime-token");
249
+
250
+ fakeUpstream.readyState = WS_OPEN;
251
+ fakeUpstream.emit("open");
252
+ expect(fakeUpstream.sent).toEqual(["hello-before-open"]);
253
+
254
+ fakeUpstream.emit("message", { data: "runtime-message" });
255
+ expect(ws.sent).toEqual(["runtime-message"]);
256
+ });
257
+ });
@@ -43,6 +43,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
43
43
  whatsappTimeoutMs: 15000,
44
44
  whatsappMaxRetries: 3,
45
45
  whatsappInitialBackoffMs: 1000,
46
+ slackChannelBotToken: undefined,
47
+ slackChannelAppToken: undefined,
48
+ slackDeliverAuthBypass: false,
46
49
  trustProxy: false,
47
50
  ...overrides,
48
51
  };
@@ -55,6 +55,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
55
55
  whatsappTimeoutMs: 15000,
56
56
  whatsappMaxRetries: 3,
57
57
  whatsappInitialBackoffMs: 1000,
58
+ slackChannelBotToken: undefined,
59
+ slackChannelAppToken: undefined,
60
+ slackDeliverAuthBypass: false,
58
61
  trustProxy: false,
59
62
  ...overrides,
60
63
  };
@@ -43,6 +43,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
43
43
  whatsappTimeoutMs: 15000,
44
44
  whatsappMaxRetries: 3,
45
45
  whatsappInitialBackoffMs: 1000,
46
+ slackChannelBotToken: undefined,
47
+ slackChannelAppToken: undefined,
48
+ slackDeliverAuthBypass: false,
46
49
  trustProxy: false,
47
50
  ...overrides,
48
51
  };
@@ -58,6 +58,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
58
58
  whatsappTimeoutMs: 15000,
59
59
  whatsappMaxRetries: 3,
60
60
  whatsappInitialBackoffMs: 1000,
61
+ slackChannelBotToken: undefined,
62
+ slackChannelAppToken: undefined,
63
+ slackDeliverAuthBypass: false,
61
64
  trustProxy: false,
62
65
  ...overrides,
63
66
  };
@@ -53,6 +53,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
53
53
  whatsappTimeoutMs: 15000,
54
54
  whatsappMaxRetries: 3,
55
55
  whatsappInitialBackoffMs: 1000,
56
+ slackChannelBotToken: undefined,
57
+ slackChannelAppToken: undefined,
58
+ slackDeliverAuthBypass: false,
56
59
  trustProxy: false,
57
60
  ...overrides,
58
61
  };
@@ -51,6 +51,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
51
51
  whatsappTimeoutMs: 15000,
52
52
  whatsappMaxRetries: 3,
53
53
  whatsappInitialBackoffMs: 1000,
54
+ slackChannelBotToken: undefined,
55
+ slackChannelAppToken: undefined,
56
+ slackDeliverAuthBypass: false,
54
57
  trustProxy: false,
55
58
  ...overrides,
56
59
  };
@@ -57,6 +57,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
57
57
  whatsappTimeoutMs: 15000,
58
58
  whatsappMaxRetries: 3,
59
59
  whatsappInitialBackoffMs: 1000,
60
+ slackChannelBotToken: undefined,
61
+ slackChannelAppToken: undefined,
62
+ slackDeliverAuthBypass: false,
60
63
  trustProxy: false,
61
64
  ...overrides,
62
65
  };
@@ -51,6 +51,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
51
51
  whatsappTimeoutMs: 15000,
52
52
  whatsappMaxRetries: 3,
53
53
  whatsappInitialBackoffMs: 1000,
54
+ slackChannelBotToken: undefined,
55
+ slackChannelAppToken: undefined,
56
+ slackDeliverAuthBypass: false,
54
57
  trustProxy: false,
55
58
  ...overrides,
56
59
  };
@@ -53,6 +53,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
53
53
  whatsappTimeoutMs: 15000,
54
54
  whatsappMaxRetries: 3,
55
55
  whatsappInitialBackoffMs: 1000,
56
+ slackChannelBotToken: undefined,
57
+ slackChannelAppToken: undefined,
58
+ slackDeliverAuthBypass: false,
56
59
  trustProxy: false,
57
60
  ...overrides,
58
61
  };
@@ -58,6 +58,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
58
58
  whatsappTimeoutMs: 15000,
59
59
  whatsappMaxRetries: 3,
60
60
  whatsappInitialBackoffMs: 1000,
61
+ slackChannelBotToken: undefined,
62
+ slackChannelAppToken: undefined,
63
+ slackDeliverAuthBypass: false,
61
64
  trustProxy: false,
62
65
  ...overrides,
63
66
  };
@@ -52,6 +52,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
52
52
  whatsappTimeoutMs: 15000,
53
53
  whatsappMaxRetries: 3,
54
54
  whatsappInitialBackoffMs: 1000,
55
+ slackChannelBotToken: undefined,
56
+ slackChannelAppToken: undefined,
57
+ slackDeliverAuthBypass: false,
55
58
  trustProxy: false,
56
59
  ...overrides,
57
60
  };
@@ -51,6 +51,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
51
51
  whatsappTimeoutMs: 15000,
52
52
  whatsappMaxRetries: 3,
53
53
  whatsappInitialBackoffMs: 1000,
54
+ slackChannelBotToken: undefined,
55
+ slackChannelAppToken: undefined,
56
+ slackDeliverAuthBypass: false,
54
57
  trustProxy: false,
55
58
  ...overrides,
56
59
  };
@@ -51,6 +51,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
51
51
  whatsappTimeoutMs: 15000,
52
52
  whatsappMaxRetries: 3,
53
53
  whatsappInitialBackoffMs: 1000,
54
+ slackChannelBotToken: undefined,
55
+ slackChannelAppToken: undefined,
56
+ slackDeliverAuthBypass: false,
54
57
  trustProxy: false,
55
58
  ...overrides,
56
59
  };
@@ -58,6 +58,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
58
58
  whatsappTimeoutMs: 15000,
59
59
  whatsappMaxRetries: 3,
60
60
  whatsappInitialBackoffMs: 1000,
61
+ slackChannelBotToken: undefined,
62
+ slackChannelAppToken: undefined,
63
+ slackDeliverAuthBypass: false,
61
64
  trustProxy: false,
62
65
  ...overrides,
63
66
  };
@@ -56,6 +56,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
56
56
  whatsappTimeoutMs: 15000,
57
57
  whatsappMaxRetries: 3,
58
58
  whatsappInitialBackoffMs: 1000,
59
+ slackChannelBotToken: undefined,
60
+ slackChannelAppToken: undefined,
61
+ slackDeliverAuthBypass: false,
59
62
  trustProxy: false,
60
63
  ...overrides,
61
64
  };
@@ -0,0 +1,195 @@
1
+ import type { GatewayConfig } from "../../config.js";
2
+ import { getLogger } from "../../logger.js";
3
+ import { validateBearerToken } from "../auth/bearer.js";
4
+
5
+ const log = getLogger("browser-relay-ws");
6
+
7
+ // Cap buffered messages to prevent unbounded memory growth if upstream stalls
8
+ const MAX_PENDING_MESSAGES = 100;
9
+
10
+ function isPrivateAddress(addr: string): boolean {
11
+ const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
12
+ const normalized = v4Mapped ? v4Mapped[1] : addr;
13
+
14
+ if (normalized.includes(".")) {
15
+ const parts = normalized.split(".").map(Number);
16
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return false;
17
+
18
+ if (parts[0] === 127) return true;
19
+ if (parts[0] === 10) return true;
20
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
21
+ if (parts[0] === 192 && parts[1] === 168) return true;
22
+ if (parts[0] === 169 && parts[1] === 254) return true;
23
+
24
+ return false;
25
+ }
26
+
27
+ const lower = normalized.toLowerCase();
28
+ if (lower === "::1") return true;
29
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
30
+ if (lower.startsWith("fe80")) return true;
31
+
32
+ return false;
33
+ }
34
+
35
+ function isPrivateNetworkPeer(server: import("bun").Server<unknown>, req: Request): boolean {
36
+ const ip = server.requestIP(req);
37
+ if (!ip) return false;
38
+ return isPrivateAddress(ip.address);
39
+ }
40
+
41
+ export type BrowserRelaySocketData = {
42
+ wsType: "browser-relay";
43
+ config: GatewayConfig;
44
+ clientToken?: string;
45
+ upstream?: WebSocket;
46
+ pendingMessages?: (string | ArrayBuffer | Uint8Array)[];
47
+ };
48
+
49
+ /**
50
+ * Create a WebSocket upgrade handler that proxies browser-relay frames between
51
+ * the local Chrome extension and the runtime's /v1/browser-relay endpoint.
52
+ */
53
+ export function createBrowserRelayWebsocketHandler(config: GatewayConfig) {
54
+ return function handleUpgrade(
55
+ req: Request,
56
+ server: import("bun").Server<unknown>,
57
+ ): Response | undefined {
58
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
59
+ return new Response("Upgrade Required", { status: 426 });
60
+ }
61
+
62
+ const url = new URL(req.url);
63
+
64
+ // Trust actual peer IP, not request host headers, for local/private gating.
65
+ if (!isPrivateNetworkPeer(server, req)) {
66
+ return new Response("Browser relay only accepts connections from localhost", { status: 403 });
67
+ }
68
+
69
+ const authResponse = checkBrowserRelayAuth(req, url, config);
70
+ if (authResponse) return authResponse;
71
+
72
+ const upgraded = server.upgrade(req, {
73
+ data: {
74
+ wsType: "browser-relay",
75
+ config,
76
+ clientToken: url.searchParams.get("token") ?? undefined,
77
+ },
78
+ });
79
+
80
+ if (!upgraded) {
81
+ return new Response("WebSocket upgrade failed", { status: 500 });
82
+ }
83
+
84
+ // Return undefined to indicate upgrade was handled
85
+ return undefined;
86
+ };
87
+ }
88
+
89
+ function checkBrowserRelayAuth(
90
+ req: Request,
91
+ url: URL,
92
+ config: GatewayConfig,
93
+ ): Response | null {
94
+ if (!config.runtimeProxyRequireAuth) return null;
95
+
96
+ if (!config.runtimeProxyBearerToken) {
97
+ log.error("Browser relay WS: no bearer token configured — rejecting (fail-closed)");
98
+ return new Response("Service not configured: bearer token required", { status: 503 });
99
+ }
100
+
101
+ const authHeader = req.headers.get("authorization");
102
+ const queryToken = url.searchParams.get("token");
103
+ const tokenSource = authHeader ?? (queryToken ? `Bearer ${queryToken}` : null);
104
+
105
+ const result = validateBearerToken(tokenSource, config.runtimeProxyBearerToken);
106
+ if (!result.authorized) {
107
+ log.warn({ reason: result.reason }, "Browser relay WS: authentication failed");
108
+ return new Response("Unauthorized", { status: 401 });
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * WebSocket handler config for Bun.serve() that proxies frames to runtime.
116
+ */
117
+ export function getBrowserRelayWebsocketHandlers() {
118
+ return {
119
+ open(ws: import("bun").ServerWebSocket<BrowserRelaySocketData>) {
120
+ const { config } = ws.data;
121
+
122
+ // Initialize message buffer for frames arriving before upstream connects
123
+ ws.data.pendingMessages = [];
124
+
125
+ const runtimeBase = config.assistantRuntimeBaseUrl.replace(/^http/, "ws");
126
+ const upstreamToken = config.runtimeBearerToken || config.runtimeProxyBearerToken || ws.data.clientToken;
127
+ const query = upstreamToken ? `?token=${encodeURIComponent(upstreamToken)}` : "";
128
+ const upstreamUrl = `${runtimeBase}/v1/browser-relay${query}`;
129
+ const logSafeUpstreamUrl = `${runtimeBase}/v1/browser-relay${upstreamToken ? "?token=<redacted>" : ""}`;
130
+
131
+ log.info({ upstreamUrl: logSafeUpstreamUrl }, "Opening upstream browser relay WS to runtime");
132
+
133
+ const upstream = new WebSocket(upstreamUrl);
134
+ ws.data.upstream = upstream;
135
+
136
+ upstream.addEventListener("open", () => {
137
+ log.info("Upstream browser relay WS connected");
138
+ const pending = ws.data.pendingMessages;
139
+ if (pending) {
140
+ for (const msg of pending) {
141
+ upstream.send(msg);
142
+ }
143
+ ws.data.pendingMessages = undefined;
144
+ }
145
+ });
146
+
147
+ upstream.addEventListener("message", (event) => {
148
+ const data = typeof event.data === "string"
149
+ ? event.data
150
+ : new Uint8Array(event.data as ArrayBuffer);
151
+ ws.send(data);
152
+ });
153
+
154
+ upstream.addEventListener("close", (event) => {
155
+ log.info({ code: event.code }, "Upstream browser relay WS closed");
156
+ ws.close(event.code, event.reason);
157
+ });
158
+
159
+ upstream.addEventListener("error", (event) => {
160
+ log.error({ error: event }, "Upstream browser relay WS error");
161
+ ws.close(1011, "Upstream error");
162
+ });
163
+ },
164
+
165
+ message(
166
+ ws: import("bun").ServerWebSocket<BrowserRelaySocketData>,
167
+ message: string | ArrayBuffer | Uint8Array,
168
+ ) {
169
+ const upstream = ws.data.upstream;
170
+ if (upstream && upstream.readyState === WebSocket.OPEN) {
171
+ upstream.send(message);
172
+ } else if (ws.data.pendingMessages) {
173
+ if (ws.data.pendingMessages.length >= MAX_PENDING_MESSAGES) {
174
+ log.warn("Browser relay pending message buffer overflow — closing connection");
175
+ ws.close(1008, "Buffer overflow");
176
+ return;
177
+ }
178
+ ws.data.pendingMessages.push(message);
179
+ }
180
+ },
181
+
182
+ close(
183
+ ws: import("bun").ServerWebSocket<BrowserRelaySocketData>,
184
+ code: number,
185
+ reason: string,
186
+ ) {
187
+ const { upstream } = ws.data;
188
+ log.info({ code, reason }, "Browser relay downstream WS closed");
189
+ ws.data.pendingMessages = undefined;
190
+ if (upstream && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
191
+ upstream.close(code, reason);
192
+ }
193
+ },
194
+ };
195
+ }
@@ -39,7 +39,7 @@ export function createSlackDeliverHandler(config: GatewayConfig) {
39
39
  }
40
40
 
41
41
  if (typeof body !== 'object' || body === null) {
42
- return Response.json({ ok: false, error: "Invalid request body" }, { status: 400 });
42
+ return Response.json({ error: "Invalid request body" }, { status: 400 });
43
43
  }
44
44
 
45
45
  if (body.attachments && Array.isArray(body.attachments) && body.attachments.length > 0) {
@@ -55,6 +55,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
55
55
  whatsappTimeoutMs: 15000,
56
56
  whatsappMaxRetries: 3,
57
57
  whatsappInitialBackoffMs: 1000,
58
+ slackChannelBotToken: undefined,
59
+ slackChannelAppToken: undefined,
60
+ slackDeliverAuthBypass: false,
58
61
  trustProxy: false,
59
62
  ...overrides,
60
63
  };
@@ -62,6 +62,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
62
62
  whatsappTimeoutMs: 15000,
63
63
  whatsappMaxRetries: 3,
64
64
  whatsappInitialBackoffMs: 1000,
65
+ slackChannelBotToken: undefined,
66
+ slackChannelAppToken: undefined,
67
+ slackDeliverAuthBypass: false,
65
68
  trustProxy: false,
66
69
  ...overrides,
67
70
  };
@@ -89,6 +89,9 @@ const baseConfig: GatewayConfig = {
89
89
  whatsappTimeoutMs: 15000,
90
90
  whatsappMaxRetries: 3,
91
91
  whatsappInitialBackoffMs: 1000,
92
+ slackChannelBotToken: undefined,
93
+ slackChannelAppToken: undefined,
94
+ slackDeliverAuthBypass: false,
92
95
  trustProxy: false,
93
96
  };
94
97
 
@@ -19,7 +19,7 @@ type RelaySocketData = {
19
19
  * frames between Twilio and the runtime's /v1/calls/relay endpoint.
20
20
  */
21
21
  export function createTwilioRelayWebsocketHandler(config: GatewayConfig) {
22
- return function handleUpgrade(req: Request, server: import("bun").Server<RelaySocketData>): Response | undefined {
22
+ return function handleUpgrade(req: Request, server: import("bun").Server<unknown>): Response | undefined {
23
23
  const url = new URL(req.url);
24
24
  const callSessionId = url.searchParams.get("callSessionId");
25
25
 
@@ -72,6 +72,9 @@ const baseConfig: GatewayConfig = {
72
72
  whatsappTimeoutMs: 15000,
73
73
  whatsappMaxRetries: 3,
74
74
  whatsappInitialBackoffMs: 1000,
75
+ slackChannelBotToken: undefined,
76
+ slackChannelAppToken: undefined,
77
+ slackDeliverAuthBypass: false,
75
78
  trustProxy: false,
76
79
  };
77
80
 
@@ -105,6 +105,9 @@ const baseConfig: GatewayConfig = {
105
105
  whatsappTimeoutMs: 15000,
106
106
  whatsappMaxRetries: 3,
107
107
  whatsappInitialBackoffMs: 1000,
108
+ slackChannelBotToken: undefined,
109
+ slackChannelAppToken: undefined,
110
+ slackDeliverAuthBypass: false,
108
111
  trustProxy: false,
109
112
  };
110
113
 
@@ -59,6 +59,9 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
59
59
  whatsappTimeoutMs: 15000,
60
60
  whatsappMaxRetries: 3,
61
61
  whatsappInitialBackoffMs: 1000,
62
+ slackChannelBotToken: undefined,
63
+ slackChannelAppToken: undefined,
64
+ slackDeliverAuthBypass: false,
62
65
  trustProxy: false,
63
66
  ...overrides,
64
67
  };
@@ -76,6 +76,9 @@ const baseConfig: GatewayConfig = {
76
76
  whatsappTimeoutMs: 15000,
77
77
  whatsappMaxRetries: 3,
78
78
  whatsappInitialBackoffMs: 1000,
79
+ slackChannelBotToken: undefined,
80
+ slackChannelAppToken: undefined,
81
+ slackDeliverAuthBypass: false,
79
82
  trustProxy: false,
80
83
  };
81
84
 
package/src/index.ts CHANGED
@@ -7,6 +7,11 @@ import { ConfigFileWatcher } from "./config-file-watcher.js";
7
7
  import { loadConfig, isSlackChannelConfigured, type GatewayConfig } from "./config.js";
8
8
  import { CredentialWatcher } from "./credential-watcher.js";
9
9
  import { createRuntimeProxyHandler } from "./http/routes/runtime-proxy.js";
10
+ import {
11
+ createBrowserRelayWebsocketHandler,
12
+ getBrowserRelayWebsocketHandlers,
13
+ type BrowserRelaySocketData,
14
+ } from "./http/routes/browser-relay-websocket.js";
10
15
  import { createTelegramDeliverHandler } from "./http/routes/telegram-deliver.js";
11
16
  import { createTelegramReconcileHandler } from "./http/routes/telegram-reconcile.js";
12
17
  import { createTelegramWebhookHandler } from "./http/routes/telegram-webhook.js";
@@ -41,6 +46,10 @@ let draining = false;
41
46
  // Shared rate limiter for auth failures and unauthenticated endpoints
42
47
  const authRateLimiter = new AuthRateLimiter();
43
48
 
49
+ function isBrowserRelaySocketData(data: unknown): data is BrowserRelaySocketData {
50
+ return !!data && typeof data === "object" && (data as { wsType?: unknown }).wsType === "browser-relay";
51
+ }
52
+
44
53
  function getClientIp(req: Request, server: ReturnType<typeof Bun.serve>, trustProxy: boolean): string {
45
54
  if (trustProxy) {
46
55
  const forwarded = req.headers.get("x-forwarded-for");
@@ -126,6 +135,9 @@ function main() {
126
135
  const handleTwilioStatusWebhook = createTwilioStatusWebhookHandler(config);
127
136
  const handleTwilioConnectActionWebhook = createTwilioConnectActionWebhookHandler(config);
128
137
  const handleTwilioRelayWs = createTwilioRelayWebsocketHandler(config);
138
+ const handleBrowserRelayWs = createBrowserRelayWebsocketHandler(config);
139
+ const twilioRelayWebsocketHandlers = getRelayWebsocketHandlers();
140
+ const browserRelayWebsocketHandlers = getBrowserRelayWebsocketHandlers();
129
141
  const { handler: handleTwilioSmsWebhook, dedupCache: smsDedupCache } = createTwilioSmsWebhookHandler(config);
130
142
  const handleSmsDeliver = createSmsDeliverHandler(config);
131
143
  const { handler: handleWhatsAppWebhook, dedupCache: whatsappDedupCache } = createWhatsAppWebhookHandler(config);
@@ -140,7 +152,29 @@ function main() {
140
152
 
141
153
  const server = Bun.serve({
142
154
  port: config.port,
143
- websocket: getRelayWebsocketHandlers(),
155
+ websocket: {
156
+ open(ws) {
157
+ if (isBrowserRelaySocketData(ws.data)) {
158
+ browserRelayWebsocketHandlers.open(ws as never);
159
+ return;
160
+ }
161
+ twilioRelayWebsocketHandlers.open(ws as never);
162
+ },
163
+ message(ws, message) {
164
+ if (isBrowserRelaySocketData(ws.data)) {
165
+ browserRelayWebsocketHandlers.message(ws as never, message);
166
+ return;
167
+ }
168
+ twilioRelayWebsocketHandlers.message(ws as never, message);
169
+ },
170
+ close(ws, code, reason) {
171
+ if (isBrowserRelaySocketData(ws.data)) {
172
+ browserRelayWebsocketHandlers.close(ws as never, code, reason);
173
+ return;
174
+ }
175
+ twilioRelayWebsocketHandlers.close(ws as never, code, reason);
176
+ },
177
+ },
144
178
  error(err) {
145
179
  if (err instanceof CircuitBreakerOpenError) {
146
180
  return Response.json(
@@ -183,6 +217,7 @@ function main() {
183
217
  url.pathname !== "/v1/calls/twilio/voice-webhook" &&
184
218
  url.pathname !== "/v1/calls/twilio/status" &&
185
219
  url.pathname !== "/v1/calls/twilio/connect-action" &&
220
+ url.pathname !== "/v1/browser-relay" &&
186
221
  url.pathname !== "/v1/calls/relay");
187
222
  if (isRateLimitedRoute) {
188
223
  const clientIp = getClientIp(req, svr, config.trustProxy);
@@ -308,6 +343,13 @@ function main() {
308
343
  return undefined as unknown as Response;
309
344
  }
310
345
 
346
+ if (config.runtimeProxyEnabled && url.pathname === "/v1/browser-relay") {
347
+ const upgradeResult = handleBrowserRelayWs(req, server);
348
+ if (upgradeResult !== undefined) return upgradeResult;
349
+ // If upgrade was handled, Bun doesn't need a response
350
+ return undefined as unknown as Response;
351
+ }
352
+
311
353
  if (url.pathname === "/webhooks/oauth/callback" && tracedReq.method === "GET") {
312
354
  const res = await handleOAuthCallback(tracedReq);
313
355
  if (res.status === 400) {
@@ -54,6 +54,9 @@ const baseConfig: GatewayConfig = {
54
54
  whatsappTimeoutMs: 15000,
55
55
  whatsappMaxRetries: 3,
56
56
  whatsappInitialBackoffMs: 1000,
57
+ slackChannelBotToken: undefined,
58
+ slackChannelAppToken: undefined,
59
+ slackDeliverAuthBypass: false,
57
60
  trustProxy: false,
58
61
  };
59
62