@vellumai/vellum-gateway 0.1.7

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 (42) hide show
  1. package/.dockerignore +7 -0
  2. package/.env.example +59 -0
  3. package/Dockerfile +44 -0
  4. package/README.md +186 -0
  5. package/bun.lock +391 -0
  6. package/eslint.config.mjs +23 -0
  7. package/knip.json +8 -0
  8. package/package.json +27 -0
  9. package/src/__tests__/bearer-auth.test.ts +40 -0
  10. package/src/__tests__/config.test.ts +236 -0
  11. package/src/__tests__/dedup-cache.test.ts +101 -0
  12. package/src/__tests__/load-guards.test.ts +86 -0
  13. package/src/__tests__/probes.test.ts +94 -0
  14. package/src/__tests__/reply-path.test.ts +51 -0
  15. package/src/__tests__/resolve-assistant.test.ts +118 -0
  16. package/src/__tests__/runtime-client.test.ts +228 -0
  17. package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
  18. package/src/__tests__/runtime-proxy.test.ts +262 -0
  19. package/src/__tests__/schema.test.ts +128 -0
  20. package/src/__tests__/telegram-normalize.test.ts +303 -0
  21. package/src/__tests__/telegram-only-default.test.ts +134 -0
  22. package/src/__tests__/telegram-send-attachments.test.ts +185 -0
  23. package/src/cli/schema.ts +8 -0
  24. package/src/config.ts +254 -0
  25. package/src/dedup-cache.ts +104 -0
  26. package/src/handlers/handle-inbound.ts +104 -0
  27. package/src/http/auth/bearer.ts +34 -0
  28. package/src/http/routes/runtime-proxy.ts +143 -0
  29. package/src/http/routes/telegram-webhook.ts +272 -0
  30. package/src/index.ts +117 -0
  31. package/src/logger.ts +103 -0
  32. package/src/routing/resolve-assistant.ts +45 -0
  33. package/src/routing/types.ts +11 -0
  34. package/src/runtime/client.ts +212 -0
  35. package/src/schema.ts +383 -0
  36. package/src/telegram/api.ts +153 -0
  37. package/src/telegram/download.ts +63 -0
  38. package/src/telegram/normalize.ts +118 -0
  39. package/src/telegram/send.ts +107 -0
  40. package/src/telegram/verify.ts +17 -0
  41. package/src/types.ts +37 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,128 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import { buildSchema } from "../schema.js";
3
+
4
+ const PORT = 19836;
5
+
6
+ const server = Bun.serve({
7
+ port: PORT,
8
+ fetch(req) {
9
+ const url = new URL(req.url);
10
+
11
+ if (url.pathname === "/schema") {
12
+ return Response.json(buildSchema());
13
+ }
14
+
15
+ return Response.json({ error: "Not found" }, { status: 404 });
16
+ },
17
+ });
18
+
19
+ afterAll(() => {
20
+ server.stop(true);
21
+ });
22
+
23
+ describe("/schema route", () => {
24
+ test("returns valid OpenAPI 3.1 schema via HTTP", async () => {
25
+ /**
26
+ * Tests that the /schema endpoint returns a valid OpenAPI document with
27
+ * the correct version and expected top-level structure.
28
+ */
29
+
30
+ // GIVEN a running gateway server
31
+
32
+ // WHEN we request the schema endpoint
33
+ const res = await fetch(`http://localhost:${PORT}/schema`);
34
+
35
+ // THEN we receive a 200 with valid JSON
36
+ expect(res.status).toBe(200);
37
+ const body = await res.json();
38
+
39
+ // AND the response is an OpenAPI 3.1 document
40
+ expect(body.openapi).toBe("3.1.0");
41
+ expect(body.info.title).toBe("Vellum Gateway");
42
+ expect(typeof body.info.version).toBe("string");
43
+
44
+ // AND it contains the expected top-level sections
45
+ expect(body.paths).toBeDefined();
46
+ expect(body.components).toBeDefined();
47
+ expect(body.components.schemas).toBeDefined();
48
+ expect(body.components.securitySchemes).toBeDefined();
49
+ });
50
+
51
+ test("schema includes all gateway routes", async () => {
52
+ /**
53
+ * Tests that the schema documents every route the gateway exposes.
54
+ */
55
+
56
+ // GIVEN a running gateway server
57
+
58
+ // WHEN we request the schema endpoint
59
+ const res = await fetch(`http://localhost:${PORT}/schema`);
60
+ const body = await res.json();
61
+
62
+ // THEN the paths include every gateway endpoint
63
+ expect(body.paths["/healthz"]).toBeDefined();
64
+ expect(body.paths["/readyz"]).toBeDefined();
65
+ expect(body.paths["/schema"]).toBeDefined();
66
+ expect(body.paths["/webhooks/telegram"]).toBeDefined();
67
+ expect(body.paths["/{path}"]).toBeDefined();
68
+ });
69
+
70
+ test("schema version matches package.json version", async () => {
71
+ /**
72
+ * Tests that the schema info.version stays in sync with package.json.
73
+ */
74
+
75
+ // GIVEN the version from package.json
76
+ const pkg = (await import("../../package.json")).default;
77
+
78
+ // WHEN we request the schema endpoint
79
+ const res = await fetch(`http://localhost:${PORT}/schema`);
80
+ const body = await res.json();
81
+
82
+ // THEN the schema version matches the package version
83
+ expect(body.info.version).toBe(pkg.version);
84
+ });
85
+ });
86
+
87
+ describe("buildSchema()", () => {
88
+ test("returns a plain object with all component schemas", () => {
89
+ /**
90
+ * Tests that buildSchema() includes all expected component schema
91
+ * definitions for request/response types.
92
+ */
93
+
94
+ // GIVEN no special setup needed
95
+
96
+ // WHEN we call buildSchema directly
97
+ const schema = buildSchema();
98
+
99
+ // THEN it contains all expected component schemas
100
+ const components = schema.components as Record<string, Record<string, unknown>>;
101
+ const schemaNames = Object.keys(components.schemas);
102
+ expect(schemaNames).toContain("HealthResponse");
103
+ expect(schemaNames).toContain("ReadyResponse");
104
+ expect(schemaNames).toContain("DrainingResponse");
105
+ expect(schemaNames).toContain("ErrorResponse");
106
+ expect(schemaNames).toContain("TelegramOk");
107
+ expect(schemaNames).toContain("TelegramUpdate");
108
+ expect(schemaNames).toContain("TelegramMessage");
109
+ expect(schemaNames).toContain("TelegramPhotoSize");
110
+ expect(schemaNames).toContain("TelegramDocument");
111
+ });
112
+
113
+ test("returns a JSON-serializable object", () => {
114
+ /**
115
+ * Tests that the schema can be round-tripped through JSON without loss.
116
+ */
117
+
118
+ // GIVEN no special setup needed
119
+
120
+ // WHEN we serialize and deserialize the schema
121
+ const schema = buildSchema();
122
+ const json = JSON.stringify(schema);
123
+ const parsed = JSON.parse(json);
124
+
125
+ // THEN the round-tripped object equals the original
126
+ expect(parsed).toEqual(schema);
127
+ });
128
+ });
@@ -0,0 +1,303 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { normalizeTelegramUpdate } from "../telegram/normalize.js";
3
+ import { verifyWebhookSecret } from "../telegram/verify.js";
4
+
5
+ describe("normalizeTelegramUpdate", () => {
6
+ const validPayload = {
7
+ update_id: 123456,
8
+ message: {
9
+ message_id: 42,
10
+ text: "Hello bot",
11
+ chat: { id: 99001, type: "private" },
12
+ from: {
13
+ id: 55001,
14
+ is_bot: false,
15
+ username: "testuser",
16
+ first_name: "Test",
17
+ last_name: "User",
18
+ language_code: "en",
19
+ },
20
+ },
21
+ };
22
+
23
+ test("normalizes a valid private text message", () => {
24
+ const result = normalizeTelegramUpdate(validPayload);
25
+ expect(result).not.toBeNull();
26
+ expect(result!.version).toBe("v1");
27
+ expect(result!.sourceChannel).toBe("telegram");
28
+ expect(result!.message.content).toBe("Hello bot");
29
+ expect(result!.message.externalChatId).toBe("99001");
30
+ expect(result!.message.externalMessageId).toBe("123456");
31
+ expect(result!.sender.externalUserId).toBe("55001");
32
+ expect(result!.sender.username).toBe("testuser");
33
+ expect(result!.sender.displayName).toBe("Test User");
34
+ expect(result!.sender.firstName).toBe("Test");
35
+ expect(result!.sender.lastName).toBe("User");
36
+ expect(result!.sender.languageCode).toBe("en");
37
+ expect(result!.sender.isBot).toBe(false);
38
+ expect(result!.source.updateId).toBe("123456");
39
+ expect(result!.source.messageId).toBe("42");
40
+ expect(result!.source.chatType).toBe("private");
41
+ expect(result!.raw).toEqual(validPayload);
42
+ });
43
+
44
+ test("returns null for unsupported message types (e.g. sticker-only)", () => {
45
+ const payload = {
46
+ update_id: 1,
47
+ message: {
48
+ message_id: 1,
49
+ chat: { id: 1, type: "private" },
50
+ sticker: { file_id: "abc" },
51
+ },
52
+ };
53
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
54
+ });
55
+
56
+ test("normalizes a photo message", () => {
57
+ const payload = {
58
+ update_id: 100,
59
+ message: {
60
+ message_id: 10,
61
+ chat: { id: 200, type: "private" },
62
+ from: { id: 300, is_bot: false, username: "photouser", first_name: "Photo" },
63
+ photo: [
64
+ { file_id: "small_id", file_unique_id: "s1", width: 90, height: 90 },
65
+ { file_id: "medium_id", file_unique_id: "s2", width: 320, height: 320 },
66
+ { file_id: "large_id", file_unique_id: "s3", width: 800, height: 800 },
67
+ ],
68
+ caption: "Check this out",
69
+ },
70
+ };
71
+ const result = normalizeTelegramUpdate(payload);
72
+ expect(result).not.toBeNull();
73
+ expect(result!.message.content).toBe("Check this out");
74
+ expect(result!.message.attachments).toHaveLength(1);
75
+ expect(result!.message.attachments![0]).toEqual({
76
+ type: "photo",
77
+ fileId: "large_id",
78
+ fileSize: undefined,
79
+ });
80
+ });
81
+
82
+ test("normalizes a photo message without caption", () => {
83
+ const payload = {
84
+ update_id: 101,
85
+ message: {
86
+ message_id: 11,
87
+ chat: { id: 200, type: "private" },
88
+ from: { id: 300, is_bot: false },
89
+ photo: [
90
+ { file_id: "only_id", file_unique_id: "s1", width: 800, height: 800 },
91
+ ],
92
+ },
93
+ };
94
+ const result = normalizeTelegramUpdate(payload);
95
+ expect(result).not.toBeNull();
96
+ expect(result!.message.content).toBe("");
97
+ expect(result!.message.attachments).toHaveLength(1);
98
+ expect(result!.message.attachments![0].fileId).toBe("only_id");
99
+ });
100
+
101
+ test("normalizes a document message", () => {
102
+ const payload = {
103
+ update_id: 102,
104
+ message: {
105
+ message_id: 12,
106
+ chat: { id: 200, type: "private" },
107
+ from: { id: 300, is_bot: false, username: "docuser" },
108
+ document: {
109
+ file_id: "doc_file_id",
110
+ file_unique_id: "du1",
111
+ file_name: "report.pdf",
112
+ mime_type: "application/pdf",
113
+ file_size: 12345,
114
+ },
115
+ caption: "Here is the report",
116
+ },
117
+ };
118
+ const result = normalizeTelegramUpdate(payload);
119
+ expect(result).not.toBeNull();
120
+ expect(result!.message.content).toBe("Here is the report");
121
+ expect(result!.message.attachments).toHaveLength(1);
122
+ expect(result!.message.attachments![0]).toEqual({
123
+ type: "document",
124
+ fileId: "doc_file_id",
125
+ fileName: "report.pdf",
126
+ mimeType: "application/pdf",
127
+ fileSize: 12345,
128
+ });
129
+ });
130
+
131
+ test("normalizes a document message without caption", () => {
132
+ const payload = {
133
+ update_id: 103,
134
+ message: {
135
+ message_id: 13,
136
+ chat: { id: 200, type: "private" },
137
+ from: { id: 300, is_bot: false },
138
+ document: {
139
+ file_id: "doc_id_2",
140
+ file_unique_id: "du2",
141
+ file_name: "data.csv",
142
+ mime_type: "text/csv",
143
+ },
144
+ },
145
+ };
146
+ const result = normalizeTelegramUpdate(payload);
147
+ expect(result).not.toBeNull();
148
+ expect(result!.message.content).toBe("");
149
+ expect(result!.message.attachments).toHaveLength(1);
150
+ });
151
+
152
+ test("text-only messages have no attachments field", () => {
153
+ const result = normalizeTelegramUpdate(validPayload);
154
+ expect(result).not.toBeNull();
155
+ expect(result!.message.attachments).toBeUndefined();
156
+ });
157
+
158
+ test("returns null for group messages", () => {
159
+ const payload = {
160
+ ...validPayload,
161
+ message: { ...validPayload.message, chat: { id: 99001, type: "group" } },
162
+ };
163
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
164
+ });
165
+
166
+ test("returns null for payloads without update_id", () => {
167
+ const { update_id: _, ...rest } = validPayload;
168
+ expect(normalizeTelegramUpdate(rest)).toBeNull();
169
+ });
170
+
171
+ test("returns null for payloads without chat id", () => {
172
+ const payload = {
173
+ update_id: 1,
174
+ message: { message_id: 1, text: "hello", chat: {} },
175
+ };
176
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
177
+ });
178
+
179
+ test("uses chat.id as fallback for sender when from.id is missing", () => {
180
+ const payload = {
181
+ update_id: 1,
182
+ message: {
183
+ message_id: 1,
184
+ text: "hello",
185
+ chat: { id: 12345, type: "private" },
186
+ },
187
+ };
188
+ const result = normalizeTelegramUpdate(payload);
189
+ expect(result).not.toBeNull();
190
+ expect(result!.sender.externalUserId).toBe("12345");
191
+ });
192
+
193
+ test("returns null for non-message updates (e.g. callback_query)", () => {
194
+ const payload = {
195
+ update_id: 1,
196
+ callback_query: { id: "abc", data: "some_data" },
197
+ };
198
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
199
+ });
200
+
201
+ test("normalizes an edited_message update with isEdit flag", () => {
202
+ const payload = {
203
+ update_id: 200,
204
+ edited_message: {
205
+ message_id: 42,
206
+ text: "Hello bot (edited)",
207
+ chat: { id: 99001, type: "private" },
208
+ from: {
209
+ id: 55001,
210
+ is_bot: false,
211
+ username: "testuser",
212
+ first_name: "Test",
213
+ },
214
+ },
215
+ };
216
+ const result = normalizeTelegramUpdate(payload);
217
+ expect(result).not.toBeNull();
218
+ expect(result!.message.isEdit).toBe(true);
219
+ expect(result!.message.content).toBe("Hello bot (edited)");
220
+ expect(result!.message.externalChatId).toBe("99001");
221
+ expect(result!.message.externalMessageId).toBe("200");
222
+ expect(result!.source.updateId).toBe("200");
223
+ expect(result!.source.messageId).toBe("42");
224
+ expect(result!.sender.externalUserId).toBe("55001");
225
+ });
226
+
227
+ test("prefers message over edited_message when both are present", () => {
228
+ const payload = {
229
+ update_id: 300,
230
+ message: {
231
+ message_id: 50,
232
+ text: "Original",
233
+ chat: { id: 99001, type: "private" },
234
+ from: { id: 55001, is_bot: false },
235
+ },
236
+ edited_message: {
237
+ message_id: 50,
238
+ text: "Edited",
239
+ chat: { id: 99001, type: "private" },
240
+ from: { id: 55001, is_bot: false },
241
+ },
242
+ };
243
+ const result = normalizeTelegramUpdate(payload);
244
+ expect(result).not.toBeNull();
245
+ expect(result!.message.isEdit).toBeUndefined();
246
+ expect(result!.message.content).toBe("Original");
247
+ });
248
+
249
+ test("sets isEdit for edited_message with photo", () => {
250
+ const payload = {
251
+ update_id: 400,
252
+ edited_message: {
253
+ message_id: 60,
254
+ chat: { id: 200, type: "private" },
255
+ from: { id: 300, is_bot: false },
256
+ photo: [
257
+ { file_id: "edited_photo", file_unique_id: "ep1", width: 800, height: 800 },
258
+ ],
259
+ caption: "Updated caption",
260
+ },
261
+ };
262
+ const result = normalizeTelegramUpdate(payload);
263
+ expect(result).not.toBeNull();
264
+ expect(result!.message.isEdit).toBe(true);
265
+ expect(result!.message.content).toBe("Updated caption");
266
+ expect(result!.message.attachments).toHaveLength(1);
267
+ });
268
+
269
+ test("returns null for edited_message in group chat", () => {
270
+ const payload = {
271
+ update_id: 500,
272
+ edited_message: {
273
+ message_id: 70,
274
+ text: "Edited group msg",
275
+ chat: { id: 99001, type: "group" },
276
+ from: { id: 55001, is_bot: false },
277
+ },
278
+ };
279
+ expect(normalizeTelegramUpdate(payload)).toBeNull();
280
+ });
281
+ });
282
+
283
+ describe("verifyWebhookSecret", () => {
284
+ test("returns true for matching secret", () => {
285
+ const headers = new Headers({ "x-telegram-bot-api-secret-token": "my-secret" });
286
+ expect(verifyWebhookSecret(headers, "my-secret")).toBe(true);
287
+ });
288
+
289
+ test("returns false for mismatched secret", () => {
290
+ const headers = new Headers({ "x-telegram-bot-api-secret-token": "wrong" });
291
+ expect(verifyWebhookSecret(headers, "my-secret")).toBe(false);
292
+ });
293
+
294
+ test("returns false when header is missing", () => {
295
+ const headers = new Headers();
296
+ expect(verifyWebhookSecret(headers, "my-secret")).toBe(false);
297
+ });
298
+
299
+ test("returns false when expected secret is empty", () => {
300
+ const headers = new Headers({ "x-telegram-bot-api-secret-token": "something" });
301
+ expect(verifyWebhookSecret(headers, "")).toBe(false);
302
+ });
303
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+
3
+ /**
4
+ * Proves that non-Telegram requests return 404 when the gateway runs in its
5
+ * default configuration (proxy disabled). Uses the same routing logic as
6
+ * src/index.ts so that changes to the production routing (e.g. accidentally
7
+ * enabling the proxy by default) will be caught by this test.
8
+ */
9
+
10
+ const PORT = 19830; // ephemeral port for test
11
+
12
+ // Minimal env for loadConfig
13
+ const env: Record<string, string> = {
14
+ TELEGRAM_BOT_TOKEN: "test-tok",
15
+ TELEGRAM_WEBHOOK_SECRET: "wh-sec",
16
+ ASSISTANT_RUNTIME_BASE_URL: "http://localhost:7821",
17
+ GATEWAY_PORT: String(PORT),
18
+ // GATEWAY_RUNTIME_PROXY_ENABLED intentionally unset → defaults to false
19
+ };
20
+
21
+ // Save and set env
22
+ const saved: Record<string, string | undefined> = {};
23
+ for (const [k, v] of Object.entries(env)) {
24
+ saved[k] = process.env[k];
25
+ process.env[k] = v;
26
+ }
27
+ // Ensure proxy flag is unset
28
+ saved["GATEWAY_RUNTIME_PROXY_ENABLED"] =
29
+ process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
30
+ delete process.env.GATEWAY_RUNTIME_PROXY_ENABLED;
31
+
32
+ // Dynamically import to pick up env
33
+ const { loadConfig } = await import("../config.js");
34
+ const { createTelegramWebhookHandler } = await import(
35
+ "../http/routes/telegram-webhook.js"
36
+ );
37
+ const { createRuntimeProxyHandler } = await import(
38
+ "../http/routes/runtime-proxy.js"
39
+ );
40
+
41
+ const config = loadConfig();
42
+
43
+ const handleTelegramWebhook = createTelegramWebhookHandler(
44
+ config,
45
+ async () => {},
46
+ );
47
+
48
+ // Mirror production routing from src/index.ts: only create proxy when enabled
49
+ const handleRuntimeProxy = config.runtimeProxyEnabled
50
+ ? createRuntimeProxyHandler(config)
51
+ : null;
52
+
53
+ const server = Bun.serve({
54
+ port: PORT,
55
+ async fetch(req) {
56
+ const url = new URL(req.url);
57
+
58
+ if (url.pathname === "/healthz") {
59
+ return Response.json({ status: "ok" });
60
+ }
61
+
62
+ if (url.pathname === "/readyz") {
63
+ return Response.json({ status: "ok" });
64
+ }
65
+
66
+ if (url.pathname === "/webhooks/telegram") {
67
+ return handleTelegramWebhook(req);
68
+ }
69
+
70
+ if (handleRuntimeProxy) {
71
+ return handleRuntimeProxy(req);
72
+ }
73
+
74
+ return Response.json({ error: "Not found" }, { status: 404 });
75
+ },
76
+ });
77
+
78
+ afterAll(() => {
79
+ server.stop(true);
80
+ for (const [k, v] of Object.entries(saved)) {
81
+ if (v === undefined) delete process.env[k];
82
+ else process.env[k] = v;
83
+ }
84
+ });
85
+
86
+ describe("Telegram-only default: non-Telegram requests return 404", () => {
87
+ test("GET / returns 404", async () => {
88
+ const res = await fetch(`http://localhost:${PORT}/`);
89
+ expect(res.status).toBe(404);
90
+ const body = await res.json();
91
+ expect(body.error).toBe("Not found");
92
+ });
93
+
94
+ test("GET /v1/health returns 404", async () => {
95
+ const res = await fetch(`http://localhost:${PORT}/v1/health`);
96
+ expect(res.status).toBe(404);
97
+ });
98
+
99
+ test("POST /v1/assistants/foo/chat returns 404", async () => {
100
+ const res = await fetch(`http://localhost:${PORT}/v1/assistants/foo/chat`, {
101
+ method: "POST",
102
+ body: "{}",
103
+ headers: { "content-type": "application/json" },
104
+ });
105
+ expect(res.status).toBe(404);
106
+ });
107
+
108
+ test("GET /random-path returns 404", async () => {
109
+ const res = await fetch(`http://localhost:${PORT}/random-path`);
110
+ expect(res.status).toBe(404);
111
+ });
112
+
113
+ test("config.runtimeProxyEnabled is false by default", () => {
114
+ expect(config.runtimeProxyEnabled).toBe(false);
115
+ });
116
+
117
+ test("runtime proxy handler is not created when proxy is disabled", () => {
118
+ expect(handleRuntimeProxy).toBeNull();
119
+ });
120
+
121
+ test("GET /healthz returns 200 (infrastructure routes still work)", async () => {
122
+ const res = await fetch(`http://localhost:${PORT}/healthz`);
123
+ expect(res.status).toBe(200);
124
+ const body = await res.json();
125
+ expect(body.status).toBe("ok");
126
+ });
127
+
128
+ test("GET /readyz returns 200 (infrastructure routes still work)", async () => {
129
+ const res = await fetch(`http://localhost:${PORT}/readyz`);
130
+ expect(res.status).toBe(200);
131
+ const body = await res.json();
132
+ expect(body.status).toBe("ok");
133
+ });
134
+ });