@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
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
258
|
+
record.platform,
|
|
273
259
|
);
|
|
274
260
|
|
|
275
261
|
const refresh = mintRefreshTokenInFamily({
|
|
276
262
|
guardianPrincipalId: record.guardianPrincipalId,
|
|
277
263
|
hashedDeviceId: record.hashedDeviceId,
|
|
278
|
-
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:
|
|
270
|
+
{ familyId: record.familyId, platform: record.platform },
|
|
285
271
|
"Credential rotation completed",
|
|
286
272
|
);
|
|
287
273
|
|