@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
@@ -27,7 +27,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
27
27
  defaultAssistantId: undefined,
28
28
  unmappedPolicy: "reject",
29
29
  port: 7830,
30
- runtimeProxyEnabled: false,
31
30
  runtimeProxyRequireAuth: true,
32
31
  shutdownDrainMs: 5000,
33
32
  runtimeTimeoutMs: 30000,
@@ -30,7 +30,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
30
30
  defaultAssistantId: undefined,
31
31
  unmappedPolicy: "reject",
32
32
  port: 7830,
33
- runtimeProxyEnabled: false,
34
33
  runtimeProxyRequireAuth: true,
35
34
  shutdownDrainMs: 5000,
36
35
  runtimeTimeoutMs: 30000,
@@ -29,7 +29,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
29
29
  defaultAssistantId: undefined,
30
30
  unmappedPolicy: "reject",
31
31
  port: 7830,
32
- runtimeProxyEnabled: false,
33
32
  runtimeProxyRequireAuth: false,
34
33
  shutdownDrainMs: 5000,
35
34
  runtimeTimeoutMs: 30000,
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { parseVerificationCode, hashVerificationSecret } from "../verification/code-parsing.js";
4
+ import { canonicalizeInboundIdentity } from "../verification/identity.js";
5
+ import { checkIdentityMatch } from "../verification/identity-match.js";
6
+ import type { VerificationSession } from "../verification/session-helpers.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Code parsing
10
+ // ---------------------------------------------------------------------------
11
+
12
+ describe("parseVerificationCode", () => {
13
+ test("accepts 6-digit numeric code", () => {
14
+ expect(parseVerificationCode("123456")).toBe("123456");
15
+ });
16
+
17
+ test("accepts 64-char hex string", () => {
18
+ const hex = "a".repeat(64);
19
+ expect(parseVerificationCode(hex)).toBe(hex);
20
+ });
21
+
22
+ test("strips mrkdwn formatting", () => {
23
+ expect(parseVerificationCode("*123456*")).toBe("123456");
24
+ expect(parseVerificationCode("_123456_")).toBe("123456");
25
+ expect(parseVerificationCode("`123456`")).toBe("123456");
26
+ expect(parseVerificationCode("~123456~")).toBe("123456");
27
+ });
28
+
29
+ test("rejects non-code messages", () => {
30
+ expect(parseVerificationCode("hello")).toBeUndefined();
31
+ expect(parseVerificationCode("12345")).toBeUndefined(); // too short
32
+ expect(parseVerificationCode("1234567")).toBeUndefined(); // too long for numeric
33
+ expect(parseVerificationCode("verify 123456")).toBeUndefined(); // not bare
34
+ });
35
+
36
+ test("trims whitespace", () => {
37
+ expect(parseVerificationCode(" 123456 ")).toBe("123456");
38
+ });
39
+ });
40
+
41
+ describe("hashVerificationSecret", () => {
42
+ test("produces a 64-char hex sha256", () => {
43
+ const hash = hashVerificationSecret("test");
44
+ expect(hash).toHaveLength(64);
45
+ expect(hash).toMatch(/^[0-9a-f]{64}$/);
46
+ });
47
+
48
+ test("is deterministic", () => {
49
+ expect(hashVerificationSecret("abc")).toBe(hashVerificationSecret("abc"));
50
+ });
51
+ });
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Identity canonicalization
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("canonicalizeInboundIdentity", () => {
58
+ test("phone channel: normalizes US 10-digit to E.164", () => {
59
+ expect(canonicalizeInboundIdentity("phone", "5551234567")).toBe("+15551234567");
60
+ expect(canonicalizeInboundIdentity("whatsapp", "5551234567")).toBe("+15551234567");
61
+ });
62
+
63
+ test("phone channel: passes through already-E.164", () => {
64
+ expect(canonicalizeInboundIdentity("phone", "+15551234567")).toBe("+15551234567");
65
+ });
66
+
67
+ test("phone channel: strips formatting", () => {
68
+ expect(canonicalizeInboundIdentity("phone", "(555) 123-4567")).toBe("+15551234567");
69
+ });
70
+
71
+ test("non-phone channel: trims only", () => {
72
+ expect(canonicalizeInboundIdentity("telegram", " user123 ")).toBe("user123");
73
+ expect(canonicalizeInboundIdentity("slack", "U12345")).toBe("U12345");
74
+ });
75
+
76
+ test("returns null for empty/whitespace", () => {
77
+ expect(canonicalizeInboundIdentity("telegram", "")).toBeNull();
78
+ expect(canonicalizeInboundIdentity("telegram", " ")).toBeNull();
79
+ });
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Identity matching
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe("checkIdentityMatch", () => {
87
+ const baseSession: VerificationSession = {
88
+ id: "sess-1",
89
+ challengeHash: "abc",
90
+ expiresAt: Date.now() + 60_000,
91
+ status: "pending",
92
+ verificationPurpose: "guardian",
93
+ expectedExternalUserId: null,
94
+ expectedChatId: null,
95
+ expectedPhoneE164: null,
96
+ identityBindingStatus: "bound",
97
+ codeDigits: 6,
98
+ maxAttempts: 3,
99
+ };
100
+
101
+ test("matches when session has no expected identity (inbound)", () => {
102
+ expect(checkIdentityMatch(baseSession, "any-user", "any-chat")).toBe(true);
103
+ });
104
+
105
+ test("matches when binding status is not bound", () => {
106
+ const session = { ...baseSession, expectedExternalUserId: "user-1", identityBindingStatus: "pending_bootstrap" };
107
+ expect(checkIdentityMatch(session, "different-user", "any-chat")).toBe(true);
108
+ });
109
+
110
+ test("matches by phone E.164", () => {
111
+ const session = { ...baseSession, expectedPhoneE164: "+15551234567" };
112
+ expect(checkIdentityMatch(session, "+15551234567", "chat-1")).toBe(true);
113
+ });
114
+
115
+ test("rejects phone mismatch", () => {
116
+ const session = { ...baseSession, expectedPhoneE164: "+15551234567" };
117
+ expect(checkIdentityMatch(session, "+19999999999", "chat-1")).toBe(false);
118
+ });
119
+
120
+ test("matches by externalUserId when expectedChatId is set", () => {
121
+ const session = { ...baseSession, expectedExternalUserId: "user-1", expectedChatId: "chat-1" };
122
+ expect(checkIdentityMatch(session, "user-1", "different-chat")).toBe(true);
123
+ });
124
+
125
+ test("matches by chatId alone when no expectedExternalUserId", () => {
126
+ const session = { ...baseSession, expectedChatId: "chat-1" };
127
+ expect(checkIdentityMatch(session, "any-user", "chat-1")).toBe(true);
128
+ });
129
+
130
+ test("rejects chatId-only mismatch", () => {
131
+ const session = { ...baseSession, expectedChatId: "chat-1" };
132
+ expect(checkIdentityMatch(session, "any-user", "wrong-chat")).toBe(false);
133
+ });
134
+ });
135
+
136
+
@@ -45,7 +45,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
45
45
  defaultAssistantId: undefined,
46
46
  unmappedPolicy: "reject",
47
47
  port: 7830,
48
- runtimeProxyEnabled: false,
49
48
  runtimeProxyRequireAuth: true,
50
49
  shutdownDrainMs: 5000,
51
50
  runtimeTimeoutMs: 30000,
@@ -44,7 +44,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
44
44
  defaultAssistantId: undefined,
45
45
  unmappedPolicy: "reject",
46
46
  port: 7830,
47
- runtimeProxyEnabled: false,
48
47
  runtimeProxyRequireAuth: true,
49
48
  shutdownDrainMs: 5000,
50
49
  runtimeTimeoutMs: 30000,
@@ -121,7 +121,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
121
121
  defaultAssistantId: undefined,
122
122
  unmappedPolicy: "reject",
123
123
  port: 7830,
124
- runtimeProxyEnabled: false,
125
124
  runtimeProxyRequireAuth: true,
126
125
  shutdownDrainMs: 5000,
127
126
  runtimeTimeoutMs: 30000,
@@ -147,10 +146,15 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
147
146
  function makeCaches(
148
147
  opts: {
149
148
  authToken?: string;
149
+ ingressEnabled?: boolean;
150
150
  ingressUrl?: string;
151
151
  } = {},
152
152
  ) {
153
- const { authToken = AUTH_TOKEN, ingressUrl } = opts;
153
+ const {
154
+ authToken = AUTH_TOKEN,
155
+ ingressEnabled,
156
+ ingressUrl,
157
+ } = opts;
154
158
  const credentials = {
155
159
  get: async (key: string, _opts?: { force?: boolean }) => {
156
160
  if (key === credentialKey("twilio", "auth_token")) return authToken;
@@ -163,6 +167,10 @@ function makeCaches(
163
167
  if (section === "ingress" && key === "publicBaseUrl") return ingressUrl;
164
168
  return undefined;
165
169
  },
170
+ getBoolean: (section: string, key: string) => {
171
+ if (section === "ingress" && key === "enabled") return ingressEnabled;
172
+ return undefined;
173
+ },
166
174
  getRecord: () => undefined,
167
175
  refreshNow: () => {},
168
176
  } as unknown as ConfigFileCache;
@@ -256,6 +264,20 @@ describe("Twilio voice webhook", () => {
256
264
  expect(res.status).toBe(403);
257
265
  });
258
266
 
267
+ test("rejects while public ingress is disabled", async () => {
268
+ const handler = createTwilioVoiceWebhookHandler(
269
+ makeConfig(),
270
+ makeCaches({ ingressEnabled: false }),
271
+ );
272
+ const url = "http://localhost:7830/webhooks/twilio/voice";
273
+ const req = buildSignedRequest(url, { From: "+15550100" }, AUTH_TOKEN);
274
+
275
+ const res = await handler(req);
276
+
277
+ expect(res.status).toBe(403);
278
+ expect(fetchMock).not.toHaveBeenCalled();
279
+ });
280
+
259
281
  test("forwards valid signed request to runtime and returns response", async () => {
260
282
  const twiml = '<?xml version="1.0" encoding="UTF-8"?><Response/>';
261
283
  fetchMock = mock(
@@ -475,6 +497,53 @@ describe("Twilio connect-action webhook", () => {
475
497
  });
476
498
 
477
499
  describe("Twilio webhook signature with canonical ingress base URL", () => {
500
+ test("validates signature against configured publicBaseUrl", async () => {
501
+ fetchMock = mock(
502
+ async () =>
503
+ new Response('<?xml version="1.0" encoding="UTF-8"?><Response/>', {
504
+ status: 200,
505
+ headers: { "Content-Type": "text/xml" },
506
+ }),
507
+ );
508
+
509
+ const publicBaseUrl = "https://public.example.com";
510
+ const handler = createTwilioVoiceWebhookHandler(
511
+ makeConfig(),
512
+ makeCaches({
513
+ ingressUrl: publicBaseUrl,
514
+ }),
515
+ );
516
+
517
+ const localUrl =
518
+ "http://localhost:7830/webhooks/twilio/voice?callSessionId=sig-test";
519
+ const publicUrl =
520
+ publicBaseUrl + "/webhooks/twilio/voice?callSessionId=sig-test";
521
+ const params = { CallSid: "CA123" };
522
+ const signature = computeSignature(publicUrl, params, AUTH_TOKEN);
523
+ const req = new Request(localUrl, {
524
+ method: "POST",
525
+ headers: {
526
+ "Content-Type": "application/x-www-form-urlencoded",
527
+ "X-Twilio-Signature": signature,
528
+ },
529
+ body: new URLSearchParams(params).toString(),
530
+ });
531
+
532
+ const res = await handler(req);
533
+ expect(res.status).toBe(200);
534
+
535
+ const successLog = findLogCall("Twilio webhook signature validated");
536
+ expect(successLog.method).toBe("info");
537
+ expect(successLog.data).toMatchObject({
538
+ webhookKind: "voice",
539
+ validatedCandidateSource: "configured_ingress",
540
+ validatedCandidateUrl: publicUrl,
541
+ candidateCount: 2,
542
+ candidateSources: ["configured_ingress", "raw_request"],
543
+ candidateUrls: [publicUrl, localUrl],
544
+ });
545
+ });
546
+
478
547
  test("validates signature against ingressPublicBaseUrl when configured", async () => {
479
548
  const twiml = '<?xml version="1.0" encoding="UTF-8"?><Response/>';
480
549
  fetchMock = mock(
@@ -713,9 +782,157 @@ describe("Twilio webhook signature with canonical ingress base URL", () => {
713
782
  ],
714
783
  });
715
784
  });
785
+
786
+ test("validates signature against X-Vellum-Ingress-URL from platform callback proxy", async () => {
787
+ fetchMock = mock(
788
+ async () =>
789
+ new Response('<?xml version="1.0" encoding="UTF-8"?><Response/>', {
790
+ status: 200,
791
+ headers: { "Content-Type": "text/xml" },
792
+ }),
793
+ );
794
+
795
+ const handler = createTwilioVoiceWebhookHandler(makeConfig(), makeCaches());
796
+
797
+ // The platform callback URL is what Twilio signs against — it includes
798
+ // the /v1/gateway/callbacks/{assistantId}/ prefix that the gateway
799
+ // never sees in the request path.
800
+ const platformCallbackUrl =
801
+ "https://platform.vellum.ai/v1/gateway/callbacks/abc123/webhooks/twilio/voice";
802
+ const localUrl =
803
+ "http://localhost:7830/webhooks/twilio/voice?callSessionId=platform-proxy-test";
804
+ const params = { CallSid: "CA-platform-proxy" };
805
+
806
+ // Sign against the platform callback URL (as Twilio would)
807
+ const signature = computeSignature(platformCallbackUrl, params, AUTH_TOKEN);
808
+ const req = new Request(localUrl, {
809
+ method: "POST",
810
+ headers: {
811
+ "Content-Type": "application/x-www-form-urlencoded",
812
+ "X-Twilio-Signature": signature,
813
+ "X-Vellum-Ingress-URL": platformCallbackUrl,
814
+ },
815
+ body: new URLSearchParams(params).toString(),
816
+ });
817
+
818
+ const res = await handler(req);
819
+ expect(res.status).toBe(200);
820
+
821
+ const successLog = findLogCall("Twilio webhook signature validated");
822
+ expect(successLog.method).toBe("info");
823
+ expect(successLog.data).toMatchObject({
824
+ webhookKind: "voice",
825
+ validatedCandidateSource: "platform_proxy",
826
+ validatedCandidateUrl: platformCallbackUrl,
827
+ candidateCount: 2,
828
+ candidateSources: ["platform_proxy", "raw_request"],
829
+ candidateUrls: [platformCallbackUrl, localUrl],
830
+ });
831
+ });
832
+
833
+ test("platform proxy URL takes priority over configured ingress", async () => {
834
+ fetchMock = mock(
835
+ async () =>
836
+ new Response('<?xml version="1.0" encoding="UTF-8"?><Response/>', {
837
+ status: 200,
838
+ headers: { "Content-Type": "text/xml" },
839
+ }),
840
+ );
841
+
842
+ const staleConfiguredBase = "https://stale.example.com";
843
+ const handler = createTwilioVoiceWebhookHandler(
844
+ makeConfig(),
845
+ makeCaches({ ingressUrl: staleConfiguredBase }),
846
+ );
847
+
848
+ const platformCallbackUrl =
849
+ "https://platform.vellum.ai/v1/gateway/callbacks/abc123/webhooks/twilio/voice";
850
+ const localUrl =
851
+ "http://localhost:7830/webhooks/twilio/voice?callSessionId=priority-test";
852
+ const params = { CallSid: "CA-priority" };
853
+
854
+ // Sign against the platform callback URL
855
+ const signature = computeSignature(platformCallbackUrl, params, AUTH_TOKEN);
856
+ const req = new Request(localUrl, {
857
+ method: "POST",
858
+ headers: {
859
+ "Content-Type": "application/x-www-form-urlencoded",
860
+ "X-Twilio-Signature": signature,
861
+ "X-Vellum-Ingress-URL": platformCallbackUrl,
862
+ },
863
+ body: new URLSearchParams(params).toString(),
864
+ });
865
+
866
+ const res = await handler(req);
867
+ expect(res.status).toBe(200);
868
+
869
+ const successLog = findLogCall("Twilio webhook signature validated");
870
+ expect(successLog.data).toMatchObject({
871
+ validatedCandidateSource: "platform_proxy",
872
+ validatedCandidateUrl: platformCallbackUrl,
873
+ candidateSources: ["platform_proxy", "configured_ingress", "raw_request"],
874
+ });
875
+ });
716
876
  });
717
877
 
718
- describe("Twilio webhook force retry on credential-missing", () => {
878
+ describe("Twilio webhook force retry", () => {
879
+ test("refreshes Twilio-specific ingress URL before retrying signature validation", async () => {
880
+ fetchMock = mock(
881
+ async () =>
882
+ new Response('<?xml version="1.0" encoding="UTF-8"?><Response/>', {
883
+ status: 200,
884
+ headers: { "Content-Type": "text/xml" },
885
+ }),
886
+ );
887
+
888
+ let refreshCount = 0;
889
+ const credentials = {
890
+ get: async () => AUTH_TOKEN,
891
+ invalidate: () => {},
892
+ } as unknown as CredentialCache;
893
+
894
+ const staleTwilioBaseUrl = "https://stale-twilio.example.com";
895
+ const freshBaseUrl = "https://fresh-twilio.example.com";
896
+ const configFile = {
897
+ getString: (section: string, key: string) => {
898
+ if (section !== "ingress") return undefined;
899
+ if (key === "publicBaseUrl") {
900
+ return refreshCount > 0 ? freshBaseUrl : staleTwilioBaseUrl;
901
+ }
902
+ return undefined;
903
+ },
904
+ getRecord: () => undefined,
905
+ refreshNow: () => {
906
+ refreshCount++;
907
+ },
908
+ } as unknown as ConfigFileCache;
909
+
910
+ const handler = createTwilioVoiceWebhookHandler(makeConfig(), {
911
+ credentials,
912
+ configFile,
913
+ });
914
+
915
+ const localUrl =
916
+ "http://localhost:7830/webhooks/twilio/voice?callSessionId=sess-refresh";
917
+ const freshTwilioUrl =
918
+ freshBaseUrl + "/webhooks/twilio/voice?callSessionId=sess-refresh";
919
+ const params = { CallSid: "CA123" };
920
+ const signature = computeSignature(freshTwilioUrl, params, AUTH_TOKEN);
921
+ const req = new Request(localUrl, {
922
+ method: "POST",
923
+ headers: {
924
+ "Content-Type": "application/x-www-form-urlencoded",
925
+ "X-Twilio-Signature": signature,
926
+ },
927
+ body: new URLSearchParams(params).toString(),
928
+ });
929
+
930
+ const res = await handler(req);
931
+
932
+ expect(res.status).toBe(200);
933
+ expect(refreshCount).toBe(1);
934
+ });
935
+
719
936
  test("succeeds after force-refreshing a missing auth token", async () => {
720
937
  const twiml = '<?xml version="1.0" encoding="UTF-8"?><Response/>';
721
938
  fetchMock = mock(
@@ -160,41 +160,6 @@ describe("HTTP proxy timeout/error via assistant-client", () => {
160
160
  describe("WS upstream URL construction via assistant-client", () => {
161
161
  const baseUrl = "http://localhost:7821";
162
162
 
163
- test("browser-relay builds correct upstream WS URL with guardian/instance params", () => {
164
- const result = buildWsUpstreamUrl({
165
- baseUrl,
166
- path: "/v1/browser-relay",
167
- serviceToken: "svc-jwt-token",
168
- extraParams: {
169
- guardianId: "guardian-abc",
170
- clientInstanceId: "inst-xyz",
171
- },
172
- });
173
-
174
- const url = new URL(result.url);
175
- expect(url.protocol).toBe("ws:");
176
- expect(url.hostname).toBe("localhost");
177
- expect(url.port).toBe("7821");
178
- expect(url.pathname).toBe("/v1/browser-relay");
179
- expect(url.searchParams.get("token")).toBe("svc-jwt-token");
180
- expect(url.searchParams.get("guardianId")).toBe("guardian-abc");
181
- expect(url.searchParams.get("clientInstanceId")).toBe("inst-xyz");
182
- });
183
-
184
- test("browser-relay builds URL without optional params when absent", () => {
185
- const result = buildWsUpstreamUrl({
186
- baseUrl,
187
- path: "/v1/browser-relay",
188
- serviceToken: "svc-jwt-token",
189
- extraParams: {},
190
- });
191
-
192
- const url = new URL(result.url);
193
- expect(url.searchParams.get("token")).toBe("svc-jwt-token");
194
- expect(url.searchParams.has("guardianId")).toBe(false);
195
- expect(url.searchParams.has("clientInstanceId")).toBe(false);
196
- });
197
-
198
163
  test("twilio-relay builds correct upstream WS URL with callSessionId", () => {
199
164
  const result = buildWsUpstreamUrl({
200
165
  baseUrl,
@@ -270,7 +235,6 @@ describe("WS upstream log-safe URL", () => {
270
235
  path: string;
271
236
  params: Record<string, string>;
272
237
  }> = [
273
- { path: "/v1/browser-relay", params: { guardianId: "g1" } },
274
238
  { path: "/v1/calls/relay", params: { callSessionId: "c1" } },
275
239
  { path: "/v1/calls/media-stream", params: { callSessionId: "m1" } },
276
240
  { path: "/v1/stt/stream", params: { mimeType: "audio/webm" } },
@@ -26,7 +26,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
26
26
  defaultAssistantId: undefined,
27
27
  unmappedPolicy: "reject",
28
28
  port: 7830,
29
- runtimeProxyEnabled: false,
30
29
  runtimeProxyRequireAuth: true,
31
30
  shutdownDrainMs: 5000,
32
31
  runtimeTimeoutMs: 30000,
@@ -53,7 +53,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
53
53
  defaultAssistantId: undefined,
54
54
  unmappedPolicy: "reject",
55
55
  port: 7830,
56
- runtimeProxyEnabled: false,
57
56
  runtimeProxyRequireAuth: true,
58
57
  shutdownDrainMs: 5000,
59
58
  runtimeTimeoutMs: 30000,
@@ -3,7 +3,7 @@
3
3
  * gateway's own SQLite database for all token operations.
4
4
  */
5
5
 
6
- import { createHash, randomBytes } from "node:crypto";
6
+ import { randomBytes } from "node:crypto";
7
7
 
8
8
  import { and, eq } from "drizzle-orm";
9
9
 
@@ -35,7 +35,6 @@ export type RefreshErrorCode =
35
35
  | "refresh_invalid"
36
36
  | "refresh_expired"
37
37
  | "refresh_reuse_detected"
38
- | "device_binding_mismatch"
39
38
  | "revoked";
40
39
 
41
40
  export interface RotateResult {
@@ -202,15 +201,10 @@ function mintRefreshTokenInFamily(params: {
202
201
  */
203
202
  export function rotateCredentials(params: {
204
203
  refreshToken: string;
205
- platform: string;
206
- deviceId: string;
207
204
  }):
208
205
  | { ok: true; result: RotateResult }
209
206
  | { ok: false; error: RefreshErrorCode } {
210
207
  const refreshTokenHash = hashToken(params.refreshToken);
211
- const hashedDeviceId = createHash("sha256")
212
- .update(params.deviceId)
213
- .digest("hex");
214
208
 
215
209
  const record = findRefreshByHash(refreshTokenHash);
216
210
 
@@ -245,14 +239,6 @@ export function rotateCredentials(params: {
245
239
  return { ok: false, error: "refresh_expired" };
246
240
  }
247
241
 
248
- if (record.hashedDeviceId !== hashedDeviceId) {
249
- return { ok: false, error: "device_binding_mismatch" };
250
- }
251
-
252
- if (record.platform !== params.platform) {
253
- return { ok: false, error: "device_binding_mismatch" };
254
- }
255
-
256
242
  return getGatewayDb().transaction((tx) => {
257
243
  void tx; // transaction scoped via the underlying bun:sqlite connection
258
244
 
@@ -269,19 +255,19 @@ export function rotateCredentials(params: {
269
255
  const access = mintAccessToken(
270
256
  record.guardianPrincipalId,
271
257
  record.hashedDeviceId,
272
- params.platform,
258
+ record.platform,
273
259
  );
274
260
 
275
261
  const refresh = mintRefreshTokenInFamily({
276
262
  guardianPrincipalId: record.guardianPrincipalId,
277
263
  hashedDeviceId: record.hashedDeviceId,
278
- platform: params.platform,
264
+ platform: record.platform,
279
265
  familyId: record.familyId,
280
266
  absoluteExpiresAt: record.absoluteExpiresAt,
281
267
  });
282
268
 
283
269
  log.info(
284
- { familyId: record.familyId, platform: params.platform },
270
+ { familyId: record.familyId, platform: record.platform },
285
271
  "Credential rotation completed",
286
272
  );
287
273