@vellumai/vellum-gateway 0.1.10 → 0.3.0

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 (52) hide show
  1. package/.env.example +18 -2
  2. package/README.md +171 -5
  3. package/package.json +2 -1
  4. package/src/__tests__/config.test.ts +28 -3
  5. package/src/__tests__/credential-reader.test.ts +291 -0
  6. package/src/__tests__/dedup-cache.test.ts +97 -6
  7. package/src/__tests__/load-guards.test.ts +6 -2
  8. package/src/__tests__/oauth-callback.test.ts +5 -1
  9. package/src/__tests__/probes.test.ts +19 -25
  10. package/src/__tests__/resolve-assistant.test.ts +6 -2
  11. package/src/__tests__/runtime-client.test.ts +27 -9
  12. package/src/__tests__/runtime-proxy-auth.test.ts +6 -2
  13. package/src/__tests__/runtime-proxy.test.ts +115 -2
  14. package/src/__tests__/schema.test.ts +19 -20
  15. package/src/__tests__/sms-ingress-guard.test.ts +130 -0
  16. package/src/__tests__/telegram-api-redaction.test.ts +137 -0
  17. package/src/__tests__/telegram-deliver-auth.test.ts +416 -0
  18. package/src/__tests__/telegram-normalize.test.ts +159 -2
  19. package/src/__tests__/telegram-only-default.test.ts +29 -33
  20. package/src/__tests__/telegram-reconcile-route.test.ts +276 -0
  21. package/src/__tests__/telegram-send-attachments.test.ts +204 -2
  22. package/src/__tests__/telegram-webhook-handler.test.ts +529 -0
  23. package/src/__tests__/telegram-webhook-manager.test.ts +196 -0
  24. package/src/__tests__/twilio-relay-websocket.test.ts +11 -7
  25. package/src/__tests__/twilio-webhooks.test.ts +66 -10
  26. package/src/config.ts +64 -13
  27. package/src/credential-reader.ts +68 -11
  28. package/src/dedup-cache.ts +138 -3
  29. package/src/handlers/handle-inbound.ts +28 -20
  30. package/src/http/routes/runtime-proxy.ts +20 -0
  31. package/src/http/routes/sms-deliver.test.ts +230 -0
  32. package/src/http/routes/sms-deliver.ts +113 -0
  33. package/src/http/routes/telegram-deliver.test.ts +436 -0
  34. package/src/http/routes/telegram-deliver.ts +104 -4
  35. package/src/http/routes/telegram-reconcile.ts +80 -0
  36. package/src/http/routes/telegram-webhook.test.ts +188 -0
  37. package/src/http/routes/telegram-webhook.ts +169 -22
  38. package/src/http/routes/twilio-relay-websocket.ts +8 -8
  39. package/src/http/routes/twilio-sms-webhook.test.ts +325 -0
  40. package/src/http/routes/twilio-sms-webhook.ts +144 -0
  41. package/src/index.ts +45 -8
  42. package/src/logger.ts +3 -2
  43. package/src/runtime/client.ts +51 -5
  44. package/src/schema.ts +336 -1
  45. package/src/telegram/api.ts +33 -2
  46. package/src/telegram/normalize.test.ts +65 -0
  47. package/src/telegram/normalize.ts +75 -1
  48. package/src/telegram/send.test.ts +170 -0
  49. package/src/telegram/send.ts +72 -17
  50. package/src/telegram/webhook-manager.ts +61 -0
  51. package/src/twilio/validate-webhook.ts +58 -14
  52. package/src/types.ts +3 -1
package/.env.example CHANGED
@@ -58,6 +58,22 @@ ASSISTANT_RUNTIME_BASE_URL=http://localhost:7821
58
58
  # Optional: Max concurrent attachment download/upload operations (default: 3)
59
59
  # GATEWAY_MAX_ATTACHMENT_CONCURRENCY=3
60
60
 
61
- # Optional: Canonical public ingress base URL for webhook signature reconstruction.
62
- # Falls back to TWILIO_WEBHOOK_BASE_URL if not set.
61
+ # Optional: Canonical public base URL where the gateway is reachable externally.
62
+ # Used by the assistant runtime to construct webhook and OAuth callback URLs.
63
+ # Required for Twilio SMS webhook signature validation when behind a tunnel.
64
+ # Set this to your tunnel's public URL during local development.
63
65
  # INGRESS_PUBLIC_BASE_URL=https://your-public-domain.com
66
+
67
+ # --- SMS (Twilio) ---
68
+
69
+ # Optional: Twilio Account SID for outbound SMS via the Messages API
70
+ # TWILIO_ACCOUNT_SID=
71
+
72
+ # Optional: Twilio Auth Token for webhook signature validation and outbound SMS
73
+ # TWILIO_AUTH_TOKEN=
74
+
75
+ # Optional: Twilio phone number (E.164) used as the From number for outbound SMS
76
+ # TWILIO_PHONE_NUMBER=
77
+
78
+ # Optional: Dev-only bypass for bearer auth on /deliver/sms (default: false)
79
+ # GATEWAY_SMS_DELIVER_AUTH_BYPASS=false
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vellum Gateway
2
2
 
3
- Standalone service that owns Telegram integration end-to-end and optionally acts as an authenticated reverse proxy for the assistant runtime.
3
+ Standalone service that serves as the public ingress boundary for all external webhooks and callbacks. It owns Telegram integration end-to-end, routes Twilio voice and SMS webhooks, handles OAuth callbacks, and optionally acts as an authenticated reverse proxy for the assistant runtime.
4
4
 
5
5
  ## Architecture
6
6
 
@@ -26,16 +26,19 @@ bun run dev
26
26
 
27
27
  | Variable | Required | Default | Description |
28
28
  |----------|----------|---------|-------------|
29
- | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset) |
30
- | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset) |
29
+ | `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store via the credential reader fallback chain: macOS Keychain first (via `security` CLI), then encrypted file store (`~/.vellum/protected/keys.enc`). The keychain reader discriminates exit code 44 (`errSecItemNotFound` — credential genuinely missing) from other non-zero exit codes (transient errors), logging the latter as warnings. On non-macOS platforms, only the encrypted store is used. |
30
+ | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
31
31
  | `TELEGRAM_API_BASE_URL` | No | `https://api.telegram.org` | Override Telegram API base URL |
32
32
  | `ASSISTANT_RUNTIME_BASE_URL` | Yes | — | Base URL of the assistant runtime HTTP server |
33
33
  | `GATEWAY_ASSISTANT_ROUTING_JSON` | No | `{}` | JSON mapping of Telegram identities to assistant IDs |
34
34
  | `GATEWAY_DEFAULT_ASSISTANT_ID` | No | — | Default assistant ID for unmapped users |
35
35
  | `GATEWAY_UNMAPPED_POLICY` | No | `reject` | Policy for unmapped users: `reject` or `default` |
36
36
  | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
37
+ | `GATEWAY_INTERNAL_BASE_URL` | No | `http://127.0.0.1:${GATEWAY_PORT}` | Base URL for runtime→gateway callbacks (e.g., the `replyCallbackUrl` sent to the assistant runtime for Telegram reply delivery). Defaults to `http://127.0.0.1:${GATEWAY_PORT}`. Override when the gateway and runtime are not co-located (e.g., separate containers, hosts, or behind a service mesh). |
38
+ | `INGRESS_PUBLIC_BASE_URL` | No | — | Public URL where the gateway is reachable (e.g. `https://abc123.ngrok-free.app`). Used by the assistant runtime to construct webhook and OAuth callback URLs. Set this to your tunnel's public URL. |
37
39
  | `GATEWAY_RUNTIME_PROXY_ENABLED` | No | `false` | Enable runtime proxy for non-Telegram requests |
38
40
  | `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH` | No | `true` | Require bearer auth for proxied requests |
41
+ | `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). |
39
42
  | `RUNTIME_PROXY_BEARER_TOKEN` | Conditional | — | Bearer token for proxy auth (required when proxy + auth enabled) |
40
43
  | `GATEWAY_SHUTDOWN_DRAIN_MS` | No | `5000` | Graceful shutdown drain window in milliseconds |
41
44
  | `GATEWAY_RUNTIME_TIMEOUT_MS` | No | `30000` | Timeout for runtime HTTP calls (ms) |
@@ -45,6 +48,11 @@ bun run dev
45
48
  | `GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES` | No | `1048576` | Max inbound webhook payload size (rejects with 413) |
46
49
  | `GATEWAY_MAX_ATTACHMENT_BYTES` | No | `20971520` | Max single attachment size (oversized are skipped) |
47
50
  | `GATEWAY_MAX_ATTACHMENT_CONCURRENCY` | No | `3` | Max concurrent attachment download/upload operations |
51
+ | `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/telegram` when no token is configured |
52
+ | `TWILIO_ACCOUNT_SID` | No | — | Twilio Account SID for sending outbound SMS via the Messages API |
53
+ | `TWILIO_AUTH_TOKEN` | No | — | Twilio Auth Token for HMAC-SHA1 webhook signature validation and outbound SMS |
54
+ | `TWILIO_PHONE_NUMBER` | No | — | Twilio phone number (E.164) used as the `From` for outbound SMS |
55
+ | `GATEWAY_SMS_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/sms` when no token is configured |
48
56
 
49
57
  ## Routing
50
58
 
@@ -65,13 +73,158 @@ v1 uses deterministic settings-based routing (no database):
65
73
 
66
74
  ## Setting up the Telegram webhook
67
75
 
68
- After deploying the gateway, register the webhook with Telegram using the `setWebhook` API method. Pass:
76
+ Webhook registration is now handled automatically by the gateway. On startup, the gateway reconciles the Telegram webhook by registering it at `${INGRESS_PUBLIC_BASE_URL}/webhooks/telegram` with the configured secret and allowed updates. This also runs whenever the credential watcher detects changes to the bot token or webhook secret (e.g., secret rotation). If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
77
+
78
+ For manual setup (or reference), register the webhook with Telegram using the `setWebhook` API method. Pass:
69
79
  - `url` — your gateway URL, e.g. `https://your-host/webhooks/telegram`
70
80
  - The verify value matching your `TELEGRAM_WEBHOOK_SECRET` env var
71
- - `allowed_updates` — `["message", "edited_message"]`
81
+ - `allowed_updates` — `["message", "edited_message", "callback_query"]`
72
82
 
73
83
  See the [Telegram Bot API docs](https://core.telegram.org/bots/api#setwebhook) for the full API reference.
74
84
 
85
+ ## Telegram Deliver Endpoint Security
86
+
87
+ The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed). The security behavior is:
88
+
89
+ | Condition | Result |
90
+ |-----------|--------|
91
+ | Bearer token configured + valid `Authorization` header | Request allowed |
92
+ | Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
93
+ | No bearer token configured + `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) |
94
+ | No bearer token configured + bypass not set | 503 Service Not Configured |
95
+
96
+ This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, always configure `RUNTIME_PROXY_BEARER_TOKEN`. The `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` flag is intended for local development only.
97
+
98
+ ## SMS Ingress (Twilio)
99
+
100
+ The `/webhooks/twilio/sms` endpoint receives inbound SMS messages from Twilio. On each request:
101
+
102
+ 1. **Signature validation** — The `X-Twilio-Signature` header is validated using HMAC-SHA1 with the `TWILIO_AUTH_TOKEN`. When behind a tunnel or reverse proxy, the gateway reconstructs the canonical request URL from `INGRESS_PUBLIC_BASE_URL` for validation.
103
+ 2. **MessageSid dedup** — Each `MessageSid` is tracked in an in-memory dedup cache. Duplicate webhook deliveries (Twilio retries) are silently accepted without re-forwarding.
104
+ 3. **Normalization** — The form-encoded Twilio payload is normalized into a `GatewayInboundEventV1` with `sourceChannel: "sms"`. The sender's phone number (`From`) is used as both `externalChatId` and `externalUserId`.
105
+ 4. **Routing** — The same routing resolver used for Telegram (chat_id -> user_id -> default/reject) determines the target assistant.
106
+ 5. **Forwarding** — The event is forwarded to the runtime via `POST /channels/inbound` with SMS-specific transport hints (`chat-first-medium`, `sms-character-limits`, etc.) and a `replyCallbackUrl` pointing to `/deliver/sms`.
107
+
108
+ SMS is text-only in v1 — MMS (media attachments) is deferred.
109
+
110
+ ## SMS Deliver Endpoint Security
111
+
112
+ The `/deliver/sms` endpoint requires the same fail-closed bearer auth as `/deliver/telegram`:
113
+
114
+ | Condition | Result |
115
+ |-----------|--------|
116
+ | Bearer token configured + valid `Authorization` header | Request allowed |
117
+ | Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
118
+ | No bearer token configured + `GATEWAY_SMS_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) |
119
+ | No bearer token configured + bypass not set | 503 Service Not Configured |
120
+
121
+ The endpoint also requires `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_PHONE_NUMBER` to be configured. If any are missing, requests return `503 SMS integration not configured`.
122
+
123
+ Outbound SMS is sent via the Twilio Messages API using the configured `TWILIO_PHONE_NUMBER` as the `From` number. The request body is `{ to: string, text: string }`.
124
+
125
+ ## Callback Query Handling
126
+
127
+ The gateway normalizes Telegram `callback_query` updates (inline button clicks) into the same `GatewayInboundEventV1` format used for regular messages. When a `callback_query` is present in the webhook payload, the normalizer extracts:
128
+
129
+ - `callbackQueryId` — the Telegram callback query ID
130
+ - `callbackData` — the opaque data string attached to the button (e.g., `apr:<runId>:<action>`)
131
+ - `content` — set to the callback data string (so the runtime always has content to process)
132
+
133
+ These fields are forwarded to the runtime in the `/channels/inbound` payload alongside the standard `externalChatId`, `externalMessageId`, and sender metadata. The runtime uses `callbackData` to route the click to the appropriate approval handler.
134
+
135
+ ## Approval Buttons and Inline Keyboard
136
+
137
+ The `/deliver/telegram` endpoint accepts an optional `approval` field in the request body. When present, the gateway renders Telegram inline keyboard buttons below the message text.
138
+
139
+ **Approval payload shape:**
140
+
141
+ ```json
142
+ {
143
+ "chatId": "123456",
144
+ "text": "The assistant wants to use the tool \"bash\". Do you want to allow this?",
145
+ "approval": {
146
+ "runId": "run-uuid",
147
+ "requestId": "request-uuid",
148
+ "actions": [
149
+ { "id": "approve_once", "label": "Approve once" },
150
+ { "id": "approve_always", "label": "Approve always" },
151
+ { "id": "reject", "label": "Reject" }
152
+ ],
153
+ "plainTextFallback": "Reply \"yes\" to approve once, \"always\" to approve always, or \"no\" to reject."
154
+ }
155
+ }
156
+ ```
157
+
158
+ **Inline keyboard format:** Each action is rendered as a single-button row. The callback data uses the compact format `apr:<runId>:<action>` (e.g., `apr:run-uuid:approve_once`) so the runtime can parse it back when the button is clicked.
159
+
160
+ **Fallback behavior:** For non-Telegram channels that do not support inline keyboards, the `plainTextFallback` string is included in the prompt text, providing plain-text instructions for the user to type their decision.
161
+
162
+ ## Public Ingress Routes
163
+
164
+ The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding:
165
+
166
+ | Route | Method | Description |
167
+ |-------|--------|-------------|
168
+ | `/webhooks/telegram` | POST | Telegram bot webhook (validated via `TELEGRAM_WEBHOOK_SECRET`) |
169
+ | `/deliver/telegram` | POST | Internal endpoint for the assistant runtime to deliver outbound messages/attachments to Telegram chats |
170
+ | `/webhooks/twilio/voice` | POST | Twilio voice webhook (validated via HMAC-SHA1 signature) |
171
+ | `/webhooks/twilio/status` | POST | Twilio status callback (validated via HMAC-SHA1 signature) |
172
+ | `/webhooks/twilio/connect-action` | POST | Twilio connect-action callback (validated via HMAC-SHA1 signature) |
173
+ | `/webhooks/twilio/relay` | WS | Twilio ConversationRelay WebSocket (bidirectional proxy to runtime, requires `callSessionId` query param) |
174
+ | `/webhooks/twilio/sms` | POST | Twilio SMS webhook — validates X-Twilio-Signature (HMAC-SHA1), normalizes into `GatewayInboundEventV1` with `sourceChannel: "sms"`, deduplicates by `MessageSid`, and forwards to runtime |
175
+ | `/deliver/sms` | POST | Internal endpoint for the assistant runtime to deliver outbound SMS messages via the Twilio Messages API |
176
+ | `/webhooks/oauth/callback` | GET | OAuth2 callback endpoint — receives authorization codes from OAuth providers (Google, Slack, etc.) and forwards them to the assistant runtime |
177
+ | `/healthz` | GET | Liveness probe |
178
+ | `/readyz` | GET | Readiness probe |
179
+ | `/schema` | GET | Returns the OpenAPI 3.1 schema for this gateway |
180
+
181
+ #### Backward-Compatibility Paths
182
+
183
+ The following legacy paths are aliases that map to their canonical equivalents above:
184
+
185
+ | Legacy Path | Canonical Path |
186
+ |-------------|---------------|
187
+ | `/v1/calls/twilio/voice-webhook` | `/webhooks/twilio/voice` |
188
+ | `/v1/calls/twilio/status` | `/webhooks/twilio/status` |
189
+ | `/v1/calls/twilio/connect-action` | `/webhooks/twilio/connect-action` |
190
+ | `/v1/calls/relay` | `/webhooks/twilio/relay` |
191
+
192
+ ### Tunnel Setup
193
+
194
+ To receive external callbacks during local development, point a tunnel service at the local gateway (default `http://127.0.0.1:7830`) and configure the resulting public URL:
195
+
196
+ #### Test Gateway Source Changes Locally (No Release Needed)
197
+
198
+ Use this flow when you are changing files under `gateway/` and need to validate immediately without publishing `@vellumai/vellum-gateway`.
199
+
200
+ ```bash
201
+ # Terminal 1: restart assistant runtime HTTP server
202
+ cd assistant
203
+ bun run daemon:restart:http
204
+
205
+ # Terminal 2: run gateway from local source with runtime proxy enabled
206
+ cd gateway
207
+ bun run dev:proxy
208
+ ```
209
+
210
+ If `7830` is already in use, start the gateway on another port:
211
+
212
+ ```bash
213
+ cd gateway
214
+ GATEWAY_PORT=7840 bun run dev:proxy
215
+ ```
216
+
217
+ Then point your tunnel to that same local target (for example `http://127.0.0.1:7840`).
218
+
219
+ 1. Start your tunnel (e.g. ngrok, Cloudflare Tunnel, or similar) targeting `http://127.0.0.1:7830`
220
+ 2. Copy the public URL provided by the tunnel service (e.g. `https://abc123.ngrok-free.app`)
221
+ 3. Set the URL as `ingress.publicBaseUrl` in the Settings UI (Public Ingress section) **or** as the `INGRESS_PUBLIC_BASE_URL` environment variable.
222
+ 4. Use the Settings UI "Local Gateway Target" value as the source of truth for tunnel destination (it reflects `GATEWAY_PORT`).
223
+
224
+ In local tunnel setups, updating `ingress.publicBaseUrl` in Settings is typically live for Twilio inbound validation (no manual gateway restart required) because the gateway also validates signatures against forwarded public URL headers.
225
+
226
+ The assistant runtime uses this URL to construct all webhook and OAuth callback URLs automatically.
227
+
75
228
  ## Default Mode: Telegram-Only
76
229
 
77
230
  By default the gateway only serves the Telegram webhook endpoint (`/webhooks/telegram`). All other HTTP requests return `404`. The runtime proxy is **opt-in** — set `GATEWAY_RUNTIME_PROXY_ENABLED=true` to enable it. This behavior is enforced by automated tests.
@@ -145,6 +298,8 @@ docker run --rm -p 7830:7830 \
145
298
 
146
299
  The image runs as non-root user `gateway` (uid 1001) and exposes port `7830`.
147
300
 
301
+ When the runtime and gateway run in separate containers or hosts, set `GATEWAY_INTERNAL_BASE_URL` so the runtime can reach the gateway for callbacks (e.g., Telegram reply delivery). By default it points to `http://127.0.0.1:${GATEWAY_PORT}`, which only works when both services share the same host.
302
+
148
303
  ## Development
149
304
 
150
305
  ```bash
@@ -184,3 +339,14 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
184
339
  | "No route configured" replies | Add a routing entry or set `GATEWAY_UNMAPPED_POLICY=default` with a default assistant |
185
340
  | Runtime errors | Is `ASSISTANT_RUNTIME_BASE_URL` reachable? Check runtime logs. |
186
341
  | No reply from assistant | Is the assistant runtime processing messages? Check for `RUNTIME_HTTP_PORT` env var. |
342
+ | 403 `GATEWAY_ORIGIN_REQUIRED` on channel inbound | The runtime rejected the request because it lacks a valid `X-Gateway-Origin` header. Ensure `RUNTIME_BEARER_TOKEN` (or the `~/.vellum/http-token` file) is set so the gateway and runtime share the same secret. |
343
+
344
+ ### Guardian-Specific Troubleshooting
345
+
346
+ | Symptom | Cause | Resolution |
347
+ |---------|-------|------------|
348
+ | `/guardian_verify` command gets no reply | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
349
+ | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The system is fail-closed: without a guardian, all sensitive actions are denied. | Set up a guardian by running the verification flow from the desktop UI. |
350
+ | Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_INTERNAL_BASE_URL` is set correctly (especially in containerized deployments). Re-verify the guardian if the chat ID has changed. |
351
+ | Guardian approval expired | The 30-minute TTL elapsed without a decision | The approval is auto-denied. The non-guardian user must re-trigger the action. |
352
+ | "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `externalUserId` matches the approval request can approve or deny. |
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.1.10",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun run --watch src/index.ts",
7
+ "dev:proxy": "GATEWAY_RUNTIME_PROXY_ENABLED=true GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=false bun run --watch src/index.ts",
7
8
  "build": "bun build src/index.ts --outdir dist --target bun",
8
9
  "schema": "bun run src/cli/schema.ts",
9
10
  "start": "bun run src/index.ts",
@@ -29,6 +29,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void)
29
29
  "GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES",
30
30
  "GATEWAY_MAX_ATTACHMENT_BYTES",
31
31
  "GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
32
+ "GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS",
32
33
  "VELLUM_HTTP_TOKEN_PATH",
33
34
  ];
34
35
 
@@ -220,17 +221,41 @@ describe("config: runtime proxy flags", () => {
220
221
  });
221
222
 
222
223
  describe("config: runtime bearer token", () => {
223
- test("runtimeBearerToken is undefined when RUNTIME_BEARER_TOKEN is unset", () => {
224
- withEnv({}, () => {
224
+ test("runtimeBearerToken is undefined when env is unset and http-token file is missing", () => {
225
+ withEnv({ VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
225
226
  const config = loadConfig();
226
227
  expect(config.runtimeBearerToken).toBeUndefined();
227
228
  });
228
229
  });
229
230
 
230
231
  test("runtimeBearerToken is set from RUNTIME_BEARER_TOKEN env var", () => {
231
- withEnv({ RUNTIME_BEARER_TOKEN: "rt-secret" }, () => {
232
+ withEnv({ RUNTIME_BEARER_TOKEN: "rt-secret", VELLUM_HTTP_TOKEN_PATH: "/nonexistent/http-token" }, () => {
232
233
  const config = loadConfig();
233
234
  expect(config.runtimeBearerToken).toBe("rt-secret");
234
235
  });
235
236
  });
237
+
238
+ test("runtimeBearerToken is read from http-token file when env var is unset", () => {
239
+ withEnv({ VELLUM_HTTP_TOKEN_PATH: "/tmp/test-runtime-http-token" }, () => {
240
+ writeFileSync("/tmp/test-runtime-http-token", "runtime-file-token\n");
241
+ const config = loadConfig();
242
+ expect(config.runtimeBearerToken).toBe("runtime-file-token");
243
+ unlinkSync("/tmp/test-runtime-http-token");
244
+ });
245
+ });
246
+
247
+ test("RUNTIME_BEARER_TOKEN env var takes precedence over http-token file", () => {
248
+ withEnv(
249
+ {
250
+ RUNTIME_BEARER_TOKEN: "runtime-env-token",
251
+ VELLUM_HTTP_TOKEN_PATH: "/tmp/test-runtime-http-token-priority",
252
+ },
253
+ () => {
254
+ writeFileSync("/tmp/test-runtime-http-token-priority", "runtime-file-token");
255
+ const config = loadConfig();
256
+ expect(config.runtimeBearerToken).toBe("runtime-env-token");
257
+ unlinkSync("/tmp/test-runtime-http-token-priority");
258
+ },
259
+ );
260
+ });
236
261
  });
@@ -0,0 +1,291 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
2
+ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mock logger — capture log calls to verify no secrets leak
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const logCalls: { level: string; args: unknown[] }[] = [];
12
+
13
+ mock.module("../logger.js", () => ({
14
+ getLogger: () =>
15
+ new Proxy({} as Record<string, unknown>, {
16
+ get: (_target, prop) =>
17
+ (...args: unknown[]) => {
18
+ logCalls.push({ level: String(prop), args });
19
+ },
20
+ }),
21
+ }));
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Mock execFileSync — intercept keychain CLI calls
25
+ // ---------------------------------------------------------------------------
26
+
27
+ let execFileSyncMock: ReturnType<typeof mock>;
28
+
29
+ mock.module("node:child_process", () => {
30
+ execFileSyncMock = mock(() => {
31
+ throw new Error("not found");
32
+ });
33
+ return { execFileSync: execFileSyncMock };
34
+ });
35
+
36
+ import {
37
+ readTelegramCredentials,
38
+ readCredential,
39
+ readKeychainCredential,
40
+ getMetadataPath,
41
+ getRootDir,
42
+ } from "../credential-reader.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Temp directory for metadata / encrypted store fixtures
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const testDir = join(tmpdir(), `cred-reader-test-${randomBytes(4).toString("hex")}`);
49
+
50
+ function metadataDir(): string {
51
+ return join(testDir, ".vellum", "workspace", "data", "credentials");
52
+ }
53
+
54
+ function writeMetadata(credentials: { service: string; field: string }[]): void {
55
+ const dir = metadataDir();
56
+ mkdirSync(dir, { recursive: true });
57
+ writeFileSync(join(dir, "metadata.json"), JSON.stringify({ credentials }));
58
+ }
59
+
60
+ const originalPlatform = process.platform;
61
+
62
+ beforeEach(() => {
63
+ process.env.BASE_DATA_DIR = testDir;
64
+ logCalls.length = 0;
65
+ execFileSyncMock.mockReset();
66
+ // Default: execFileSync throws with exit code 44 (errSecItemNotFound)
67
+ execFileSyncMock.mockImplementation(() => {
68
+ const err = new Error("not found") as Error & { status: number };
69
+ err.status = 44;
70
+ throw err;
71
+ });
72
+ });
73
+
74
+ afterEach(() => {
75
+ delete process.env.BASE_DATA_DIR;
76
+ try {
77
+ rmSync(testDir, { recursive: true, force: true });
78
+ } catch {
79
+ // best-effort cleanup
80
+ }
81
+ // Restore platform in case a test changed it
82
+ Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Tests
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("readTelegramCredentials: encrypted store only (existing behavior)", () => {
90
+ test("returns null when metadata file does not exist", () => {
91
+ const result = readTelegramCredentials();
92
+ expect(result).toBeNull();
93
+ });
94
+
95
+ test("returns null when metadata has no Telegram entries", () => {
96
+ writeMetadata([{ service: "github", field: "token" }]);
97
+ const result = readTelegramCredentials();
98
+ expect(result).toBeNull();
99
+ });
100
+
101
+ test("returns null when metadata exists but secrets are missing from both backends", () => {
102
+ writeMetadata([
103
+ { service: "telegram", field: "bot_token" },
104
+ { service: "telegram", field: "webhook_secret" },
105
+ ]);
106
+
107
+ // Keychain returns nothing (throws), encrypted store has no file
108
+ const result = readTelegramCredentials();
109
+ expect(result).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe("readTelegramCredentials: keychain on macOS", () => {
114
+ beforeEach(() => {
115
+ Object.defineProperty(process, "platform", { value: "darwin", writable: true });
116
+ });
117
+
118
+ test("returns credentials from keychain when available on macOS", () => {
119
+ writeMetadata([
120
+ { service: "telegram", field: "bot_token" },
121
+ { service: "telegram", field: "webhook_secret" },
122
+ ]);
123
+
124
+ // Simulate keychain returning credentials
125
+ execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => {
126
+ const aIdx = (args as string[]).indexOf("-a");
127
+ const account = (args as string[])[aIdx + 1];
128
+ if (account === "credential:telegram:bot_token") return "kc-bot-token\n";
129
+ if (account === "credential:telegram:webhook_secret") return "kc-webhook-secret\n";
130
+ throw new Error("not found");
131
+ });
132
+
133
+ const result = readTelegramCredentials();
134
+ expect(result).toEqual({
135
+ botToken: "kc-bot-token",
136
+ webhookSecret: "kc-webhook-secret",
137
+ });
138
+ });
139
+
140
+ test("prefers keychain over encrypted store on macOS", () => {
141
+ writeMetadata([
142
+ { service: "telegram", field: "bot_token" },
143
+ { service: "telegram", field: "webhook_secret" },
144
+ ]);
145
+
146
+ // Keychain returns credentials — encrypted store should not be consulted
147
+ execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => {
148
+ const aIdx = (args as string[]).indexOf("-a");
149
+ const account = (args as string[])[aIdx + 1];
150
+ if (account === "credential:telegram:bot_token") return "keychain-token\n";
151
+ if (account === "credential:telegram:webhook_secret") return "keychain-secret\n";
152
+ throw new Error("not found");
153
+ });
154
+
155
+ const result = readTelegramCredentials();
156
+ expect(result).not.toBeNull();
157
+ expect(result!.botToken).toBe("keychain-token");
158
+ expect(result!.webhookSecret).toBe("keychain-secret");
159
+ });
160
+
161
+ test("falls back to encrypted store when keychain has no credentials", () => {
162
+ writeMetadata([
163
+ { service: "telegram", field: "bot_token" },
164
+ { service: "telegram", field: "webhook_secret" },
165
+ ]);
166
+
167
+ // Keychain throws (credential not found) — fall through to encrypted store
168
+ // Encrypted store also has nothing, so result should be null
169
+ const result = readTelegramCredentials();
170
+ expect(result).toBeNull();
171
+ });
172
+ });
173
+
174
+ describe("readTelegramCredentials: non-macOS platforms", () => {
175
+ test("skips keychain on non-macOS and uses encrypted store", () => {
176
+ Object.defineProperty(process, "platform", { value: "linux", writable: true });
177
+
178
+ writeMetadata([
179
+ { service: "telegram", field: "bot_token" },
180
+ { service: "telegram", field: "webhook_secret" },
181
+ ]);
182
+
183
+ // readKeychainCredential should return undefined on linux
184
+ const keychainResult = readKeychainCredential("credential:telegram:bot_token");
185
+ expect(keychainResult).toBeUndefined();
186
+
187
+ // execFileSync should NOT have been called since platform is not darwin
188
+ expect(execFileSyncMock).not.toHaveBeenCalled();
189
+ });
190
+ });
191
+
192
+ describe("readTelegramCredentials: neither backend has credentials", () => {
193
+ test("returns null when both keychain and encrypted store have nothing", () => {
194
+ writeMetadata([
195
+ { service: "telegram", field: "bot_token" },
196
+ { service: "telegram", field: "webhook_secret" },
197
+ ]);
198
+
199
+ // Keychain throws, encrypted store file doesn't exist
200
+ const result = readTelegramCredentials();
201
+ expect(result).toBeNull();
202
+ });
203
+ });
204
+
205
+ describe("readKeychainCredential", () => {
206
+ beforeEach(() => {
207
+ Object.defineProperty(process, "platform", { value: "darwin", writable: true });
208
+ });
209
+
210
+ test("returns credential value from keychain on macOS", () => {
211
+ execFileSyncMock.mockImplementation(() => "my-secret-value\n");
212
+
213
+ const result = readKeychainCredential("credential:telegram:bot_token");
214
+ expect(result).toBe("my-secret-value");
215
+ });
216
+
217
+ test("returns undefined when keychain item not found (exit code 44)", () => {
218
+ execFileSyncMock.mockImplementation(() => {
219
+ const err = new Error("security: SecKeychainSearchCopyNext: The specified item could not be found") as Error & { status: number };
220
+ err.status = 44;
221
+ throw err;
222
+ });
223
+
224
+ const result = readKeychainCredential("credential:telegram:bot_token");
225
+ expect(result).toBeUndefined();
226
+ });
227
+
228
+ test("returns undefined for transient keychain errors (non-44 exit code)", () => {
229
+ execFileSyncMock.mockImplementation(() => {
230
+ const err = new Error("security: The user name or passphrase you entered is not correct.") as Error & { status: number };
231
+ err.status = 51;
232
+ throw err;
233
+ });
234
+
235
+ // Should still return undefined (graceful fallback), but logs a warning
236
+ const result = readKeychainCredential("credential:telegram:bot_token");
237
+ expect(result).toBeUndefined();
238
+ });
239
+
240
+ test("returns undefined on non-darwin platforms", () => {
241
+ Object.defineProperty(process, "platform", { value: "linux", writable: true });
242
+
243
+ const result = readKeychainCredential("credential:telegram:bot_token");
244
+ expect(result).toBeUndefined();
245
+ expect(execFileSyncMock).not.toHaveBeenCalled();
246
+ });
247
+
248
+ test("passes correct service name and account to security CLI", () => {
249
+ execFileSyncMock.mockImplementation(() => "value\n");
250
+
251
+ readKeychainCredential("credential:telegram:bot_token");
252
+
253
+ expect(execFileSyncMock).toHaveBeenCalledWith(
254
+ "security",
255
+ ["find-generic-password", "-s", "vellum-assistant", "-a", "credential:telegram:bot_token", "-w"],
256
+ expect.objectContaining({ encoding: "utf-8", timeout: 5000 }),
257
+ );
258
+ });
259
+ });
260
+
261
+ describe("log output: no plaintext secrets", () => {
262
+ beforeEach(() => {
263
+ Object.defineProperty(process, "platform", { value: "darwin", writable: true });
264
+ });
265
+
266
+ test("log messages never contain secret values", () => {
267
+ writeMetadata([
268
+ { service: "telegram", field: "bot_token" },
269
+ { service: "telegram", field: "webhook_secret" },
270
+ ]);
271
+
272
+ execFileSyncMock.mockImplementation((_cmd: string, args: string[]) => {
273
+ const aIdx = (args as string[]).indexOf("-a");
274
+ const account = (args as string[])[aIdx + 1];
275
+ if (account === "credential:telegram:bot_token") return "SUPER_SECRET_TOKEN_123\n";
276
+ if (account === "credential:telegram:webhook_secret") return "SUPER_SECRET_WEBHOOK_456\n";
277
+ throw new Error("not found");
278
+ });
279
+
280
+ const result = readTelegramCredentials();
281
+
282
+ // Verify credentials were actually returned (mock is working)
283
+ expect(result).not.toBeNull();
284
+ expect(result!.botToken).toBe("SUPER_SECRET_TOKEN_123");
285
+
286
+ // Verify no secret values appear in any log output
287
+ const allLogText = JSON.stringify(logCalls);
288
+ expect(allLogText).not.toContain("SUPER_SECRET_TOKEN_123");
289
+ expect(allLogText).not.toContain("SUPER_SECRET_WEBHOOK_456");
290
+ });
291
+ });