@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.
Files changed (61) hide show
  1. package/ARCHITECTURE.md +15 -15
  2. package/package.json +1 -1
  3. package/src/__tests__/browser-relay-websocket.test.ts +6 -14
  4. package/src/__tests__/config.test.ts +3 -145
  5. package/src/__tests__/guardian-control-plane-proxy.test.ts +0 -6
  6. package/src/__tests__/ingress-control-plane-proxy.test.ts +0 -6
  7. package/src/__tests__/load-guards.test.ts +0 -6
  8. package/src/__tests__/oauth-callback.test.ts +1 -7
  9. package/src/__tests__/resolve-assistant.test.ts +0 -6
  10. package/src/__tests__/runtime-client.test.ts +4 -10
  11. package/src/__tests__/runtime-health-proxy.test.ts +0 -6
  12. package/src/__tests__/runtime-proxy-auth.test.ts +26 -11
  13. package/src/__tests__/runtime-proxy.test.ts +3 -9
  14. package/src/__tests__/slack-config.test.ts +0 -3
  15. package/src/__tests__/slack-deliver.test.ts +1 -7
  16. package/src/__tests__/slack-normalize.test.ts +0 -3
  17. package/src/__tests__/sms-ingress-guard.test.ts +0 -6
  18. package/src/__tests__/telegram-api-redaction.test.ts +0 -6
  19. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -6
  20. package/src/__tests__/telegram-deliver-auth.test.ts +7 -13
  21. package/src/__tests__/telegram-only-default.test.ts +2 -7
  22. package/src/__tests__/telegram-reconcile-route.test.ts +1 -7
  23. package/src/__tests__/telegram-send-attachments.test.ts +0 -6
  24. package/src/__tests__/telegram-webhook-handler.test.ts +0 -8
  25. package/src/__tests__/telegram-webhook-manager.test.ts +0 -6
  26. package/src/__tests__/twilio-relay-websocket.test.ts +30 -15
  27. package/src/__tests__/twilio-webhooks.test.ts +0 -6
  28. package/src/auth/policy.ts +17 -0
  29. package/src/auth/scopes.ts +64 -0
  30. package/src/auth/subject.ts +68 -0
  31. package/src/auth/token-exchange.ts +127 -0
  32. package/src/auth/token-service.ts +208 -0
  33. package/src/auth/types.ts +79 -0
  34. package/src/config.ts +2 -121
  35. package/src/http/middleware/deliver-auth.ts +15 -18
  36. package/src/http/routes/brain-graph-proxy.ts +2 -7
  37. package/src/http/routes/browser-relay-websocket.ts +17 -14
  38. package/src/http/routes/channel-readiness-proxy.ts +2 -7
  39. package/src/http/routes/guardian-control-plane-proxy.ts +4 -10
  40. package/src/http/routes/ingress-control-plane-proxy.ts +2 -7
  41. package/src/http/routes/pairing-proxy.ts +3 -4
  42. package/src/http/routes/runtime-health-proxy.ts +2 -7
  43. package/src/http/routes/runtime-proxy.ts +22 -28
  44. package/src/http/routes/sms-deliver.test.ts +12 -22
  45. package/src/http/routes/telegram-control-plane-proxy.ts +2 -7
  46. package/src/http/routes/telegram-deliver.test.ts +5 -11
  47. package/src/http/routes/telegram-reconcile.ts +8 -13
  48. package/src/http/routes/telegram-webhook.test.ts +0 -3
  49. package/src/http/routes/twilio-control-plane-proxy.ts +2 -7
  50. package/src/http/routes/twilio-relay-websocket.ts +21 -16
  51. package/src/http/routes/twilio-sms-webhook.test.ts +0 -3
  52. package/src/http/routes/twilio-voice-webhook.test.ts +0 -3
  53. package/src/http/routes/whatsapp-deliver.test.ts +3 -11
  54. package/src/http/routes/whatsapp-webhook.test.ts +0 -3
  55. package/src/index.ts +80 -190
  56. package/src/runtime/client.ts +21 -24
  57. package/src/telegram/send.test.ts +0 -3
  58. package/src/whatsapp/send.ts +22 -2
  59. package/src/__tests__/bearer-auth.test.ts +0 -40
  60. package/src/feature-flags-auth.test.ts +0 -286
  61. 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 token (the **feature-flag token**) stored at `~/.vellum/feature-flag-token`, separate from the **runtime token** (`~/.vellum/http-token`). This separation ensures that clients with feature-flag access cannot access runtime endpoints, and vice versa.
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` | Runtime bearer token OR feature-flag token |
61
- | `PATCH /v1/feature-flags/:key` | Feature-flag token ONLY (runtime token is explicitly rejected) |
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 actor + refresh token credentials. Clients must call this through the gateway; the runtime endpoint is not directly exposed. The gateway validates the caller's bearer token and forwards to the runtime, which handles refresh token validation, rotation, and replay detection (see [`assistant/ARCHITECTURE.md`](../assistant/ARCHITECTURE.md) for refresh token lifecycle details).
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 auth against the runtime token.
98
- - Gateway forwards requests to runtime with the runtime bearer token and `X-Gateway-Origin` proof header.
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 bearer-auth enforcement for `/v1/integrations/guardian/*` |
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 auth against the runtime token.
115
- - Gateway forwards the request to runtime with the runtime bearer token and `X-Gateway-Origin` proof header.
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 auth against the runtime token.
151
- - Gateway forwards requests to runtime with the runtime bearer token and `X-Gateway-Origin` proof header.
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 auth against the runtime token.
187
- - Gateway forwards requests to runtime with the runtime bearer token and `X-Gateway-Origin` proof header.
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 auth against the runtime token.
211
- - Gateway forwards requests to runtime with the runtime bearer token and `X-Gateway-Origin` proof header.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -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({ runtimeProxyBearerToken: TEST_TOKEN });
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({ runtimeProxyBearerToken: TEST_TOKEN });
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, runtimeProxyBearerToken: undefined });
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({ runtimeProxyBearerToken: TEST_TOKEN });
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({ runtimeProxyBearerToken: TEST_TOKEN });
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({ runtimeProxyBearerToken: TEST_TOKEN });
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({ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
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 and valid token", () => {
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 enabled with auth required but no token throws", () => {
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({ runtimeBearerToken: undefined }),
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({ runtimeBearerToken: "my-secret-token" });
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({ runtimeBearerToken: "rt-tok" });
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({ runtimeBearerToken: "rt-tok" });
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({ runtimeBearerToken: "rt-tok" });
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 TOKEN = "test-secret-token";
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 authorization with configured bearer token for upstream", async () => {
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
- expect(capturedHeaders!.get("authorization")).toBe(`Bearer ${TOKEN}`);
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, runtimeProxyBearerToken: undefined }),
154
+ makeConfig({ runtimeProxyRequireAuth: false}),
140
155
  );
141
156
  const req = new Request("http://localhost:7830/v1/health");
142
157
  const res = await handler(req);