@vellumai/vellum-gateway 0.4.13 → 0.4.15
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/ARCHITECTURE.md +15 -15
- package/package.json +1 -1
- package/src/__tests__/browser-relay-websocket.test.ts +6 -14
- package/src/__tests__/config.test.ts +3 -145
- package/src/__tests__/guardian-control-plane-proxy.test.ts +0 -6
- package/src/__tests__/ingress-control-plane-proxy.test.ts +0 -6
- package/src/__tests__/load-guards.test.ts +0 -6
- package/src/__tests__/oauth-callback.test.ts +1 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -6
- package/src/__tests__/runtime-client.test.ts +4 -10
- package/src/__tests__/runtime-health-proxy.test.ts +0 -6
- package/src/__tests__/runtime-proxy-auth.test.ts +26 -11
- package/src/__tests__/runtime-proxy.test.ts +3 -9
- package/src/__tests__/slack-config.test.ts +0 -3
- package/src/__tests__/slack-deliver.test.ts +1 -7
- package/src/__tests__/slack-normalize.test.ts +0 -3
- package/src/__tests__/sms-ingress-guard.test.ts +0 -6
- package/src/__tests__/telegram-api-redaction.test.ts +0 -6
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -6
- package/src/__tests__/telegram-deliver-auth.test.ts +7 -13
- package/src/__tests__/telegram-only-default.test.ts +2 -7
- package/src/__tests__/telegram-reconcile-route.test.ts +1 -7
- package/src/__tests__/telegram-send-attachments.test.ts +0 -6
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -8
- package/src/__tests__/telegram-webhook-manager.test.ts +0 -6
- package/src/__tests__/twilio-relay-websocket.test.ts +30 -15
- package/src/__tests__/twilio-webhooks.test.ts +0 -6
- package/src/auth/policy.ts +17 -0
- package/src/auth/scopes.ts +64 -0
- package/src/auth/subject.ts +68 -0
- package/src/auth/token-exchange.ts +127 -0
- package/src/auth/token-service.ts +208 -0
- package/src/auth/types.ts +79 -0
- package/src/config.ts +2 -121
- package/src/http/middleware/deliver-auth.ts +15 -18
- package/src/http/routes/brain-graph-proxy.ts +2 -7
- package/src/http/routes/browser-relay-websocket.ts +17 -14
- package/src/http/routes/channel-readiness-proxy.ts +2 -7
- package/src/http/routes/guardian-control-plane-proxy.ts +4 -10
- package/src/http/routes/ingress-control-plane-proxy.ts +2 -7
- package/src/http/routes/pairing-proxy.ts +3 -4
- package/src/http/routes/runtime-health-proxy.ts +2 -7
- package/src/http/routes/runtime-proxy.ts +22 -28
- package/src/http/routes/sms-deliver.test.ts +12 -22
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -7
- package/src/http/routes/telegram-deliver.test.ts +5 -11
- package/src/http/routes/telegram-reconcile.ts +8 -13
- package/src/http/routes/telegram-webhook.test.ts +0 -3
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -7
- package/src/http/routes/twilio-relay-websocket.ts +21 -16
- package/src/http/routes/twilio-sms-webhook.test.ts +0 -3
- package/src/http/routes/twilio-voice-webhook.test.ts +0 -3
- package/src/http/routes/whatsapp-deliver.test.ts +3 -11
- package/src/http/routes/whatsapp-webhook.test.ts +0 -3
- package/src/index.ts +80 -190
- package/src/runtime/client.ts +21 -24
- package/src/telegram/send.test.ts +0 -3
- package/src/whatsapp/send.ts +22 -2
- package/src/__tests__/bearer-auth.test.ts +0 -40
- package/src/feature-flags-auth.test.ts +0 -286
- package/src/http/auth/bearer.ts +0 -34
package/ARCHITECTURE.md
CHANGED
|
@@ -53,12 +53,12 @@ The gateway exposes a REST API for reading and mutating assistant feature flags.
|
|
|
53
53
|
|
|
54
54
|
**Token separation (authentication boundary):**
|
|
55
55
|
|
|
56
|
-
The assistant feature flags API uses a dedicated
|
|
56
|
+
The assistant feature flags API uses a dedicated feature-flag token stored at `~/.vellum/feature-flag-token`, separate from JWT auth tokens. This separation ensures that clients with feature-flag access cannot access runtime endpoints, and vice versa.
|
|
57
57
|
|
|
58
58
|
| Operation | Accepted tokens |
|
|
59
59
|
|-----------|----------------|
|
|
60
|
-
| `GET /v1/feature-flags` |
|
|
61
|
-
| `PATCH /v1/feature-flags/:key` | Feature-flag token ONLY (
|
|
60
|
+
| `GET /v1/feature-flags` | JWT bearer token OR feature-flag token |
|
|
61
|
+
| `PATCH /v1/feature-flags/:key` | Feature-flag token ONLY (JWT bearer tokens are explicitly rejected) |
|
|
62
62
|
|
|
63
63
|
The feature-flag token is auto-generated on first gateway startup if the file does not exist. The gateway watches the token file for changes and hot-reloads without restart.
|
|
64
64
|
|
|
@@ -90,12 +90,12 @@ Guardian verification endpoints are exposed directly by the gateway and forwarde
|
|
|
90
90
|
| POST | `/v1/integrations/guardian/outbound/cancel` |
|
|
91
91
|
| POST | `/v1/integrations/guardian/vellum/refresh` |
|
|
92
92
|
|
|
93
|
-
The `/vellum/refresh` endpoint is the only public ingress for rotating
|
|
93
|
+
The `/vellum/refresh` endpoint is the only public ingress for rotating JWT access + refresh token credentials. Clients must call this through the gateway; the runtime endpoint is not directly exposed. The gateway validates the caller's JWT and forwards to the runtime, which handles refresh token validation, rotation, and replay detection (see [`assistant/ARCHITECTURE.md`](../assistant/ARCHITECTURE.md) for the JWT auth lifecycle).
|
|
94
94
|
|
|
95
95
|
**Authentication boundary:**
|
|
96
96
|
|
|
97
|
-
- Gateway validates caller bearer
|
|
98
|
-
- Gateway forwards requests to runtime with
|
|
97
|
+
- Gateway validates the caller's JWT bearer token.
|
|
98
|
+
- Gateway forwards requests to runtime with a minted JWT (`gateway_service_v1` scope profile).
|
|
99
99
|
- Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
|
|
100
100
|
|
|
101
101
|
**Key source files:**
|
|
@@ -103,7 +103,7 @@ The `/vellum/refresh` endpoint is the only public ingress for rotating actor + r
|
|
|
103
103
|
| File | Purpose |
|
|
104
104
|
|------|---------|
|
|
105
105
|
| `gateway/src/http/routes/guardian-control-plane-proxy.ts` | Guardian control-plane proxy handlers and upstream forwarding |
|
|
106
|
-
| `gateway/src/index.ts` | Route registration and
|
|
106
|
+
| `gateway/src/index.ts` | Route registration and JWT auth enforcement for `/v1/integrations/guardian/*` |
|
|
107
107
|
|
|
108
108
|
### Runtime Health Proxy
|
|
109
109
|
|
|
@@ -111,8 +111,8 @@ Runtime health is exposed directly by the gateway at `GET /v1/health` and forwar
|
|
|
111
111
|
|
|
112
112
|
**Authentication boundary:**
|
|
113
113
|
|
|
114
|
-
- Gateway validates caller bearer
|
|
115
|
-
- Gateway forwards the request to runtime with
|
|
114
|
+
- Gateway validates the caller's JWT bearer token.
|
|
115
|
+
- Gateway forwards the request to runtime with a minted JWT (`gateway_service_v1` scope profile).
|
|
116
116
|
- Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
|
|
117
117
|
|
|
118
118
|
**Key source files:**
|
|
@@ -147,8 +147,8 @@ Telegram integration setup/config endpoints and ingress members/invites endpoint
|
|
|
147
147
|
|
|
148
148
|
**Authentication boundary:**
|
|
149
149
|
|
|
150
|
-
- Gateway validates caller bearer
|
|
151
|
-
- Gateway forwards requests to runtime with
|
|
150
|
+
- Gateway validates the caller's JWT bearer token.
|
|
151
|
+
- Gateway forwards requests to runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile).
|
|
152
152
|
- Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
|
|
153
153
|
|
|
154
154
|
**Key source files:**
|
|
@@ -183,8 +183,8 @@ Twilio integration setup/config endpoints are exposed directly by the gateway an
|
|
|
183
183
|
|
|
184
184
|
**Authentication boundary:**
|
|
185
185
|
|
|
186
|
-
- Gateway validates caller bearer
|
|
187
|
-
- Gateway forwards requests to runtime with
|
|
186
|
+
- Gateway validates the caller's JWT bearer token.
|
|
187
|
+
- Gateway forwards requests to runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile).
|
|
188
188
|
- Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
|
|
189
189
|
|
|
190
190
|
**Key source files:**
|
|
@@ -207,8 +207,8 @@ Channel readiness endpoints are exposed directly by the gateway and forwarded to
|
|
|
207
207
|
|
|
208
208
|
**Authentication boundary:**
|
|
209
209
|
|
|
210
|
-
- Gateway validates caller bearer
|
|
211
|
-
- Gateway forwards requests to runtime with
|
|
210
|
+
- Gateway validates the caller's JWT bearer token.
|
|
211
|
+
- Gateway forwards requests to runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile).
|
|
212
212
|
- Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
|
|
213
213
|
|
|
214
214
|
**Key source files:**
|
package/package.json
CHANGED
|
@@ -19,11 +19,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
19
19
|
defaultAssistantId: undefined,
|
|
20
20
|
unmappedPolicy: "reject",
|
|
21
21
|
port: 7830,
|
|
22
|
-
runtimeBearerToken: undefined,
|
|
23
|
-
runtimeGatewayOriginSecret: undefined,
|
|
24
22
|
runtimeProxyEnabled: false,
|
|
25
23
|
runtimeProxyRequireAuth: true,
|
|
26
|
-
runtimeProxyBearerToken: undefined,
|
|
27
24
|
shutdownDrainMs: 5000,
|
|
28
25
|
runtimeTimeoutMs: 30000,
|
|
29
26
|
runtimeMaxRetries: 2,
|
|
@@ -56,9 +53,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
56
53
|
trustProxy: false,
|
|
57
54
|
...overrides,
|
|
58
55
|
};
|
|
59
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
60
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
61
|
-
}
|
|
62
56
|
return merged;
|
|
63
57
|
}
|
|
64
58
|
|
|
@@ -104,7 +98,7 @@ describe("createBrowserRelayWebsocketHandler", () => {
|
|
|
104
98
|
const TEST_TOKEN = "relay-token-abc123";
|
|
105
99
|
|
|
106
100
|
test("upgrades when token query parameter is valid", () => {
|
|
107
|
-
const config = makeConfig({
|
|
101
|
+
const config = makeConfig({});
|
|
108
102
|
const handler = createBrowserRelayWebsocketHandler(config);
|
|
109
103
|
const req = new Request(
|
|
110
104
|
`http://localhost:7830/v1/browser-relay?token=${TEST_TOKEN}`,
|
|
@@ -121,7 +115,7 @@ describe("createBrowserRelayWebsocketHandler", () => {
|
|
|
121
115
|
});
|
|
122
116
|
|
|
123
117
|
test("returns 401 when token is missing", () => {
|
|
124
|
-
const config = makeConfig({
|
|
118
|
+
const config = makeConfig({});
|
|
125
119
|
const handler = createBrowserRelayWebsocketHandler(config);
|
|
126
120
|
const req = new Request("http://localhost:7830/v1/browser-relay", {
|
|
127
121
|
headers: { upgrade: "websocket" },
|
|
@@ -138,7 +132,7 @@ describe("createBrowserRelayWebsocketHandler", () => {
|
|
|
138
132
|
});
|
|
139
133
|
|
|
140
134
|
test("allows unauthenticated upgrade when runtime proxy auth is disabled", () => {
|
|
141
|
-
const config = makeConfig({ runtimeProxyRequireAuth: false
|
|
135
|
+
const config = makeConfig({ runtimeProxyRequireAuth: false});
|
|
142
136
|
const handler = createBrowserRelayWebsocketHandler(config);
|
|
143
137
|
const req = new Request("http://localhost:7830/v1/browser-relay", {
|
|
144
138
|
headers: { upgrade: "websocket" },
|
|
@@ -154,7 +148,7 @@ describe("createBrowserRelayWebsocketHandler", () => {
|
|
|
154
148
|
});
|
|
155
149
|
|
|
156
150
|
test("returns 403 when non-loopback host is requested from a public peer", () => {
|
|
157
|
-
const config = makeConfig({
|
|
151
|
+
const config = makeConfig({});
|
|
158
152
|
const handler = createBrowserRelayWebsocketHandler(config);
|
|
159
153
|
const req = new Request(
|
|
160
154
|
`http://gateway.example.com:7830/v1/browser-relay?token=${TEST_TOKEN}`,
|
|
@@ -173,7 +167,7 @@ describe("createBrowserRelayWebsocketHandler", () => {
|
|
|
173
167
|
});
|
|
174
168
|
|
|
175
169
|
test("returns 403 for localhost host when peer is public (host spoof prevention)", () => {
|
|
176
|
-
const config = makeConfig({
|
|
170
|
+
const config = makeConfig({});
|
|
177
171
|
const handler = createBrowserRelayWebsocketHandler(config);
|
|
178
172
|
const req = new Request(
|
|
179
173
|
`http://localhost:7830/v1/browser-relay?token=${TEST_TOKEN}`,
|
|
@@ -191,9 +185,8 @@ describe("createBrowserRelayWebsocketHandler", () => {
|
|
|
191
185
|
expect(fakeServer.upgrade).not.toHaveBeenCalled();
|
|
192
186
|
});
|
|
193
187
|
|
|
194
|
-
|
|
195
188
|
test("allows non-loopback host when peer is private network", () => {
|
|
196
|
-
const config = makeConfig({
|
|
189
|
+
const config = makeConfig({});
|
|
197
190
|
const handler = createBrowserRelayWebsocketHandler(config);
|
|
198
191
|
const req = new Request(
|
|
199
192
|
`http://gateway.example.com:7830/v1/browser-relay?token=${TEST_TOKEN}`,
|
|
@@ -238,7 +231,6 @@ describe("getBrowserRelayWebsocketHandlers", () => {
|
|
|
238
231
|
wsType: "browser-relay",
|
|
239
232
|
config: makeConfig({
|
|
240
233
|
assistantRuntimeBaseUrl: "http://runtime.internal:7821",
|
|
241
|
-
runtimeBearerToken: "runtime-token",
|
|
242
234
|
}),
|
|
243
235
|
});
|
|
244
236
|
|
|
@@ -74,66 +74,32 @@ describe("config: Telegram-only default mode", () => {
|
|
|
74
74
|
|
|
75
75
|
describe("config: runtime proxy flags", () => {
|
|
76
76
|
test("proxy disabled by default", () => {
|
|
77
|
-
withEnv({
|
|
77
|
+
withEnv({}, () => {
|
|
78
78
|
const config = loadConfig();
|
|
79
79
|
expect(config.runtimeProxyEnabled).toBe(false);
|
|
80
80
|
expect(config.runtimeProxyRequireAuth).toBe(true);
|
|
81
|
-
expect(config.runtimeProxyBearerToken).toBeUndefined();
|
|
82
81
|
});
|
|
83
82
|
});
|
|
84
83
|
|
|
85
|
-
test("proxy enabled with auth
|
|
86
|
-
withEnv(
|
|
87
|
-
{
|
|
88
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
89
|
-
RUNTIME_PROXY_BEARER_TOKEN: "secret-key",
|
|
90
|
-
},
|
|
91
|
-
() => {
|
|
92
|
-
const config = loadConfig();
|
|
93
|
-
expect(config.runtimeProxyEnabled).toBe(true);
|
|
94
|
-
expect(config.runtimeProxyRequireAuth).toBe(true);
|
|
95
|
-
expect(config.runtimeProxyBearerToken).toBe("secret-key");
|
|
96
|
-
},
|
|
97
|
-
);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("proxy enabled with auth disabled (no token needed)", () => {
|
|
84
|
+
test("proxy enabled with auth disabled", () => {
|
|
101
85
|
withEnv(
|
|
102
86
|
{
|
|
103
87
|
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
104
88
|
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
|
|
105
|
-
VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
|
|
106
89
|
},
|
|
107
90
|
() => {
|
|
108
91
|
const config = loadConfig();
|
|
109
92
|
expect(config.runtimeProxyEnabled).toBe(true);
|
|
110
93
|
expect(config.runtimeProxyRequireAuth).toBe(false);
|
|
111
|
-
expect(config.runtimeProxyBearerToken).toBeUndefined();
|
|
112
94
|
},
|
|
113
95
|
);
|
|
114
96
|
});
|
|
115
97
|
|
|
116
|
-
test("proxy
|
|
117
|
-
withEnv(
|
|
118
|
-
{
|
|
119
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
120
|
-
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
121
|
-
VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
|
|
122
|
-
},
|
|
123
|
-
() => {
|
|
124
|
-
expect(() => loadConfig()).toThrow(
|
|
125
|
-
"RUNTIME_PROXY_BEARER_TOKEN is required when proxy is enabled with auth required",
|
|
126
|
-
);
|
|
127
|
-
},
|
|
128
|
-
);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("proxy disabled ignores missing token even with auth required", () => {
|
|
98
|
+
test("proxy disabled ignores auth setting", () => {
|
|
132
99
|
withEnv(
|
|
133
100
|
{
|
|
134
101
|
GATEWAY_RUNTIME_PROXY_ENABLED: "false",
|
|
135
102
|
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "true",
|
|
136
|
-
VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
|
|
137
103
|
},
|
|
138
104
|
() => {
|
|
139
105
|
const config = loadConfig();
|
|
@@ -146,8 +112,6 @@ describe("config: runtime proxy flags", () => {
|
|
|
146
112
|
withEnv(
|
|
147
113
|
{
|
|
148
114
|
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
149
|
-
RUNTIME_PROXY_BEARER_TOKEN: "my-token",
|
|
150
|
-
VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
|
|
151
115
|
},
|
|
152
116
|
() => {
|
|
153
117
|
const config = loadConfig();
|
|
@@ -155,112 +119,6 @@ describe("config: runtime proxy flags", () => {
|
|
|
155
119
|
},
|
|
156
120
|
);
|
|
157
121
|
});
|
|
158
|
-
|
|
159
|
-
test("reads bearer token from http-token file when available", () => {
|
|
160
|
-
/** Verifies the gateway reads the daemon's http-token file for auth. */
|
|
161
|
-
withEnv(
|
|
162
|
-
{
|
|
163
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
164
|
-
VELLUM_HTTP_TOKEN_PATH: "/tmp/test-http-token",
|
|
165
|
-
},
|
|
166
|
-
() => {
|
|
167
|
-
// GIVEN an http-token file exists with a known token
|
|
168
|
-
writeFileSync("/tmp/test-http-token", "file-based-token\n");
|
|
169
|
-
|
|
170
|
-
// WHEN we load the config
|
|
171
|
-
const config = loadConfig();
|
|
172
|
-
|
|
173
|
-
// THEN the bearer token is read from the file (trimmed)
|
|
174
|
-
expect(config.runtimeProxyBearerToken).toBe("file-based-token");
|
|
175
|
-
|
|
176
|
-
// AND cleanup
|
|
177
|
-
unlinkSync("/tmp/test-http-token");
|
|
178
|
-
},
|
|
179
|
-
);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("env var takes precedence over http-token file", () => {
|
|
183
|
-
/** Verifies that the env var is preferred over the http-token file. */
|
|
184
|
-
withEnv(
|
|
185
|
-
{
|
|
186
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
187
|
-
RUNTIME_PROXY_BEARER_TOKEN: "env-token",
|
|
188
|
-
VELLUM_HTTP_TOKEN_PATH: "/tmp/test-http-token-priority",
|
|
189
|
-
},
|
|
190
|
-
() => {
|
|
191
|
-
// GIVEN an http-token file exists with a different token than the env var
|
|
192
|
-
writeFileSync("/tmp/test-http-token-priority", "file-token");
|
|
193
|
-
|
|
194
|
-
// WHEN we load the config
|
|
195
|
-
const config = loadConfig();
|
|
196
|
-
|
|
197
|
-
// THEN the env var token takes precedence
|
|
198
|
-
expect(config.runtimeProxyBearerToken).toBe("env-token");
|
|
199
|
-
|
|
200
|
-
// AND cleanup
|
|
201
|
-
unlinkSync("/tmp/test-http-token-priority");
|
|
202
|
-
},
|
|
203
|
-
);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test("falls back to env var when http-token file is missing", () => {
|
|
207
|
-
/** Verifies fallback to RUNTIME_PROXY_BEARER_TOKEN env var. */
|
|
208
|
-
withEnv(
|
|
209
|
-
{
|
|
210
|
-
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
|
|
211
|
-
RUNTIME_PROXY_BEARER_TOKEN: "env-fallback-token",
|
|
212
|
-
VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token",
|
|
213
|
-
},
|
|
214
|
-
() => {
|
|
215
|
-
// GIVEN the http-token file does not exist
|
|
216
|
-
// WHEN we load the config
|
|
217
|
-
const config = loadConfig();
|
|
218
|
-
|
|
219
|
-
// THEN the env var token is used as fallback
|
|
220
|
-
expect(config.runtimeProxyBearerToken).toBe("env-fallback-token");
|
|
221
|
-
},
|
|
222
|
-
);
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe("config: runtime bearer token", () => {
|
|
227
|
-
test("runtimeBearerToken is undefined when env is unset and http-token file is missing", () => {
|
|
228
|
-
withEnv({ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
|
|
229
|
-
const config = loadConfig();
|
|
230
|
-
expect(config.runtimeBearerToken).toBeUndefined();
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test("runtimeBearerToken is set from RUNTIME_BEARER_TOKEN env var", () => {
|
|
235
|
-
withEnv({ RUNTIME_BEARER_TOKEN: "rt-secret", VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
|
|
236
|
-
const config = loadConfig();
|
|
237
|
-
expect(config.runtimeBearerToken).toBe("rt-secret");
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("runtimeBearerToken is read from http-token file when env var is unset", () => {
|
|
242
|
-
withEnv({ VELLUM_HTTP_TOKEN_PATH: "/tmp/test-runtime-http-token" }, () => {
|
|
243
|
-
writeFileSync("/tmp/test-runtime-http-token", "runtime-file-token\n");
|
|
244
|
-
const config = loadConfig();
|
|
245
|
-
expect(config.runtimeBearerToken).toBe("runtime-file-token");
|
|
246
|
-
unlinkSync("/tmp/test-runtime-http-token");
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
test("RUNTIME_BEARER_TOKEN env var takes precedence over http-token file", () => {
|
|
251
|
-
withEnv(
|
|
252
|
-
{
|
|
253
|
-
RUNTIME_BEARER_TOKEN: "runtime-env-token",
|
|
254
|
-
VELLUM_HTTP_TOKEN_PATH: "/tmp/test-runtime-http-token-priority",
|
|
255
|
-
},
|
|
256
|
-
() => {
|
|
257
|
-
writeFileSync("/tmp/test-runtime-http-token-priority", "runtime-file-token");
|
|
258
|
-
const config = loadConfig();
|
|
259
|
-
expect(config.runtimeBearerToken).toBe("runtime-env-token");
|
|
260
|
-
unlinkSync("/tmp/test-runtime-http-token-priority");
|
|
261
|
-
},
|
|
262
|
-
);
|
|
263
|
-
});
|
|
264
122
|
});
|
|
265
123
|
|
|
266
124
|
describe("config: twilio assistant phone number mapping", () => {
|
|
@@ -22,11 +22,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
22
22
|
defaultAssistantId: undefined,
|
|
23
23
|
unmappedPolicy: "reject",
|
|
24
24
|
port: 7830,
|
|
25
|
-
runtimeBearerToken: "runtime-token",
|
|
26
|
-
runtimeGatewayOriginSecret: "gateway-origin",
|
|
27
25
|
runtimeProxyEnabled: false,
|
|
28
26
|
runtimeProxyRequireAuth: true,
|
|
29
|
-
runtimeProxyBearerToken: undefined,
|
|
30
27
|
shutdownDrainMs: 5000,
|
|
31
28
|
runtimeTimeoutMs: 30000,
|
|
32
29
|
runtimeMaxRetries: 2,
|
|
@@ -59,9 +56,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
59
56
|
trustProxy: false,
|
|
60
57
|
...overrides,
|
|
61
58
|
};
|
|
62
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
63
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
64
|
-
}
|
|
65
59
|
return merged;
|
|
66
60
|
}
|
|
67
61
|
|
|
@@ -22,11 +22,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
22
22
|
defaultAssistantId: undefined,
|
|
23
23
|
unmappedPolicy: "reject",
|
|
24
24
|
port: 7830,
|
|
25
|
-
runtimeBearerToken: "runtime-token",
|
|
26
|
-
runtimeGatewayOriginSecret: "gateway-origin",
|
|
27
25
|
runtimeProxyEnabled: false,
|
|
28
26
|
runtimeProxyRequireAuth: true,
|
|
29
|
-
runtimeProxyBearerToken: undefined,
|
|
30
27
|
shutdownDrainMs: 5000,
|
|
31
28
|
runtimeTimeoutMs: 30000,
|
|
32
29
|
runtimeMaxRetries: 2,
|
|
@@ -59,9 +56,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
59
56
|
trustProxy: false,
|
|
60
57
|
...overrides,
|
|
61
58
|
};
|
|
62
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
63
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
64
|
-
}
|
|
65
59
|
return merged;
|
|
66
60
|
}
|
|
67
61
|
|
|
@@ -12,11 +12,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
12
12
|
defaultAssistantId: undefined,
|
|
13
13
|
unmappedPolicy: "reject",
|
|
14
14
|
port: 7830,
|
|
15
|
-
runtimeBearerToken: undefined,
|
|
16
|
-
runtimeGatewayOriginSecret: undefined,
|
|
17
15
|
runtimeProxyEnabled: false,
|
|
18
16
|
runtimeProxyRequireAuth: true,
|
|
19
|
-
runtimeProxyBearerToken: undefined,
|
|
20
17
|
shutdownDrainMs: 5000,
|
|
21
18
|
runtimeTimeoutMs: 30000,
|
|
22
19
|
runtimeMaxRetries: 2,
|
|
@@ -49,9 +46,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
49
46
|
trustProxy: false,
|
|
50
47
|
...overrides,
|
|
51
48
|
};
|
|
52
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
53
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
54
|
-
}
|
|
55
49
|
return merged;
|
|
56
50
|
}
|
|
57
51
|
|
|
@@ -24,11 +24,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
24
24
|
defaultAssistantId: undefined,
|
|
25
25
|
unmappedPolicy: "reject",
|
|
26
26
|
port: 7830,
|
|
27
|
-
runtimeBearerToken: "rt-token",
|
|
28
|
-
runtimeGatewayOriginSecret: undefined,
|
|
29
27
|
runtimeProxyEnabled: false,
|
|
30
28
|
runtimeProxyRequireAuth: true,
|
|
31
|
-
runtimeProxyBearerToken: undefined,
|
|
32
29
|
shutdownDrainMs: 5000,
|
|
33
30
|
runtimeTimeoutMs: 30000,
|
|
34
31
|
runtimeMaxRetries: 2,
|
|
@@ -61,9 +58,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
61
58
|
trustProxy: false,
|
|
62
59
|
...overrides,
|
|
63
60
|
};
|
|
64
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
65
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
66
|
-
}
|
|
67
61
|
return merged;
|
|
68
62
|
}
|
|
69
63
|
|
|
@@ -329,7 +323,7 @@ describe("OAuth callback handler", () => {
|
|
|
329
323
|
);
|
|
330
324
|
|
|
331
325
|
const handler = createOAuthCallbackHandler(
|
|
332
|
-
makeConfig({
|
|
326
|
+
makeConfig({}),
|
|
333
327
|
);
|
|
334
328
|
const req = new Request(
|
|
335
329
|
`http://localhost:7830/webhooks/oauth/callback?state=${VALID_STATE}&code=c1`,
|
|
@@ -12,11 +12,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
12
12
|
defaultAssistantId: undefined,
|
|
13
13
|
unmappedPolicy: "reject",
|
|
14
14
|
port: 7830,
|
|
15
|
-
runtimeBearerToken: undefined,
|
|
16
|
-
runtimeGatewayOriginSecret: undefined,
|
|
17
15
|
runtimeProxyEnabled: false,
|
|
18
16
|
runtimeProxyRequireAuth: true,
|
|
19
|
-
runtimeProxyBearerToken: undefined,
|
|
20
17
|
shutdownDrainMs: 5000,
|
|
21
18
|
runtimeTimeoutMs: 30000,
|
|
22
19
|
runtimeMaxRetries: 2,
|
|
@@ -49,9 +46,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
49
46
|
trustProxy: false,
|
|
50
47
|
...overrides,
|
|
51
48
|
};
|
|
52
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
53
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
54
|
-
}
|
|
55
49
|
return merged;
|
|
56
50
|
}
|
|
57
51
|
|
|
@@ -27,11 +27,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
27
27
|
defaultAssistantId: undefined,
|
|
28
28
|
unmappedPolicy: "reject",
|
|
29
29
|
port: 7830,
|
|
30
|
-
runtimeBearerToken: undefined,
|
|
31
|
-
runtimeGatewayOriginSecret: undefined,
|
|
32
30
|
runtimeProxyEnabled: false,
|
|
33
31
|
runtimeProxyRequireAuth: true,
|
|
34
|
-
runtimeProxyBearerToken: undefined,
|
|
35
32
|
shutdownDrainMs: 5000,
|
|
36
33
|
runtimeTimeoutMs: 30000,
|
|
37
34
|
runtimeMaxRetries: 2,
|
|
@@ -64,9 +61,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
64
61
|
trustProxy: false,
|
|
65
62
|
...overrides,
|
|
66
63
|
};
|
|
67
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
68
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
69
|
-
}
|
|
70
64
|
return merged;
|
|
71
65
|
}
|
|
72
66
|
|
|
@@ -193,7 +187,7 @@ describe("forwardToRuntime", () => {
|
|
|
193
187
|
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
194
188
|
);
|
|
195
189
|
|
|
196
|
-
const config = makeConfig({
|
|
190
|
+
const config = makeConfig({});
|
|
197
191
|
await forwardToRuntime(config, payload);
|
|
198
192
|
|
|
199
193
|
const calledInit = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
|
|
@@ -270,7 +264,7 @@ describe("forwardTwilioVoiceWebhook", () => {
|
|
|
270
264
|
}),
|
|
271
265
|
);
|
|
272
266
|
|
|
273
|
-
const config = makeConfig({
|
|
267
|
+
const config = makeConfig({});
|
|
274
268
|
const params = { CallSid: "CA123", AccountSid: "AC456" };
|
|
275
269
|
const originalUrl = "https://example.com/webhooks/twilio/voice?callSessionId=sess-1";
|
|
276
270
|
|
|
@@ -300,7 +294,7 @@ describe("forwardTwilioStatusWebhook", () => {
|
|
|
300
294
|
test("sends params to runtime internal status endpoint", async () => {
|
|
301
295
|
fetchMock = mock(async () => new Response(null, { status: 200 }));
|
|
302
296
|
|
|
303
|
-
const config = makeConfig({
|
|
297
|
+
const config = makeConfig({});
|
|
304
298
|
const params = { CallSid: "CA123", CallStatus: "completed" };
|
|
305
299
|
|
|
306
300
|
const result = await forwardTwilioStatusWebhook(config, params);
|
|
@@ -329,7 +323,7 @@ describe("forwardTwilioConnectActionWebhook", () => {
|
|
|
329
323
|
}),
|
|
330
324
|
);
|
|
331
325
|
|
|
332
|
-
const config = makeConfig({
|
|
326
|
+
const config = makeConfig({});
|
|
333
327
|
const params = { CallSid: "CA123" };
|
|
334
328
|
|
|
335
329
|
const result = await forwardTwilioConnectActionWebhook(config, params);
|
|
@@ -22,11 +22,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
22
22
|
defaultAssistantId: undefined,
|
|
23
23
|
unmappedPolicy: "reject",
|
|
24
24
|
port: 7830,
|
|
25
|
-
runtimeBearerToken: "runtime-token",
|
|
26
|
-
runtimeGatewayOriginSecret: "gateway-origin",
|
|
27
25
|
runtimeProxyEnabled: false,
|
|
28
26
|
runtimeProxyRequireAuth: true,
|
|
29
|
-
runtimeProxyBearerToken: undefined,
|
|
30
27
|
shutdownDrainMs: 5000,
|
|
31
28
|
runtimeTimeoutMs: 30000,
|
|
32
29
|
runtimeMaxRetries: 2,
|
|
@@ -59,9 +56,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
59
56
|
trustProxy: false,
|
|
60
57
|
...overrides,
|
|
61
58
|
};
|
|
62
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
63
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
64
|
-
}
|
|
65
59
|
return merged;
|
|
66
60
|
}
|
|
67
61
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
1
|
+
import { describe, test, expect, mock, afterEach, beforeAll } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey, mintToken } from "../auth/token-service.js";
|
|
4
|
+
import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
|
|
3
5
|
|
|
4
6
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
7
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -10,7 +12,21 @@ mock.module("../fetch.js", () => ({
|
|
|
10
12
|
|
|
11
13
|
const { createRuntimeProxyHandler } = await import("../http/routes/runtime-proxy.js");
|
|
12
14
|
|
|
13
|
-
const
|
|
15
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
16
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
17
|
+
|
|
18
|
+
/** Mint a valid edge JWT (aud=vellum-gateway) for test requests. */
|
|
19
|
+
function mintEdgeToken(): string {
|
|
20
|
+
return mintToken({
|
|
21
|
+
aud: 'vellum-gateway',
|
|
22
|
+
sub: 'actor:test-assistant:test-user',
|
|
23
|
+
scope_profile: 'actor_client_v1',
|
|
24
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
25
|
+
ttlSeconds: 300,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TOKEN = mintEdgeToken();
|
|
14
30
|
|
|
15
31
|
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
16
32
|
const merged: GatewayConfig = {
|
|
@@ -22,11 +38,8 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
22
38
|
defaultAssistantId: undefined,
|
|
23
39
|
unmappedPolicy: "reject",
|
|
24
40
|
port: 7830,
|
|
25
|
-
runtimeBearerToken: undefined,
|
|
26
|
-
runtimeGatewayOriginSecret: undefined,
|
|
27
41
|
runtimeProxyEnabled: true,
|
|
28
42
|
runtimeProxyRequireAuth: true,
|
|
29
|
-
runtimeProxyBearerToken: TOKEN,
|
|
30
43
|
shutdownDrainMs: 5000,
|
|
31
44
|
runtimeTimeoutMs: 30000,
|
|
32
45
|
runtimeMaxRetries: 2,
|
|
@@ -59,9 +72,6 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
59
72
|
trustProxy: false,
|
|
60
73
|
...overrides,
|
|
61
74
|
};
|
|
62
|
-
if (merged.runtimeGatewayOriginSecret === undefined) {
|
|
63
|
-
merged.runtimeGatewayOriginSecret = merged.runtimeBearerToken;
|
|
64
|
-
}
|
|
65
75
|
return merged;
|
|
66
76
|
}
|
|
67
77
|
|
|
@@ -114,7 +124,7 @@ describe("runtime proxy auth enforcement", () => {
|
|
|
114
124
|
expect(body.ok).toBe(true);
|
|
115
125
|
});
|
|
116
126
|
|
|
117
|
-
test("auth required: replaces client
|
|
127
|
+
test("auth required: replaces client edge token with exchange token for upstream", async () => {
|
|
118
128
|
let capturedHeaders: Headers | undefined;
|
|
119
129
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
120
130
|
capturedHeaders = init?.headers as unknown as Headers;
|
|
@@ -130,13 +140,18 @@ describe("runtime proxy auth enforcement", () => {
|
|
|
130
140
|
});
|
|
131
141
|
await handler(req);
|
|
132
142
|
|
|
133
|
-
|
|
143
|
+
const upstreamAuth = capturedHeaders!.get("authorization");
|
|
144
|
+
expect(upstreamAuth).toBeTruthy();
|
|
145
|
+
// The upstream should receive an exchange token (aud=vellum-daemon),
|
|
146
|
+
// NOT the original edge token.
|
|
147
|
+
expect(upstreamAuth).toStartWith("Bearer ");
|
|
148
|
+
expect(upstreamAuth).not.toBe(`Bearer ${TOKEN}`);
|
|
134
149
|
});
|
|
135
150
|
|
|
136
151
|
test("auth not required: proxies without token", async () => {
|
|
137
152
|
mockUpstream();
|
|
138
153
|
const handler = createRuntimeProxyHandler(
|
|
139
|
-
makeConfig({ runtimeProxyRequireAuth: false
|
|
154
|
+
makeConfig({ runtimeProxyRequireAuth: false}),
|
|
140
155
|
);
|
|
141
156
|
const req = new Request("http://localhost:7830/v1/health");
|
|
142
157
|
const res = await handler(req);
|