@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.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
VELAY_FRAME_TYPES,
|
|
6
|
+
VELAY_WEBSOCKET_MESSAGE_TYPES,
|
|
7
|
+
type VelayFrame,
|
|
8
|
+
type VelayWebSocketOpenFrame,
|
|
9
|
+
} from "./protocol.js";
|
|
10
|
+
import { FakeWebSocket } from "./test-fake-websocket.js";
|
|
11
|
+
import { VelayWebSocketBridge } from "./websocket-bridge.js";
|
|
12
|
+
|
|
13
|
+
const WS_OPEN = WebSocket.OPEN;
|
|
14
|
+
|
|
15
|
+
const OriginalWebSocket = globalThis.WebSocket;
|
|
16
|
+
let fakeSocket: FakeWebSocket;
|
|
17
|
+
let sentFrames: VelayFrame[];
|
|
18
|
+
let bridge: VelayWebSocketBridge;
|
|
19
|
+
let WebSocketMock: ReturnType<typeof mock>;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
fakeSocket = new FakeWebSocket("", undefined, { validateReason: true });
|
|
23
|
+
sentFrames = [];
|
|
24
|
+
WebSocketMock = mock(() => fakeSocket);
|
|
25
|
+
Object.assign(WebSocketMock, FakeWebSocket);
|
|
26
|
+
globalThis.WebSocket = WebSocketMock as unknown as typeof WebSocket;
|
|
27
|
+
bridge = new VelayWebSocketBridge("http://127.0.0.1:7830", (frame) => {
|
|
28
|
+
sentFrames.push(frame);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
globalThis.WebSocket = OriginalWebSocket;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function makeOpenFrame(
|
|
37
|
+
overrides: Partial<VelayWebSocketOpenFrame> = {},
|
|
38
|
+
): VelayWebSocketOpenFrame {
|
|
39
|
+
return {
|
|
40
|
+
type: VELAY_FRAME_TYPES.websocketOpen,
|
|
41
|
+
connection_id: "conn-123",
|
|
42
|
+
path: "/webhooks/twilio/relay",
|
|
43
|
+
raw_query: "callSessionId=session-123&token=edge-token",
|
|
44
|
+
headers: {},
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function base64(text: string | Uint8Array): string {
|
|
50
|
+
return Buffer.from(text).toString("base64");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function flushPromises(): Promise<void> {
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
await Promise.resolve();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("VelayWebSocketBridge", () => {
|
|
59
|
+
test("opens a loopback WebSocket and sends websocket_opened after local open", () => {
|
|
60
|
+
bridge.handleFrame(
|
|
61
|
+
makeOpenFrame({
|
|
62
|
+
headers: {
|
|
63
|
+
authorization: ["Bearer edge-token"],
|
|
64
|
+
connection: ["upgrade"],
|
|
65
|
+
host: ["public.example.com"],
|
|
66
|
+
"sec-websocket-key": ["client-key"],
|
|
67
|
+
"x-twilio-signature": ["sig-123"],
|
|
68
|
+
},
|
|
69
|
+
subprotocol: "twilio-relay",
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(sentFrames).toEqual([]);
|
|
74
|
+
expect(WebSocketMock).toHaveBeenCalledTimes(1);
|
|
75
|
+
|
|
76
|
+
const [url, options] = WebSocketMock.mock.calls[0] as [
|
|
77
|
+
string,
|
|
78
|
+
{ headers: Record<string, string>; protocols?: string[] },
|
|
79
|
+
];
|
|
80
|
+
expect(url).toBe(
|
|
81
|
+
"ws://127.0.0.1:7830/webhooks/twilio/relay?callSessionId=session-123&token=edge-token",
|
|
82
|
+
);
|
|
83
|
+
expect(options.protocols).toEqual(["twilio-relay"]);
|
|
84
|
+
expect(options.headers.authorization).toBe("Bearer edge-token");
|
|
85
|
+
expect(options.headers.host).toBe("public.example.com");
|
|
86
|
+
expect(options.headers["x-twilio-signature"]).toBe("sig-123");
|
|
87
|
+
expect(options.headers.connection).toBeUndefined();
|
|
88
|
+
expect(options.headers["sec-websocket-key"]).toBeUndefined();
|
|
89
|
+
|
|
90
|
+
fakeSocket.readyState = WS_OPEN;
|
|
91
|
+
fakeSocket.emit("open");
|
|
92
|
+
|
|
93
|
+
expect(sentFrames).toEqual([
|
|
94
|
+
{
|
|
95
|
+
type: VELAY_FRAME_TYPES.websocketOpened,
|
|
96
|
+
connection_id: "conn-123",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("sends websocket_open_error when the local upgrade fails before open", () => {
|
|
102
|
+
bridge.open(makeOpenFrame());
|
|
103
|
+
|
|
104
|
+
fakeSocket.emit("error");
|
|
105
|
+
|
|
106
|
+
expect(sentFrames).toEqual([
|
|
107
|
+
{
|
|
108
|
+
type: VELAY_FRAME_TYPES.websocketOpenError,
|
|
109
|
+
connection_id: "conn-123",
|
|
110
|
+
reason: "WebSocket connection failed",
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
114
|
+
expect(fakeSocket.closes).toEqual([{ code: undefined, reason: undefined }]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("forwards text frames from Velay to the local gateway WebSocket", () => {
|
|
118
|
+
bridge.open(makeOpenFrame());
|
|
119
|
+
fakeSocket.readyState = WS_OPEN;
|
|
120
|
+
fakeSocket.emit("open");
|
|
121
|
+
|
|
122
|
+
bridge.message({
|
|
123
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
124
|
+
connection_id: "conn-123",
|
|
125
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
126
|
+
body_base64: base64('{"event":"start"}'),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(fakeSocket.sent).toEqual(['{"event":"start"}']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("preserves leading UTF-8 BOM bytes in Velay text frames", () => {
|
|
133
|
+
bridge.open(makeOpenFrame());
|
|
134
|
+
fakeSocket.readyState = WS_OPEN;
|
|
135
|
+
fakeSocket.emit("open");
|
|
136
|
+
|
|
137
|
+
bridge.message({
|
|
138
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
139
|
+
connection_id: "conn-123",
|
|
140
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
141
|
+
body_base64: base64(new Uint8Array([0xef, 0xbb, 0xbf, 0x68, 0x69])),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(fakeSocket.sent).toEqual(["\ufeffhi"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("forwards local text frames back to Velay as websocket_message frames", async () => {
|
|
148
|
+
bridge.open(makeOpenFrame());
|
|
149
|
+
fakeSocket.readyState = WS_OPEN;
|
|
150
|
+
fakeSocket.emit("open");
|
|
151
|
+
|
|
152
|
+
fakeSocket.emit("message", { data: "hello from gateway" });
|
|
153
|
+
await flushPromises();
|
|
154
|
+
|
|
155
|
+
expect(sentFrames.at(-1)).toEqual({
|
|
156
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
157
|
+
connection_id: "conn-123",
|
|
158
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
159
|
+
body_base64: base64("hello from gateway"),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("preserves binary frames in both directions", async () => {
|
|
164
|
+
bridge.open(makeOpenFrame());
|
|
165
|
+
fakeSocket.readyState = WS_OPEN;
|
|
166
|
+
fakeSocket.emit("open");
|
|
167
|
+
|
|
168
|
+
bridge.message({
|
|
169
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
170
|
+
connection_id: "conn-123",
|
|
171
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.binary,
|
|
172
|
+
body_base64: base64(new Uint8Array([1, 2, 3])),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(fakeSocket.sent[0]).toEqual(new Uint8Array([1, 2, 3]));
|
|
176
|
+
|
|
177
|
+
fakeSocket.emit("message", { data: new Uint8Array([4, 5, 6]) });
|
|
178
|
+
await flushPromises();
|
|
179
|
+
|
|
180
|
+
expect(sentFrames.at(-1)).toEqual({
|
|
181
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
182
|
+
connection_id: "conn-123",
|
|
183
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.binary,
|
|
184
|
+
body_base64: base64(new Uint8Array([4, 5, 6])),
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("forwards close frames and cleans up the connection map", () => {
|
|
189
|
+
bridge.open(makeOpenFrame());
|
|
190
|
+
fakeSocket.readyState = WS_OPEN;
|
|
191
|
+
fakeSocket.emit("open");
|
|
192
|
+
|
|
193
|
+
bridge.close({
|
|
194
|
+
type: VELAY_FRAME_TYPES.websocketClose,
|
|
195
|
+
connection_id: "conn-123",
|
|
196
|
+
code: 1000,
|
|
197
|
+
reason: "done",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(fakeSocket.closes).toEqual([{ code: 1000, reason: "done" }]);
|
|
201
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
202
|
+
|
|
203
|
+
fakeSocket.emit("close", { code: 1000, reason: "done" });
|
|
204
|
+
expect(
|
|
205
|
+
sentFrames.filter(
|
|
206
|
+
(frame) => frame.type === VELAY_FRAME_TYPES.websocketClose,
|
|
207
|
+
),
|
|
208
|
+
).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("remaps protocol close codes from Velay before closing locally", () => {
|
|
212
|
+
bridge.open(makeOpenFrame());
|
|
213
|
+
fakeSocket.readyState = WS_OPEN;
|
|
214
|
+
fakeSocket.emit("open");
|
|
215
|
+
|
|
216
|
+
expect(() => {
|
|
217
|
+
bridge.close({
|
|
218
|
+
type: VELAY_FRAME_TYPES.websocketClose,
|
|
219
|
+
connection_id: "conn-123",
|
|
220
|
+
code: 1008,
|
|
221
|
+
reason: "Policy violation",
|
|
222
|
+
});
|
|
223
|
+
}).not.toThrow();
|
|
224
|
+
|
|
225
|
+
expect(fakeSocket.closes).toEqual([
|
|
226
|
+
{ code: 4008, reason: "Policy violation" },
|
|
227
|
+
]);
|
|
228
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("sanitizes invalid reserved close codes from Velay before closing locally", () => {
|
|
232
|
+
bridge.open(makeOpenFrame());
|
|
233
|
+
fakeSocket.readyState = WS_OPEN;
|
|
234
|
+
fakeSocket.emit("open");
|
|
235
|
+
|
|
236
|
+
expect(() => {
|
|
237
|
+
bridge.close({
|
|
238
|
+
type: VELAY_FRAME_TYPES.websocketClose,
|
|
239
|
+
connection_id: "conn-123",
|
|
240
|
+
code: 1006,
|
|
241
|
+
reason: "invalid reserved code",
|
|
242
|
+
});
|
|
243
|
+
}).not.toThrow();
|
|
244
|
+
|
|
245
|
+
expect(fakeSocket.closes).toEqual([{ code: undefined, reason: undefined }]);
|
|
246
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("truncates overlong close reasons from Velay without splitting UTF-8 characters", () => {
|
|
250
|
+
bridge.open(makeOpenFrame());
|
|
251
|
+
fakeSocket.readyState = WS_OPEN;
|
|
252
|
+
fakeSocket.emit("open");
|
|
253
|
+
|
|
254
|
+
bridge.close({
|
|
255
|
+
type: VELAY_FRAME_TYPES.websocketClose,
|
|
256
|
+
connection_id: "conn-123",
|
|
257
|
+
code: 1000,
|
|
258
|
+
reason: "🙂".repeat(40),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(fakeSocket.closes).toEqual([
|
|
262
|
+
{ code: 1000, reason: "🙂".repeat(30) },
|
|
263
|
+
]);
|
|
264
|
+
expect(
|
|
265
|
+
new TextEncoder().encode(fakeSocket.closes[0].reason).byteLength,
|
|
266
|
+
).toBe(120);
|
|
267
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("keeps empty text frames distinct from invalid payloads", () => {
|
|
271
|
+
bridge.open(makeOpenFrame());
|
|
272
|
+
fakeSocket.readyState = WS_OPEN;
|
|
273
|
+
fakeSocket.emit("open");
|
|
274
|
+
|
|
275
|
+
bridge.message({
|
|
276
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
277
|
+
connection_id: "conn-123",
|
|
278
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
279
|
+
body_base64: "",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(fakeSocket.sent).toEqual([""]);
|
|
283
|
+
expect(fakeSocket.closes).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("rejects invalid base64 payloads and cleans up", () => {
|
|
287
|
+
bridge.open(makeOpenFrame());
|
|
288
|
+
fakeSocket.readyState = WS_OPEN;
|
|
289
|
+
fakeSocket.emit("open");
|
|
290
|
+
|
|
291
|
+
bridge.message({
|
|
292
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
293
|
+
connection_id: "conn-123",
|
|
294
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
295
|
+
body_base64: "not base64",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(fakeSocket.closes).toEqual([
|
|
299
|
+
{ code: 4003, reason: "Invalid message" },
|
|
300
|
+
]);
|
|
301
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("buffers Velay messages until local open", () => {
|
|
305
|
+
bridge.open(makeOpenFrame());
|
|
306
|
+
|
|
307
|
+
bridge.message({
|
|
308
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
309
|
+
connection_id: "conn-123",
|
|
310
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
311
|
+
body_base64: base64("early"),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(fakeSocket.sent).toEqual([]);
|
|
315
|
+
|
|
316
|
+
fakeSocket.readyState = WS_OPEN;
|
|
317
|
+
fakeSocket.emit("open");
|
|
318
|
+
|
|
319
|
+
expect(fakeSocket.sent).toEqual(["early"]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("sends websocket_open_error without connecting for unsafe paths", () => {
|
|
323
|
+
bridge.open(
|
|
324
|
+
makeOpenFrame({
|
|
325
|
+
path: "https://example.com/webhooks/twilio/relay",
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(WebSocketMock).not.toHaveBeenCalled();
|
|
330
|
+
expect(sentFrames).toEqual([
|
|
331
|
+
{
|
|
332
|
+
type: VELAY_FRAME_TYPES.websocketOpenError,
|
|
333
|
+
connection_id: "conn-123",
|
|
334
|
+
reason: "Invalid WebSocket path",
|
|
335
|
+
},
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("forwards local close frames back to Velay and cleans up", () => {
|
|
340
|
+
bridge.open(makeOpenFrame());
|
|
341
|
+
fakeSocket.readyState = WS_OPEN;
|
|
342
|
+
fakeSocket.emit("open");
|
|
343
|
+
|
|
344
|
+
fakeSocket.emit("close", { code: 1001, reason: "going away" });
|
|
345
|
+
|
|
346
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
347
|
+
expect(sentFrames.at(-1)).toEqual({
|
|
348
|
+
type: VELAY_FRAME_TYPES.websocketClose,
|
|
349
|
+
connection_id: "conn-123",
|
|
350
|
+
code: 1001,
|
|
351
|
+
reason: "going away",
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("remaps closeAll going-away semantics to an application close code", () => {
|
|
356
|
+
bridge.open(makeOpenFrame());
|
|
357
|
+
fakeSocket.readyState = WS_OPEN;
|
|
358
|
+
fakeSocket.emit("open");
|
|
359
|
+
|
|
360
|
+
bridge.closeAll();
|
|
361
|
+
|
|
362
|
+
expect(fakeSocket.closes).toEqual([
|
|
363
|
+
{ code: 4001, reason: "Tunnel closed" },
|
|
364
|
+
]);
|
|
365
|
+
expect(bridge.getConnectionCount()).toBe(0);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import type { OutgoingHttpHeaders } from "node:http";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
binaryLikeToBytes,
|
|
5
|
+
buildLoopbackWebSocketUrl,
|
|
6
|
+
closeWebSocket,
|
|
7
|
+
decodeBase64Bytes,
|
|
8
|
+
encodeBase64,
|
|
9
|
+
websocketHeadersFromVelay,
|
|
10
|
+
} from "./bridge-utils.js";
|
|
11
|
+
import {
|
|
12
|
+
VELAY_FRAME_TYPES,
|
|
13
|
+
VELAY_WEBSOCKET_MESSAGE_TYPES,
|
|
14
|
+
type VelayFrame,
|
|
15
|
+
type VelayWebSocketCloseFrame,
|
|
16
|
+
type VelayWebSocketInboundFrame,
|
|
17
|
+
type VelayWebSocketMessageFrame,
|
|
18
|
+
type VelayWebSocketOpenErrorFrame,
|
|
19
|
+
type VelayWebSocketOpenedFrame,
|
|
20
|
+
type VelayWebSocketOpenFrame,
|
|
21
|
+
} from "./protocol.js";
|
|
22
|
+
|
|
23
|
+
const MAX_PENDING_MESSAGES = 100;
|
|
24
|
+
const textFrameDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
25
|
+
|
|
26
|
+
type PendingMessage = string | Uint8Array;
|
|
27
|
+
|
|
28
|
+
type WebSocketConstructorWithHeaders = {
|
|
29
|
+
new (
|
|
30
|
+
url: string,
|
|
31
|
+
options?: {
|
|
32
|
+
headers?: OutgoingHttpHeaders;
|
|
33
|
+
protocols?: string | string[];
|
|
34
|
+
},
|
|
35
|
+
): WebSocket;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type BridgeConnection = {
|
|
39
|
+
ws: WebSocket;
|
|
40
|
+
opened: boolean;
|
|
41
|
+
openErrorSent: boolean;
|
|
42
|
+
pendingMessages: PendingMessage[];
|
|
43
|
+
suppressNextCloseFrame: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type SendVelayFrame = (frame: VelayFrame) => void;
|
|
47
|
+
|
|
48
|
+
export class VelayWebSocketBridge {
|
|
49
|
+
private readonly connections = new Map<string, BridgeConnection>();
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly gatewayLoopbackBaseUrl: string,
|
|
53
|
+
private readonly sendFrame: SendVelayFrame,
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
handleFrame(frame: VelayWebSocketInboundFrame): void {
|
|
57
|
+
switch (frame.type) {
|
|
58
|
+
case VELAY_FRAME_TYPES.websocketOpen:
|
|
59
|
+
this.open(frame);
|
|
60
|
+
return;
|
|
61
|
+
case VELAY_FRAME_TYPES.websocketMessage:
|
|
62
|
+
this.message(frame);
|
|
63
|
+
return;
|
|
64
|
+
case VELAY_FRAME_TYPES.websocketClose:
|
|
65
|
+
this.close(frame);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
open(frame: VelayWebSocketOpenFrame): void {
|
|
71
|
+
this.closeExisting(frame.connection_id);
|
|
72
|
+
|
|
73
|
+
const url = buildLoopbackWebSocketUrl(
|
|
74
|
+
this.gatewayLoopbackBaseUrl,
|
|
75
|
+
frame.path,
|
|
76
|
+
frame.raw_query,
|
|
77
|
+
);
|
|
78
|
+
if (!url) {
|
|
79
|
+
this.sendOpenError(frame.connection_id, "Invalid WebSocket path");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let ws: WebSocket;
|
|
84
|
+
try {
|
|
85
|
+
const WebSocketWithHeaders =
|
|
86
|
+
WebSocket as unknown as WebSocketConstructorWithHeaders;
|
|
87
|
+
ws = new WebSocketWithHeaders(url, {
|
|
88
|
+
headers: websocketHeadersFromVelay(frame.headers),
|
|
89
|
+
...(frame.subprotocol ? { protocols: [frame.subprotocol] } : {}),
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
this.sendOpenError(frame.connection_id, "WebSocket connection failed");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const connection: BridgeConnection = {
|
|
97
|
+
ws,
|
|
98
|
+
opened: false,
|
|
99
|
+
openErrorSent: false,
|
|
100
|
+
pendingMessages: [],
|
|
101
|
+
suppressNextCloseFrame: false,
|
|
102
|
+
};
|
|
103
|
+
this.connections.set(frame.connection_id, connection);
|
|
104
|
+
|
|
105
|
+
ws.binaryType = "arraybuffer";
|
|
106
|
+
ws.addEventListener("open", () => {
|
|
107
|
+
if (this.connections.get(frame.connection_id) !== connection) return;
|
|
108
|
+
|
|
109
|
+
connection.opened = true;
|
|
110
|
+
this.sendFrame({
|
|
111
|
+
type: VELAY_FRAME_TYPES.websocketOpened,
|
|
112
|
+
connection_id: frame.connection_id,
|
|
113
|
+
} satisfies VelayWebSocketOpenedFrame);
|
|
114
|
+
|
|
115
|
+
for (const message of connection.pendingMessages) {
|
|
116
|
+
ws.send(message);
|
|
117
|
+
}
|
|
118
|
+
connection.pendingMessages = [];
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
ws.addEventListener("message", (event) => {
|
|
122
|
+
void this.forwardLocalMessage(
|
|
123
|
+
frame.connection_id,
|
|
124
|
+
connection,
|
|
125
|
+
event.data,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
ws.addEventListener("close", (event) => {
|
|
130
|
+
this.handleLocalClose(frame.connection_id, connection, event);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
ws.addEventListener("error", () => {
|
|
134
|
+
if (connection.opened) return;
|
|
135
|
+
this.failOpeningConnection(
|
|
136
|
+
frame.connection_id,
|
|
137
|
+
connection,
|
|
138
|
+
"WebSocket connection failed",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
message(frame: VelayWebSocketMessageFrame): void {
|
|
144
|
+
const connection = this.connections.get(frame.connection_id);
|
|
145
|
+
if (!connection) return;
|
|
146
|
+
|
|
147
|
+
const message = decodeVelayMessage(frame);
|
|
148
|
+
if (message === undefined) {
|
|
149
|
+
this.closeConnection(
|
|
150
|
+
frame.connection_id,
|
|
151
|
+
connection,
|
|
152
|
+
1003,
|
|
153
|
+
"Invalid message",
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (connection.opened && connection.ws.readyState === WebSocket.OPEN) {
|
|
159
|
+
connection.ws.send(message);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (connection.pendingMessages.length >= MAX_PENDING_MESSAGES) {
|
|
164
|
+
this.closeConnection(
|
|
165
|
+
frame.connection_id,
|
|
166
|
+
connection,
|
|
167
|
+
1008,
|
|
168
|
+
"Buffer overflow",
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
connection.pendingMessages.push(message);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
close(frame: VelayWebSocketCloseFrame): void {
|
|
176
|
+
const connection = this.connections.get(frame.connection_id);
|
|
177
|
+
if (!connection) return;
|
|
178
|
+
|
|
179
|
+
connection.suppressNextCloseFrame = true;
|
|
180
|
+
this.closeConnection(
|
|
181
|
+
frame.connection_id,
|
|
182
|
+
connection,
|
|
183
|
+
frame.code,
|
|
184
|
+
frame.reason,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
closeAll(code = 1001, reason = "Tunnel closed"): void {
|
|
189
|
+
for (const [connectionId, connection] of this.connections) {
|
|
190
|
+
connection.suppressNextCloseFrame = true;
|
|
191
|
+
this.closeConnection(connectionId, connection, code, reason);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getConnectionCount(): number {
|
|
196
|
+
return this.connections.size;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async forwardLocalMessage(
|
|
200
|
+
connectionId: string,
|
|
201
|
+
connection: BridgeConnection,
|
|
202
|
+
data: unknown,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
if (this.connections.get(connectionId) !== connection) return;
|
|
205
|
+
|
|
206
|
+
const message = await encodeLocalMessage(connectionId, data);
|
|
207
|
+
if (this.connections.get(connectionId) !== connection) return;
|
|
208
|
+
this.sendFrame(message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private handleLocalClose(
|
|
212
|
+
connectionId: string,
|
|
213
|
+
connection: BridgeConnection,
|
|
214
|
+
event: CloseEvent,
|
|
215
|
+
): void {
|
|
216
|
+
if (this.connections.get(connectionId) !== connection) return;
|
|
217
|
+
this.connections.delete(connectionId);
|
|
218
|
+
connection.pendingMessages = [];
|
|
219
|
+
|
|
220
|
+
if (!connection.opened) {
|
|
221
|
+
this.sendOpenErrorOnce(
|
|
222
|
+
connectionId,
|
|
223
|
+
connection,
|
|
224
|
+
"WebSocket connection failed",
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (connection.suppressNextCloseFrame) return;
|
|
230
|
+
this.sendFrame({
|
|
231
|
+
type: VELAY_FRAME_TYPES.websocketClose,
|
|
232
|
+
connection_id: connectionId,
|
|
233
|
+
code: event.code,
|
|
234
|
+
reason: event.reason,
|
|
235
|
+
} satisfies VelayWebSocketCloseFrame);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private failOpeningConnection(
|
|
239
|
+
connectionId: string,
|
|
240
|
+
connection: BridgeConnection,
|
|
241
|
+
reason: string,
|
|
242
|
+
): void {
|
|
243
|
+
if (this.connections.get(connectionId) === connection) {
|
|
244
|
+
this.connections.delete(connectionId);
|
|
245
|
+
}
|
|
246
|
+
connection.pendingMessages = [];
|
|
247
|
+
this.sendOpenErrorOnce(connectionId, connection, reason);
|
|
248
|
+
closeWebSocket(connection.ws);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private closeExisting(connectionId: string): void {
|
|
252
|
+
const existing = this.connections.get(connectionId);
|
|
253
|
+
if (!existing) return;
|
|
254
|
+
existing.suppressNextCloseFrame = true;
|
|
255
|
+
this.closeConnection(connectionId, existing, 1000, "Replaced");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private closeConnection(
|
|
259
|
+
connectionId: string,
|
|
260
|
+
connection: BridgeConnection,
|
|
261
|
+
code?: number,
|
|
262
|
+
reason?: string,
|
|
263
|
+
): void {
|
|
264
|
+
if (this.connections.get(connectionId) === connection) {
|
|
265
|
+
this.connections.delete(connectionId);
|
|
266
|
+
}
|
|
267
|
+
connection.pendingMessages = [];
|
|
268
|
+
closeWebSocket(connection.ws, code, reason);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private sendOpenError(connectionId: string, reason: string): void {
|
|
272
|
+
this.sendFrame({
|
|
273
|
+
type: VELAY_FRAME_TYPES.websocketOpenError,
|
|
274
|
+
connection_id: connectionId,
|
|
275
|
+
reason,
|
|
276
|
+
} satisfies VelayWebSocketOpenErrorFrame);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private sendOpenErrorOnce(
|
|
280
|
+
connectionId: string,
|
|
281
|
+
connection: BridgeConnection,
|
|
282
|
+
reason: string,
|
|
283
|
+
): void {
|
|
284
|
+
if (connection.openErrorSent) return;
|
|
285
|
+
connection.openErrorSent = true;
|
|
286
|
+
this.sendOpenError(connectionId, reason);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function decodeVelayMessage(
|
|
291
|
+
frame: VelayWebSocketMessageFrame,
|
|
292
|
+
): PendingMessage | undefined {
|
|
293
|
+
const bytes = decodeBase64Bytes(frame.body_base64 ?? "");
|
|
294
|
+
if (bytes === undefined) return undefined;
|
|
295
|
+
|
|
296
|
+
if (frame.message_type === VELAY_WEBSOCKET_MESSAGE_TYPES.text) {
|
|
297
|
+
return textFrameDecoder.decode(bytes);
|
|
298
|
+
}
|
|
299
|
+
if (frame.message_type === VELAY_WEBSOCKET_MESSAGE_TYPES.binary) {
|
|
300
|
+
return bytes;
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function encodeLocalMessage(
|
|
306
|
+
connectionId: string,
|
|
307
|
+
data: unknown,
|
|
308
|
+
): Promise<VelayWebSocketMessageFrame> {
|
|
309
|
+
if (typeof data === "string") {
|
|
310
|
+
return {
|
|
311
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
312
|
+
connection_id: connectionId,
|
|
313
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.text,
|
|
314
|
+
body_base64: encodeBase64(data),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
type: VELAY_FRAME_TYPES.websocketMessage,
|
|
320
|
+
connection_id: connectionId,
|
|
321
|
+
message_type: VELAY_WEBSOCKET_MESSAGE_TYPES.binary,
|
|
322
|
+
body_base64: encodeBase64(await binaryLikeToBytes(data)),
|
|
323
|
+
};
|
|
324
|
+
}
|