@vellumai/vellum-gateway 0.4.16 → 0.4.18
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 +7 -7
- package/README.md +2 -3
- package/package.json +1 -1
- package/src/__tests__/browser-relay-websocket.test.ts +19 -2
- package/src/__tests__/config.test.ts +1 -1
- package/src/__tests__/guardian-control-plane-proxy.test.ts +6 -3
- package/src/__tests__/ingress-control-plane-proxy.test.ts +6 -3
- package/src/__tests__/oauth-callback.test.ts +7 -3
- package/src/__tests__/runtime-client.test.ts +7 -16
- package/src/__tests__/runtime-health-proxy.test.ts +6 -3
- package/src/__tests__/runtime-proxy-auth.test.ts +1 -1
- package/src/__tests__/runtime-proxy.test.ts +9 -48
- package/src/__tests__/slack-deliver.test.ts +0 -2
- package/src/__tests__/telegram-control-plane-proxy.test.ts +6 -3
- package/src/__tests__/telegram-deliver-auth.test.ts +17 -34
- package/src/__tests__/telegram-reconcile-route.test.ts +26 -17
- package/src/__tests__/telegram-send-attachments.test.ts +4 -0
- package/src/__tests__/telegram-webhook-handler.test.ts +4 -38
- package/src/__tests__/twilio-webhooks.test.ts +4 -0
- package/src/auth/scopes.ts +3 -0
- package/src/auth/types.ts +2 -1
- package/src/http/routes/sms-deliver.test.ts +17 -12
- package/src/http/routes/telegram-deliver.test.ts +17 -12
- package/src/http/routes/whatsapp-deliver.test.ts +20 -17
- package/src/index.ts +2 -2
package/ARCHITECTURE.md
CHANGED
|
@@ -326,7 +326,7 @@ Runtime detects needs_confirmation
|
|
|
326
326
|
|
|
327
327
|
**Proactive expiry sweep:** The runtime runs a periodic sweep every 60 seconds (`sweepExpiredGuardianApprovals`) that finds guardian approval requests past the 30-minute TTL, auto-denies the underlying runs, and notifies both the requester and guardian via the gateway's per-channel `/deliver/<channel>` endpoint. This ensures expired approvals are closed without waiting for follow-up traffic from either party. The sweep is started automatically whenever a run orchestrator is available.
|
|
328
328
|
|
|
329
|
-
**Gateway-origin ingress contract:** The
|
|
329
|
+
**Gateway-origin ingress contract:** The JWT token exchanged during gateway-to-runtime authentication proves gateway origin (via the `aud=vellum-daemon` claim). No separate header is required.
|
|
330
330
|
|
|
331
331
|
**Key modules:**
|
|
332
332
|
|
|
@@ -395,8 +395,8 @@ sequenceDiagram
|
|
|
395
395
|
User->>TG: <replies with code>
|
|
396
396
|
TG->>GW: POST /webhooks/telegram (webhook secret validated)
|
|
397
397
|
GW->>GW: Verify webhook secret, normalize update
|
|
398
|
-
GW->>Daemon: POST /v1/channels/inbound (
|
|
399
|
-
Daemon->>Daemon: Verify
|
|
398
|
+
GW->>Daemon: POST /v1/channels/inbound (JWT auth)
|
|
399
|
+
Daemon->>Daemon: Verify JWT auth
|
|
400
400
|
Daemon->>Daemon: Hash secret, find pending challenge, validate expiry
|
|
401
401
|
Daemon->>Daemon: Consume challenge (replay prevention)
|
|
402
402
|
Daemon->>Daemon: Revoke existing binding (if any)
|
|
@@ -413,8 +413,8 @@ The channel inbound handler (`inbound-message-handler.ts`) evaluates incoming me
|
|
|
413
413
|
|
|
414
414
|
```mermaid
|
|
415
415
|
flowchart TD
|
|
416
|
-
MSG["Inbound message arrives<br/>POST /channels/inbound"] --> GW_CHECK{"
|
|
417
|
-
GW_CHECK -- No --> REJECT_403["403
|
|
416
|
+
MSG["Inbound message arrives<br/>POST /channels/inbound"] --> GW_CHECK{"JWT auth<br/>valid?"}
|
|
417
|
+
GW_CHECK -- No --> REJECT_403["403 Unauthorized"]
|
|
418
418
|
GW_CHECK -- Yes --> HAS_SENDER{"actorExternalId<br/>present?"}
|
|
419
419
|
|
|
420
420
|
HAS_SENDER -- Yes --> ACL_LOOKUP["Look up ingress member<br/>by (channel, userId/chatId)"]
|
|
@@ -465,7 +465,7 @@ sequenceDiagram
|
|
|
465
465
|
|
|
466
466
|
NG->>TG: Message triggers tool use
|
|
467
467
|
TG->>GW: POST /webhooks/telegram
|
|
468
|
-
GW->>Daemon: POST /v1/channels/inbound (
|
|
468
|
+
GW->>Daemon: POST /v1/channels/inbound (JWT auth)
|
|
469
469
|
Daemon->>Daemon: Detect non-guardian, set forceStrictSideEffects
|
|
470
470
|
Daemon->>Daemon: Tool needs confirmation → create GuardianApprovalRequest
|
|
471
471
|
Daemon->>GW: POST /deliver/telegram (approval prompt + inline keyboard)
|
|
@@ -474,7 +474,7 @@ sequenceDiagram
|
|
|
474
474
|
GW-->>NG: "Waiting for guardian approval..."
|
|
475
475
|
Guardian->>TG: Approve / Deny (callback_query or text)
|
|
476
476
|
TG->>GW: POST /webhooks/telegram (callback_query)
|
|
477
|
-
GW->>Daemon: POST /v1/channels/inbound (
|
|
477
|
+
GW->>Daemon: POST /v1/channels/inbound (JWT auth)
|
|
478
478
|
Daemon->>Daemon: Validate guardian identity, update approval decision
|
|
479
479
|
Daemon->>Daemon: Apply decision to pending run
|
|
480
480
|
Daemon->>GW: POST /deliver/telegram (outcome notification)
|
package/README.md
CHANGED
|
@@ -41,7 +41,6 @@ bun run dev
|
|
|
41
41
|
| `GATEWAY_RUNTIME_PROXY_ENABLED` | No | `false` | Enable runtime proxy for non-Telegram requests |
|
|
42
42
|
| `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH` | No | `true` | Require bearer auth for proxied requests |
|
|
43
43
|
| `RUNTIME_BEARER_TOKEN` | No | `~/.vellum/http-token` (if present) | Bearer token used by gateway when forwarding requests to assistant runtime internal endpoints (Twilio/OAuth/proxy upstream). |
|
|
44
|
-
| `RUNTIME_GATEWAY_ORIGIN_SECRET` | No | Falls back to `RUNTIME_BEARER_TOKEN` | Dedicated secret sent as the `X-Gateway-Origin` header on `/channels/inbound` requests to prove gateway origin. When not set, the gateway falls back to sending `RUNTIME_BEARER_TOKEN` as the origin proof. Both gateway and runtime must share the same value. |
|
|
45
44
|
| `RUNTIME_PROXY_BEARER_TOKEN` | Conditional | — | Bearer token for proxy auth (required when proxy + auth enabled) |
|
|
46
45
|
| `GATEWAY_SHUTDOWN_DRAIN_MS` | No | `5000` | Graceful shutdown drain window in milliseconds |
|
|
47
46
|
| `GATEWAY_RUNTIME_TIMEOUT_MS` | No | `30000` | Timeout for runtime HTTP calls (ms) |
|
|
@@ -291,7 +290,7 @@ The gateway is the **sole public ingress point** for all external webhooks, incl
|
|
|
291
290
|
Inbound SMS follows the same gateway-only pattern as voice and Telegram:
|
|
292
291
|
|
|
293
292
|
1. **Twilio → Gateway** (`/webhooks/twilio/sms`) — Gateway validates `X-Twilio-Signature` using HMAC-SHA1 with the configured `TWILIO_AUTH_TOKEN`.
|
|
294
|
-
2. **Gateway → Runtime** (`/v1/channels/inbound`) — Gateway forwards the normalized event to the runtime with
|
|
293
|
+
2. **Gateway → Runtime** (`/v1/channels/inbound`) — Gateway forwards the normalized event to the runtime with JWT bearer auth.
|
|
295
294
|
3. **Runtime rejects direct SMS webhooks** — Any direct POST to `/webhooks/twilio/sms` or `/v1/calls/twilio/sms` on the runtime returns `410 GATEWAY_ONLY`.
|
|
296
295
|
|
|
297
296
|
### Signature URL Tightening
|
|
@@ -413,7 +412,7 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
|
|
|
413
412
|
| "No route configured" replies | Add a routing entry or set `GATEWAY_UNMAPPED_POLICY=default` with a default assistant |
|
|
414
413
|
| Runtime errors | Is `ASSISTANT_RUNTIME_BASE_URL` reachable? Check runtime logs. |
|
|
415
414
|
| No reply from assistant | Is the assistant runtime processing messages? Check for `RUNTIME_HTTP_PORT` env var. |
|
|
416
|
-
| 403
|
|
415
|
+
| 403 on channel inbound | The runtime rejected the request because JWT authentication failed. Ensure the gateway and runtime share the same signing key (`~/.vellum/protected/actor-token-signing-key`). |
|
|
417
416
|
|
|
418
417
|
### Guardian-Specific Troubleshooting
|
|
419
418
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { describe, test, expect, mock, beforeEach, afterAll } 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
|
import {
|
|
4
6
|
createBrowserRelayWebsocketHandler,
|
|
5
7
|
getBrowserRelayWebsocketHandlers,
|
|
6
8
|
} from "../http/routes/browser-relay-websocket.js";
|
|
7
9
|
|
|
10
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
11
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
12
|
+
|
|
13
|
+
/** Mint a valid edge JWT for browser relay auth. */
|
|
14
|
+
function mintEdgeToken(): string {
|
|
15
|
+
return mintToken({
|
|
16
|
+
aud: 'vellum-gateway',
|
|
17
|
+
sub: 'actor:test-assistant:test-user',
|
|
18
|
+
scope_profile: 'actor_client_v1',
|
|
19
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
20
|
+
ttlSeconds: 300,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
const WS_CONNECTING = WebSocket.CONNECTING; // 0
|
|
9
25
|
const WS_OPEN = WebSocket.OPEN; // 1
|
|
10
26
|
const WS_CLOSED = WebSocket.CLOSED; // 3
|
|
@@ -95,7 +111,7 @@ function createFakeUpstreamWs() {
|
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
describe("createBrowserRelayWebsocketHandler", () => {
|
|
98
|
-
const TEST_TOKEN =
|
|
114
|
+
const TEST_TOKEN = mintEdgeToken();
|
|
99
115
|
|
|
100
116
|
test("upgrades when token query parameter is valid", () => {
|
|
101
117
|
const config = makeConfig({});
|
|
@@ -238,7 +254,8 @@ describe("getBrowserRelayWebsocketHandlers", () => {
|
|
|
238
254
|
handlers.message(ws as never, "hello-before-open");
|
|
239
255
|
|
|
240
256
|
const MockWS = globalThis.WebSocket as unknown as ReturnType<typeof mock>;
|
|
241
|
-
|
|
257
|
+
const calledUrl = (MockWS.mock.calls[0] as unknown[])[0] as string;
|
|
258
|
+
expect(calledUrl).toMatch(/^ws:\/\/runtime\.internal:7821\/v1\/browser-relay\?token=ey/);
|
|
242
259
|
|
|
243
260
|
fakeUpstream.readyState = WS_OPEN;
|
|
244
261
|
fakeUpstream.emit("open");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { describe, test, expect } from "bun:test";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -101,7 +105,7 @@ describe("guardian control-plane proxy", () => {
|
|
|
101
105
|
]);
|
|
102
106
|
});
|
|
103
107
|
|
|
104
|
-
test("replaces caller auth with runtime auth
|
|
108
|
+
test("replaces caller auth with runtime auth", async () => {
|
|
105
109
|
let capturedHeaders: Headers | undefined;
|
|
106
110
|
let capturedBody = "";
|
|
107
111
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
@@ -127,8 +131,7 @@ describe("guardian control-plane proxy", () => {
|
|
|
127
131
|
|
|
128
132
|
expect(res.status).toBe(200);
|
|
129
133
|
expect(capturedBody).toBe('{"channel":"voice","destination":"+15551234567"}');
|
|
130
|
-
expect(capturedHeaders?.get("authorization")).
|
|
131
|
-
expect(capturedHeaders?.get("X-Gateway-Origin")).toBe("gateway-origin");
|
|
134
|
+
expect(capturedHeaders?.get("authorization")).toMatch(/^Bearer ey/);
|
|
132
135
|
expect(capturedHeaders?.has("host")).toBe(false);
|
|
133
136
|
});
|
|
134
137
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -116,7 +120,7 @@ describe("ingress control-plane proxy", () => {
|
|
|
116
120
|
]);
|
|
117
121
|
});
|
|
118
122
|
|
|
119
|
-
test("replaces caller auth with runtime auth
|
|
123
|
+
test("replaces caller auth with runtime auth", async () => {
|
|
120
124
|
let capturedHeaders: Headers | undefined;
|
|
121
125
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
122
126
|
capturedHeaders = init?.headers as unknown as Headers;
|
|
@@ -136,8 +140,7 @@ describe("ingress control-plane proxy", () => {
|
|
|
136
140
|
);
|
|
137
141
|
|
|
138
142
|
expect(res.status).toBe(200);
|
|
139
|
-
expect(capturedHeaders?.get("authorization")).
|
|
140
|
-
expect(capturedHeaders?.get("X-Gateway-Origin")).toBe("gateway-origin");
|
|
143
|
+
expect(capturedHeaders?.get("authorization")).toMatch(/^Bearer ey/);
|
|
141
144
|
expect(capturedHeaders?.has("host")).toBe(false);
|
|
142
145
|
});
|
|
143
146
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -96,7 +100,7 @@ describe("OAuth callback handler", () => {
|
|
|
96
100
|
expect(sentBody.error).toBeUndefined();
|
|
97
101
|
|
|
98
102
|
const headers = calledInit.headers as Record<string, string>;
|
|
99
|
-
expect(headers["Authorization"]).
|
|
103
|
+
expect(headers["Authorization"]).toMatch(/^Bearer ey/);
|
|
100
104
|
});
|
|
101
105
|
|
|
102
106
|
test("missing state parameter returns 400 error page", async () => {
|
|
@@ -317,7 +321,7 @@ describe("OAuth callback handler", () => {
|
|
|
317
321
|
expect(fetchMock.mock.calls.length).toBe(MAX_CONSUMED_STATES + 1);
|
|
318
322
|
});
|
|
319
323
|
|
|
320
|
-
test("
|
|
324
|
+
test("always sends JWT Authorization header to runtime", async () => {
|
|
321
325
|
fetchMock = mock(async () =>
|
|
322
326
|
Response.json({ ok: true }, { status: 200 }),
|
|
323
327
|
);
|
|
@@ -334,6 +338,6 @@ describe("OAuth callback handler", () => {
|
|
|
334
338
|
|
|
335
339
|
const calledInit = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
|
|
336
340
|
const headers = calledInit.headers as Record<string, string>;
|
|
337
|
-
expect(headers["Authorization"]).
|
|
341
|
+
expect(headers["Authorization"]).toMatch(/^Bearer ey/);
|
|
338
342
|
});
|
|
339
343
|
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { RuntimeAttachmentMeta, RuntimeInboundPayload } from "../runtime/client.js";
|
|
3
3
|
import type { GatewayConfig } from "../config.js";
|
|
4
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
4
8
|
|
|
5
9
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
6
10
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -182,7 +186,7 @@ describe("forwardToRuntime", () => {
|
|
|
182
186
|
expect(attachments[0].kind).toBe("generated_image");
|
|
183
187
|
});
|
|
184
188
|
|
|
185
|
-
test("sends Authorization header
|
|
189
|
+
test("sends JWT Authorization header to runtime", async () => {
|
|
186
190
|
fetchMock = mock(async () =>
|
|
187
191
|
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
188
192
|
);
|
|
@@ -192,20 +196,7 @@ describe("forwardToRuntime", () => {
|
|
|
192
196
|
|
|
193
197
|
const calledInit = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
|
|
194
198
|
const headers = calledInit.headers as Record<string, string>;
|
|
195
|
-
expect(headers["Authorization"]).
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
test("omits Authorization header when runtimeBearerToken is undefined", async () => {
|
|
199
|
-
fetchMock = mock(async () =>
|
|
200
|
-
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
const config = makeConfig();
|
|
204
|
-
await forwardToRuntime(config, payload);
|
|
205
|
-
|
|
206
|
-
const calledInit = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
|
|
207
|
-
const headers = calledInit.headers as Record<string, string>;
|
|
208
|
-
expect(headers["Authorization"]).toBeUndefined();
|
|
199
|
+
expect(headers["Authorization"]).toMatch(/^Bearer ey/);
|
|
209
200
|
});
|
|
210
201
|
});
|
|
211
202
|
|
|
@@ -282,7 +273,7 @@ describe("forwardTwilioVoiceWebhook", () => {
|
|
|
282
273
|
expect(sentBody.originalUrl).toBe(originalUrl);
|
|
283
274
|
|
|
284
275
|
const headers = calledInit.headers as Record<string, string>;
|
|
285
|
-
expect(headers["Authorization"]).
|
|
276
|
+
expect(headers["Authorization"]).toMatch(/^Bearer ey/);
|
|
286
277
|
});
|
|
287
278
|
});
|
|
288
279
|
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -84,7 +88,7 @@ describe("runtime health proxy", () => {
|
|
|
84
88
|
expect(await res.json()).toEqual({ status: "healthy" });
|
|
85
89
|
});
|
|
86
90
|
|
|
87
|
-
test("replaces caller auth with runtime auth
|
|
91
|
+
test("replaces caller auth with runtime auth", async () => {
|
|
88
92
|
let capturedHeaders: Headers | undefined;
|
|
89
93
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
90
94
|
capturedHeaders = init?.headers as unknown as Headers;
|
|
@@ -102,8 +106,7 @@ describe("runtime health proxy", () => {
|
|
|
102
106
|
);
|
|
103
107
|
|
|
104
108
|
expect(res.status).toBe(200);
|
|
105
|
-
expect(capturedHeaders?.get("authorization")).
|
|
106
|
-
expect(capturedHeaders?.get("X-Gateway-Origin")).toBe("gateway-origin");
|
|
109
|
+
expect(capturedHeaders?.get("authorization")).toMatch(/^Bearer ey/);
|
|
107
110
|
expect(capturedHeaders?.has("host")).toBe(false);
|
|
108
111
|
});
|
|
109
112
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect, mock, afterEach
|
|
1
|
+
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
3
|
import { initSigningKey, mintToken } from "../auth/token-service.js";
|
|
4
4
|
import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -223,7 +227,7 @@ describe("runtime proxy handler", () => {
|
|
|
223
227
|
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
|
224
228
|
});
|
|
225
229
|
|
|
226
|
-
test("
|
|
230
|
+
test("replaces client authorization with JWT service token when auth is not required", async () => {
|
|
227
231
|
let capturedHeaders: Headers | undefined;
|
|
228
232
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
229
233
|
capturedHeaders = init?.headers as unknown as Headers;
|
|
@@ -236,10 +240,11 @@ describe("runtime proxy handler", () => {
|
|
|
236
240
|
});
|
|
237
241
|
await handler(req);
|
|
238
242
|
|
|
239
|
-
|
|
243
|
+
// When auth is not required, gateway still mints a JWT service token for the runtime
|
|
244
|
+
expect(capturedHeaders!.get("authorization")).toMatch(/^Bearer ey/);
|
|
240
245
|
});
|
|
241
246
|
|
|
242
|
-
test("replaces client authorization with
|
|
247
|
+
test("replaces client authorization with JWT token for upstream", async () => {
|
|
243
248
|
let capturedHeaders: Headers | undefined;
|
|
244
249
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
245
250
|
capturedHeaders = init?.headers as unknown as Headers;
|
|
@@ -254,7 +259,7 @@ describe("runtime proxy handler", () => {
|
|
|
254
259
|
});
|
|
255
260
|
await handler(req);
|
|
256
261
|
|
|
257
|
-
expect(capturedHeaders!.get("authorization")).
|
|
262
|
+
expect(capturedHeaders!.get("authorization")).toMatch(/^Bearer ey/);
|
|
258
263
|
});
|
|
259
264
|
|
|
260
265
|
test("truncates long upstream error bodies in logs", async () => {
|
|
@@ -397,48 +402,4 @@ describe("runtime proxy handler", () => {
|
|
|
397
402
|
expect(fetchCalls.length).toBe(1);
|
|
398
403
|
});
|
|
399
404
|
});
|
|
400
|
-
|
|
401
|
-
// ── Gateway-origin header on proxied requests ────────────────────────
|
|
402
|
-
|
|
403
|
-
describe("gateway-origin header", () => {
|
|
404
|
-
test("sets X-Gateway-Origin header when runtimeBearerToken is configured", async () => {
|
|
405
|
-
let capturedHeaders: Headers | undefined;
|
|
406
|
-
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
407
|
-
capturedHeaders = init?.headers as unknown as Headers;
|
|
408
|
-
return new Response("ok", { status: 200 });
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
const handler = createRuntimeProxyHandler(
|
|
412
|
-
makeConfig({}),
|
|
413
|
-
);
|
|
414
|
-
const req = new Request("http://localhost:7830/v1/channels/inbound", {
|
|
415
|
-
method: "POST",
|
|
416
|
-
headers: { "content-type": "application/json" },
|
|
417
|
-
body: JSON.stringify({ message: "hello" }),
|
|
418
|
-
});
|
|
419
|
-
await handler(req);
|
|
420
|
-
|
|
421
|
-
expect(capturedHeaders!.get("x-gateway-origin")).toBe("runtime-secret");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test("does not set X-Gateway-Origin header when no runtimeBearerToken", async () => {
|
|
425
|
-
let capturedHeaders: Headers | undefined;
|
|
426
|
-
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
427
|
-
capturedHeaders = init?.headers as unknown as Headers;
|
|
428
|
-
return new Response("ok", { status: 200 });
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
const handler = createRuntimeProxyHandler(
|
|
432
|
-
makeConfig({}),
|
|
433
|
-
);
|
|
434
|
-
const req = new Request("http://localhost:7830/v1/channels/inbound", {
|
|
435
|
-
method: "POST",
|
|
436
|
-
headers: { "content-type": "application/json" },
|
|
437
|
-
body: JSON.stringify({ message: "hello" }),
|
|
438
|
-
});
|
|
439
|
-
await handler(req);
|
|
440
|
-
|
|
441
|
-
expect(capturedHeaders!.has("x-gateway-origin")).toBe(false);
|
|
442
|
-
});
|
|
443
|
-
});
|
|
444
405
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -101,7 +105,7 @@ describe("telegram control-plane proxy", () => {
|
|
|
101
105
|
]);
|
|
102
106
|
});
|
|
103
107
|
|
|
104
|
-
test("replaces caller auth with runtime auth
|
|
108
|
+
test("replaces caller auth with runtime auth", async () => {
|
|
105
109
|
let capturedHeaders: Headers | undefined;
|
|
106
110
|
fetchMock = mock(async (_input: string | URL | Request, init?: RequestInit) => {
|
|
107
111
|
capturedHeaders = init?.headers as unknown as Headers;
|
|
@@ -122,8 +126,7 @@ describe("telegram control-plane proxy", () => {
|
|
|
122
126
|
);
|
|
123
127
|
|
|
124
128
|
expect(res.status).toBe(200);
|
|
125
|
-
expect(capturedHeaders?.get("authorization")).
|
|
126
|
-
expect(capturedHeaders?.get("X-Gateway-Origin")).toBe("gateway-origin");
|
|
129
|
+
expect(capturedHeaders?.get("authorization")).toMatch(/^Bearer ey/);
|
|
127
130
|
expect(capturedHeaders?.has("host")).toBe(false);
|
|
128
131
|
});
|
|
129
132
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } 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";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
8
|
|
|
4
9
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
10
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -10,7 +15,18 @@ mock.module("../fetch.js", () => ({
|
|
|
10
15
|
|
|
11
16
|
const { createTelegramDeliverHandler } = await import("../http/routes/telegram-deliver.js");
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
/** Mint a valid daemon JWT for deliver auth. */
|
|
19
|
+
function mintDeliverToken(): string {
|
|
20
|
+
return mintToken({
|
|
21
|
+
aud: 'vellum-daemon',
|
|
22
|
+
sub: 'svc:gateway:self',
|
|
23
|
+
scope_profile: 'gateway_service_v1',
|
|
24
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
25
|
+
ttlSeconds: 300,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TOKEN = mintDeliverToken();
|
|
14
30
|
|
|
15
31
|
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
16
32
|
const merged: GatewayConfig = {
|
|
@@ -354,22 +370,6 @@ describe("/deliver/telegram bearer auth enforcement", () => {
|
|
|
354
370
|
expect(body.ok).toBe(true);
|
|
355
371
|
});
|
|
356
372
|
|
|
357
|
-
test("returns 503 when no token is configured and bypass is not set", async () => {
|
|
358
|
-
const handler = createTelegramDeliverHandler(
|
|
359
|
-
makeConfig({}),
|
|
360
|
-
);
|
|
361
|
-
const req = new Request("http://localhost:7830/deliver/telegram", {
|
|
362
|
-
method: "POST",
|
|
363
|
-
headers: { "content-type": "application/json" },
|
|
364
|
-
body: JSON.stringify({ chatId: "123", text: "hello" }),
|
|
365
|
-
});
|
|
366
|
-
const res = await handler(req);
|
|
367
|
-
|
|
368
|
-
expect(res.status).toBe(503);
|
|
369
|
-
const body = await res.json();
|
|
370
|
-
expect(body.error).toBe("Service not configured: bearer token required");
|
|
371
|
-
});
|
|
372
|
-
|
|
373
373
|
test("allows unauthenticated access when bypass flag is set and no token configured", async () => {
|
|
374
374
|
mockTelegramApi();
|
|
375
375
|
const handler = createTelegramDeliverHandler(
|
|
@@ -387,23 +387,6 @@ describe("/deliver/telegram bearer auth enforcement", () => {
|
|
|
387
387
|
expect(body.ok).toBe(true);
|
|
388
388
|
});
|
|
389
389
|
|
|
390
|
-
test("bypass flag is ignored when a bearer token is configured (auth still required)", async () => {
|
|
391
|
-
const handler = createTelegramDeliverHandler(
|
|
392
|
-
makeConfig({ telegramDeliverAuthBypass: true }),
|
|
393
|
-
);
|
|
394
|
-
const req = new Request("http://localhost:7830/deliver/telegram", {
|
|
395
|
-
method: "POST",
|
|
396
|
-
headers: { "content-type": "application/json" },
|
|
397
|
-
body: JSON.stringify({ chatId: "123", text: "hello" }),
|
|
398
|
-
});
|
|
399
|
-
const res = await handler(req);
|
|
400
|
-
|
|
401
|
-
// Token is configured, so missing Authorization header is still rejected
|
|
402
|
-
expect(res.status).toBe(401);
|
|
403
|
-
const body = await res.json();
|
|
404
|
-
expect(body.error).toBe("Unauthorized");
|
|
405
|
-
});
|
|
406
|
-
|
|
407
390
|
test("still rejects non-POST methods before auth check", async () => {
|
|
408
391
|
const handler = createTelegramDeliverHandler(makeConfig());
|
|
409
392
|
const req = new Request("http://localhost:7830/deliver/telegram", {
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach, mock } 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";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
8
|
+
|
|
9
|
+
/** Mint a valid daemon JWT for reconcile auth. */
|
|
10
|
+
function mintDaemonToken(): string {
|
|
11
|
+
return mintToken({
|
|
12
|
+
aud: 'vellum-daemon',
|
|
13
|
+
sub: 'svc:gateway:self',
|
|
14
|
+
scope_profile: 'gateway_service_v1',
|
|
15
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
16
|
+
ttlSeconds: 300,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TOKEN = mintDaemonToken();
|
|
3
21
|
|
|
4
22
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
23
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -125,15 +143,6 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
125
143
|
expect(res.status).toBe(405);
|
|
126
144
|
});
|
|
127
145
|
|
|
128
|
-
test("returns 503 when no bearer token is configured", async () => {
|
|
129
|
-
const config = makeConfig({});
|
|
130
|
-
const handler = createTelegramReconcileHandler(config);
|
|
131
|
-
const res = await handler(makeRequest("POST", "any-token"));
|
|
132
|
-
expect(res.status).toBe(503);
|
|
133
|
-
const body = await res.json();
|
|
134
|
-
expect(body.error).toContain("bearer token required");
|
|
135
|
-
});
|
|
136
|
-
|
|
137
146
|
test("returns 401 for missing auth header", async () => {
|
|
138
147
|
const config = makeConfig();
|
|
139
148
|
const handler = createTelegramReconcileHandler(config);
|
|
@@ -151,7 +160,7 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
151
160
|
test("triggers reconcile with correct auth", async () => {
|
|
152
161
|
const config = makeConfig();
|
|
153
162
|
const handler = createTelegramReconcileHandler(config);
|
|
154
|
-
const res = await handler(makeRequest("POST",
|
|
163
|
+
const res = await handler(makeRequest("POST", TOKEN));
|
|
155
164
|
expect(res.status).toBe(200);
|
|
156
165
|
const body = await res.json();
|
|
157
166
|
expect(body.ok).toBe(true);
|
|
@@ -163,7 +172,7 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
163
172
|
const config = makeConfig({ ingressPublicBaseUrl: "https://old.example.com" });
|
|
164
173
|
const handler = createTelegramReconcileHandler(config);
|
|
165
174
|
const res = await handler(
|
|
166
|
-
makeRequest("POST",
|
|
175
|
+
makeRequest("POST", TOKEN, {
|
|
167
176
|
ingressPublicBaseUrl: "https://new.example.com/",
|
|
168
177
|
}),
|
|
169
178
|
);
|
|
@@ -176,7 +185,7 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
176
185
|
const config = makeConfig({ ingressPublicBaseUrl: "https://old.example.com" });
|
|
177
186
|
const handler = createTelegramReconcileHandler(config);
|
|
178
187
|
const res = await handler(
|
|
179
|
-
makeRequest("POST",
|
|
188
|
+
makeRequest("POST", TOKEN, {
|
|
180
189
|
ingressPublicBaseUrl: "",
|
|
181
190
|
}),
|
|
182
191
|
);
|
|
@@ -190,7 +199,7 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
190
199
|
const req = new Request("http://localhost:7830/internal/telegram/reconcile", {
|
|
191
200
|
method: "POST",
|
|
192
201
|
headers: {
|
|
193
|
-
authorization:
|
|
202
|
+
authorization: `Bearer ${TOKEN}`,
|
|
194
203
|
},
|
|
195
204
|
});
|
|
196
205
|
const res = await handler(req);
|
|
@@ -204,7 +213,7 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
204
213
|
});
|
|
205
214
|
const config = makeConfig();
|
|
206
215
|
const handler = createTelegramReconcileHandler(config);
|
|
207
|
-
const res = await handler(makeRequest("POST",
|
|
216
|
+
const res = await handler(makeRequest("POST", TOKEN));
|
|
208
217
|
expect(res.status).toBe(502);
|
|
209
218
|
const body = await res.json();
|
|
210
219
|
expect(body.error).toBe("Reconciliation failed");
|
|
@@ -216,7 +225,7 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
216
225
|
const req = new Request("http://localhost:7830/internal/telegram/reconcile", {
|
|
217
226
|
method: "POST",
|
|
218
227
|
headers: {
|
|
219
|
-
authorization:
|
|
228
|
+
authorization: `Bearer ${TOKEN}`,
|
|
220
229
|
"content-type": "application/json",
|
|
221
230
|
},
|
|
222
231
|
body: "not-json{",
|
|
@@ -263,12 +272,12 @@ describe("POST /internal/telegram/reconcile", () => {
|
|
|
263
272
|
// is still running, leaving Telegram pointed at the first URL.
|
|
264
273
|
const [res1, res2] = await Promise.all([
|
|
265
274
|
handler(
|
|
266
|
-
makeRequest("POST",
|
|
275
|
+
makeRequest("POST", TOKEN, {
|
|
267
276
|
ingressPublicBaseUrl: "https://first.com",
|
|
268
277
|
}),
|
|
269
278
|
),
|
|
270
279
|
handler(
|
|
271
|
-
makeRequest("POST",
|
|
280
|
+
makeRequest("POST", TOKEN, {
|
|
272
281
|
ingressPublicBaseUrl: "https://second.com",
|
|
273
282
|
}),
|
|
274
283
|
),
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import type { RuntimeAttachmentMeta } from "../runtime/client.js";
|
|
3
3
|
import type { GatewayConfig } from "../config.js";
|
|
4
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
4
8
|
|
|
5
9
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
6
10
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach, beforeEach } from "bun:test";
|
|
2
2
|
import type { GatewayConfig } from "../config.js";
|
|
3
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
4
|
+
|
|
5
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
6
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
7
|
|
|
4
8
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
9
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -557,41 +561,3 @@ describe("telegram webhook handler: callback_query forwarding", () => {
|
|
|
557
561
|
});
|
|
558
562
|
});
|
|
559
563
|
|
|
560
|
-
describe("telegram webhook handler: gateway-origin marker", () => {
|
|
561
|
-
test("forwards X-Gateway-Origin header when runtimeBearerToken is configured", async () => {
|
|
562
|
-
const bearerToken = "secret-runtime-token";
|
|
563
|
-
const config = makeConfig({
|
|
564
|
-
routingEntries: [{ type: "conversation_id", key: "12345", assistantId: "assistant-a" }],
|
|
565
|
-
});
|
|
566
|
-
installFetchMock();
|
|
567
|
-
const { handler } = createTelegramWebhookHandler(config);
|
|
568
|
-
|
|
569
|
-
const payload = makeTelegramPayload("hello", 8001);
|
|
570
|
-
const req = makeWebhookRequest(payload);
|
|
571
|
-
const res = await handler(req);
|
|
572
|
-
|
|
573
|
-
expect(res.status).toBe(200);
|
|
574
|
-
|
|
575
|
-
const runtimeCall = fetchCalls.find((c) => c.url.includes("/inbound"));
|
|
576
|
-
expect(runtimeCall).toBeDefined();
|
|
577
|
-
expect(runtimeCall!.headers?.["x-gateway-origin"]).toBe(bearerToken);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
test("does not include X-Gateway-Origin header when no runtimeBearerToken", async () => {
|
|
581
|
-
const config = makeConfig({
|
|
582
|
-
routingEntries: [{ type: "conversation_id", key: "12345", assistantId: "assistant-a" }],
|
|
583
|
-
});
|
|
584
|
-
installFetchMock();
|
|
585
|
-
const { handler } = createTelegramWebhookHandler(config);
|
|
586
|
-
|
|
587
|
-
const payload = makeTelegramPayload("hello", 8002);
|
|
588
|
-
const req = makeWebhookRequest(payload);
|
|
589
|
-
const res = await handler(req);
|
|
590
|
-
|
|
591
|
-
expect(res.status).toBe(200);
|
|
592
|
-
|
|
593
|
-
const runtimeCall = fetchCalls.find((c) => c.url.includes("/inbound"));
|
|
594
|
-
expect(runtimeCall).toBeDefined();
|
|
595
|
-
expect(runtimeCall!.headers?.["x-gateway-origin"]).toBeUndefined();
|
|
596
|
-
});
|
|
597
|
-
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
2
|
import { createHmac } from "node:crypto";
|
|
3
3
|
import type { GatewayConfig } from "../config.js";
|
|
4
|
+
import { initSigningKey } from "../auth/token-service.js";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
4
8
|
|
|
5
9
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
6
10
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
package/src/auth/scopes.ts
CHANGED
|
@@ -42,6 +42,9 @@ const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
|
|
|
42
42
|
ipc_v1: new Set<Scope>([
|
|
43
43
|
'ipc.all',
|
|
44
44
|
]),
|
|
45
|
+
ui_page_v1: new Set<Scope>([
|
|
46
|
+
'settings.read',
|
|
47
|
+
]),
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
// ---------------------------------------------------------------------------
|
package/src/auth/types.ts
CHANGED
|
@@ -13,7 +13,8 @@ export type ScopeProfile =
|
|
|
13
13
|
| 'actor_client_v1'
|
|
14
14
|
| 'gateway_ingress_v1'
|
|
15
15
|
| 'gateway_service_v1'
|
|
16
|
-
| 'ipc_v1'
|
|
16
|
+
| 'ipc_v1'
|
|
17
|
+
| 'ui_page_v1';
|
|
17
18
|
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Individual scope strings
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, mock, afterEach } 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";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
8
|
|
|
4
9
|
type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
5
10
|
let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () => new Response());
|
|
@@ -12,7 +17,18 @@ const { createSmsDeliverHandler } = await import("./sms-deliver.js");
|
|
|
12
17
|
|
|
13
18
|
// --- Helpers ---------------------------------------------------------------
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
/** Mint a valid daemon JWT for deliver auth. */
|
|
21
|
+
function mintDeliverToken(): string {
|
|
22
|
+
return mintToken({
|
|
23
|
+
aud: 'vellum-daemon',
|
|
24
|
+
sub: 'svc:gateway:self',
|
|
25
|
+
scope_profile: 'gateway_service_v1',
|
|
26
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
27
|
+
ttlSeconds: 300,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TOKEN = mintDeliverToken();
|
|
16
32
|
|
|
17
33
|
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
18
34
|
const merged: GatewayConfig = {
|
|
@@ -97,17 +113,6 @@ describe("/deliver/sms", () => {
|
|
|
97
113
|
expect(res.status).toBe(405);
|
|
98
114
|
});
|
|
99
115
|
|
|
100
|
-
it("rejects when no bearer token and bypass not set with 503", async () => {
|
|
101
|
-
const handler = createSmsDeliverHandler(
|
|
102
|
-
makeConfig({}),
|
|
103
|
-
);
|
|
104
|
-
const req = makeRequest({ to: "+15559876543", text: "hello" });
|
|
105
|
-
const res = await handler(req);
|
|
106
|
-
expect(res.status).toBe(503);
|
|
107
|
-
const body = await res.json();
|
|
108
|
-
expect(body.error).toBe("Service not configured: bearer token required");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
116
|
it("rejects request without Authorization header with 401", async () => {
|
|
112
117
|
const handler = createSmsDeliverHandler(makeConfig());
|
|
113
118
|
const req = makeRequest({ to: "+15559876543", text: "hello" });
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } 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
|
import { createTelegramDeliverHandler } from "./telegram-deliver.js";
|
|
4
6
|
|
|
7
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
8
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
9
|
+
|
|
5
10
|
// ---- Mocks ----
|
|
6
11
|
|
|
7
12
|
// Track calls to sendTelegramReply so we can assert on approval passthrough.
|
|
@@ -68,7 +73,18 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
|
68
73
|
return merged;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
/** Mint a valid daemon JWT for deliver auth. */
|
|
77
|
+
function mintDeliverToken(): string {
|
|
78
|
+
return mintToken({
|
|
79
|
+
aud: 'vellum-daemon',
|
|
80
|
+
sub: 'svc:gateway:self',
|
|
81
|
+
scope_profile: 'gateway_service_v1',
|
|
82
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
83
|
+
ttlSeconds: 300,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TOKEN = mintDeliverToken();
|
|
72
88
|
|
|
73
89
|
function makeRequest(body: unknown, headers?: Record<string, string>): Request {
|
|
74
90
|
return new Request("http://localhost:7830/deliver/telegram", {
|
|
@@ -97,17 +113,6 @@ describe("telegram-deliver endpoint basics", () => {
|
|
|
97
113
|
expect(body.error).toBe("Method not allowed");
|
|
98
114
|
});
|
|
99
115
|
|
|
100
|
-
it("rejects when no bearer token and bypass not set with 503", async () => {
|
|
101
|
-
const handler = createTelegramDeliverHandler(
|
|
102
|
-
makeConfig({ telegramDeliverAuthBypass: false }),
|
|
103
|
-
);
|
|
104
|
-
const req = makeRequest({ chatId: "123", text: "hello" });
|
|
105
|
-
const res = await handler(req);
|
|
106
|
-
expect(res.status).toBe(503);
|
|
107
|
-
const body = await res.json();
|
|
108
|
-
expect(body.error).toBe("Service not configured: bearer token required");
|
|
109
|
-
});
|
|
110
|
-
|
|
111
116
|
it("rejects request without Authorization header with 401", async () => {
|
|
112
117
|
const handler = createTelegramDeliverHandler(
|
|
113
118
|
makeConfig({ telegramDeliverAuthBypass: false }),
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, mock, beforeEach } 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";
|
|
5
|
+
|
|
6
|
+
const TEST_SIGNING_KEY = Buffer.from('test-signing-key-at-least-32-bytes-long');
|
|
7
|
+
initSigningKey(TEST_SIGNING_KEY);
|
|
3
8
|
|
|
4
9
|
// ---- Mocks ----
|
|
5
10
|
|
|
@@ -16,7 +21,18 @@ const { createWhatsAppDeliverHandler } = await import("./whatsapp-deliver.js");
|
|
|
16
21
|
|
|
17
22
|
// ---- Helpers ----
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
/** Mint a valid daemon JWT for deliver auth. */
|
|
25
|
+
function mintDeliverToken(): string {
|
|
26
|
+
return mintToken({
|
|
27
|
+
aud: 'vellum-daemon',
|
|
28
|
+
sub: 'svc:gateway:self',
|
|
29
|
+
scope_profile: 'gateway_service_v1',
|
|
30
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
31
|
+
ttlSeconds: 300,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const TOKEN = mintDeliverToken();
|
|
20
36
|
|
|
21
37
|
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
22
38
|
const merged: GatewayConfig = {
|
|
@@ -94,22 +110,9 @@ describe("/deliver/whatsapp", () => {
|
|
|
94
110
|
expect(body.error).toBe("Method not allowed");
|
|
95
111
|
});
|
|
96
112
|
|
|
97
|
-
it("rejects when no bearer token and bypass not set with 503", async () => {
|
|
98
|
-
const handler = createWhatsAppDeliverHandler(
|
|
99
|
-
makeConfig({
|
|
100
|
-
whatsappDeliverAuthBypass: false,
|
|
101
|
-
}),
|
|
102
|
-
);
|
|
103
|
-
const req = makeRequest({ to: "+15559876543", text: "hello" });
|
|
104
|
-
const res = await handler(req);
|
|
105
|
-
expect(res.status).toBe(503);
|
|
106
|
-
const body = await res.json();
|
|
107
|
-
expect(body.error).toBe("Service not configured: bearer token required");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
113
|
it("rejects request without Authorization header with 401", async () => {
|
|
111
114
|
const handler = createWhatsAppDeliverHandler(
|
|
112
|
-
makeConfig({}),
|
|
115
|
+
makeConfig({ whatsappDeliverAuthBypass: false }),
|
|
113
116
|
);
|
|
114
117
|
const req = makeRequest({ to: "+15559876543", text: "hello" });
|
|
115
118
|
const res = await handler(req);
|
|
@@ -120,7 +123,7 @@ describe("/deliver/whatsapp", () => {
|
|
|
120
123
|
|
|
121
124
|
it("rejects request with wrong bearer token with 401", async () => {
|
|
122
125
|
const handler = createWhatsAppDeliverHandler(
|
|
123
|
-
makeConfig({}),
|
|
126
|
+
makeConfig({ whatsappDeliverAuthBypass: false }),
|
|
124
127
|
);
|
|
125
128
|
const req = makeRequest({ to: "+15559876543", text: "hello" }, {
|
|
126
129
|
authorization: "Bearer wrong-token",
|
|
@@ -133,7 +136,7 @@ describe("/deliver/whatsapp", () => {
|
|
|
133
136
|
|
|
134
137
|
it("accepts request with correct bearer token", async () => {
|
|
135
138
|
const handler = createWhatsAppDeliverHandler(
|
|
136
|
-
makeConfig({}),
|
|
139
|
+
makeConfig({ whatsappDeliverAuthBypass: false }),
|
|
137
140
|
);
|
|
138
141
|
const req = makeRequest({ to: "+15559876543", text: "hello" }, {
|
|
139
142
|
authorization: `Bearer ${TOKEN}`,
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,12 @@ process.title = "vellum-gateway";
|
|
|
2
2
|
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { AuthRateLimiter } from "./auth-rate-limiter.js";
|
|
5
|
-
import { loadOrCreateSigningKey, initSigningKey
|
|
5
|
+
import { loadOrCreateSigningKey, initSigningKey } from "./auth/token-service.js";
|
|
6
6
|
import { validateEdgeToken } from "./auth/token-exchange.js";
|
|
7
7
|
import { resolveScopeProfile } from "./auth/scopes.js";
|
|
8
8
|
import type { Scope } from "./auth/types.js";
|
|
9
9
|
import { ConfigFileWatcher } from "./config-file-watcher.js";
|
|
10
|
-
import { loadConfig, isSlackChannelConfigured
|
|
10
|
+
import { loadConfig, isSlackChannelConfigured } from "./config.js";
|
|
11
11
|
import { CredentialWatcher } from "./credential-watcher.js";
|
|
12
12
|
import { createRuntimeProxyHandler } from "./http/routes/runtime-proxy.js";
|
|
13
13
|
import {
|