@vellumai/vellum-gateway 0.4.31 → 0.4.33

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/.env.example CHANGED
@@ -31,9 +31,6 @@ ASSISTANT_RUNTIME_BASE_URL=http://localhost:7821
31
31
  # Only relevant when proxy mode is enabled.
32
32
  # GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=true
33
33
 
34
- # Required when proxy is enabled with auth required: Bearer token for proxy auth
35
- # RUNTIME_PROXY_BEARER_TOKEN=
36
-
37
34
  # Optional: Graceful shutdown drain window in milliseconds (default: 5000)
38
35
  # GATEWAY_SHUTDOWN_DRAIN_MS=5000
39
36
 
package/README.md CHANGED
@@ -26,35 +26,34 @@ bun run dev
26
26
 
27
27
  ## Configuration
28
28
 
29
- | Variable | Required | Default | Description |
30
- | -------------------------------------- | ----------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
- | `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. |
32
- | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
- | `TELEGRAM_API_BASE_URL` | No | `https://api.telegram.org` | Override Telegram API base URL |
34
- | `ASSISTANT_RUNTIME_BASE_URL` | Yes | — | Base URL of the assistant runtime HTTP server |
35
- | `GATEWAY_ASSISTANT_ROUTING_JSON` | No | `{}` | JSON mapping of Telegram identities to assistant IDs |
36
- | `GATEWAY_DEFAULT_ASSISTANT_ID` | No | — | Default assistant ID for unmapped users |
37
- | `GATEWAY_UNMAPPED_POLICY` | No | `reject` | Policy for unmapped users: `reject` or `default` |
38
- | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
39
- | `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). |
40
- | `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. |
41
- | `GATEWAY_RUNTIME_PROXY_ENABLED` | No | `false` | Enable runtime proxy for non-Telegram requests |
42
- | `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH` | No | `true` | Require bearer auth for proxied requests |
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_PROXY_BEARER_TOKEN` | Conditional | | Bearer token for proxy auth (required when proxy + auth enabled) |
45
- | `GATEWAY_SHUTDOWN_DRAIN_MS` | No | `5000` | Graceful shutdown drain window in milliseconds |
46
- | `GATEWAY_RUNTIME_TIMEOUT_MS` | No | `30000` | Timeout for runtime HTTP calls (ms) |
47
- | `GATEWAY_RUNTIME_MAX_RETRIES` | No | `2` | Max retries for runtime forward on 5xx/network errors |
48
- | `GATEWAY_RUNTIME_INITIAL_BACKOFF_MS` | No | `500` | Initial backoff between retries (doubles each attempt) |
49
- | `GATEWAY_TELEGRAM_TIMEOUT_MS` | No | `15000` | Timeout for Telegram API/download calls (ms) |
50
- | `GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES` | No | `1048576` | Max inbound webhook payload size (rejects with 413) |
51
- | `GATEWAY_MAX_ATTACHMENT_BYTES` | No | `20971520` | Max single attachment size (oversized are skipped) |
52
- | `GATEWAY_MAX_ATTACHMENT_CONCURRENCY` | No | `3` | Max concurrent attachment download/upload operations |
53
- | `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/telegram` when no token is configured |
54
- | `TWILIO_ACCOUNT_SID` | No | — | Twilio Account SID for sending outbound SMS via the Messages API |
55
- | `TWILIO_AUTH_TOKEN` | No | — | Twilio Auth Token for HMAC-SHA1 webhook signature validation and outbound SMS |
56
- | `TWILIO_PHONE_NUMBER` | No | | Twilio phone number (E.164) used as the `From` for outbound SMS |
57
- | `GATEWAY_SMS_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/sms` when no token is configured |
29
+ | Variable | Required | Default | Description |
30
+ | -------------------------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `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. |
32
+ | `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
33
+ | `TELEGRAM_API_BASE_URL` | No | `https://api.telegram.org` | Override Telegram API base URL |
34
+ | `ASSISTANT_RUNTIME_BASE_URL` | Yes | — | Base URL of the assistant runtime HTTP server |
35
+ | `GATEWAY_ASSISTANT_ROUTING_JSON` | No | `{}` | JSON mapping of Telegram identities to assistant IDs |
36
+ | `GATEWAY_DEFAULT_ASSISTANT_ID` | No | — | Default assistant ID for unmapped users |
37
+ | `GATEWAY_UNMAPPED_POLICY` | No | `reject` | Policy for unmapped users: `reject` or `default` |
38
+ | `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
39
+ | `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). |
40
+ | `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. |
41
+ | `GATEWAY_RUNTIME_PROXY_ENABLED` | No | `false` | Enable runtime proxy for non-Telegram requests |
42
+ | `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH` | No | `true` | Require bearer auth for proxied requests |
43
+ | `RUNTIME_BEARER_TOKEN` | No | | Bearer token (JWT) used by gateway when forwarding requests to assistant runtime internal endpoints (Twilio/OAuth/proxy upstream). |
44
+ | `GATEWAY_SHUTDOWN_DRAIN_MS` | No | `5000` | Graceful shutdown drain window in milliseconds |
45
+ | `GATEWAY_RUNTIME_TIMEOUT_MS` | No | `30000` | Timeout for runtime HTTP calls (ms) |
46
+ | `GATEWAY_RUNTIME_MAX_RETRIES` | No | `2` | Max retries for runtime forward on 5xx/network errors |
47
+ | `GATEWAY_RUNTIME_INITIAL_BACKOFF_MS` | No | `500` | Initial backoff between retries (doubles each attempt) |
48
+ | `GATEWAY_TELEGRAM_TIMEOUT_MS` | No | `15000` | Timeout for Telegram API/download calls (ms) |
49
+ | `GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES` | No | `1048576` | Max inbound webhook payload size (rejects with 413) |
50
+ | `GATEWAY_MAX_ATTACHMENT_BYTES` | No | `20971520` | Max single attachment size (oversized are skipped) |
51
+ | `GATEWAY_MAX_ATTACHMENT_CONCURRENCY` | No | `3` | Max concurrent attachment download/upload operations |
52
+ | `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/telegram` when no token is configured |
53
+ | `TWILIO_ACCOUNT_SID` | No | | Twilio Account SID for sending outbound SMS via the Messages API |
54
+ | `TWILIO_AUTH_TOKEN` | No | — | Twilio Auth Token for HMAC-SHA1 webhook signature validation and outbound SMS |
55
+ | `TWILIO_PHONE_NUMBER` | No | — | Twilio phone number (E.164) used as the `From` for outbound SMS |
56
+ | `GATEWAY_SMS_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/sms` when no token is configured |
58
57
 
59
58
  ## Routing
60
59
 
@@ -97,7 +96,7 @@ The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed).
97
96
  | No bearer token configured + `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) |
98
97
  | No bearer token configured + bypass not set | 503 Service Not Configured |
99
98
 
100
- 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.
99
+ This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, ensure JWT authentication is properly configured. The `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` flag is intended for local development only.
101
100
 
102
101
  ## Voice Ingress — Inbound Calls (Twilio)
103
102
 
@@ -308,7 +307,7 @@ When `GATEWAY_RUNTIME_PROXY_ENABLED=true`, the gateway forwards all non-Telegram
308
307
 
309
308
  ### Auth behavior
310
309
 
311
- By default (`GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=true`), proxied requests must include a valid `Authorization: Bearer <token>` header matching `RUNTIME_PROXY_BEARER_TOKEN`. Set `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=false` to disable auth.
310
+ By default (`GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=true`), proxied requests must include a valid `Authorization: Bearer <jwt>` header with a JWT signed by the shared signing key. Set `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=false` to disable auth.
312
311
 
313
312
  `OPTIONS` requests are always allowed without auth (CORS preflight). Telegram webhook requests use their own secret-based verification and are not affected by proxy auth.
314
313
 
@@ -318,9 +317,9 @@ By default (`GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH=true`), proxied requests must in
318
317
  # Unauthorized (expect 401 when auth required)
319
318
  curl -i http://localhost:7830/v1/assistants/test/health
320
319
 
321
- # Authorized (expect 200)
320
+ # Authorized with JWT (expect 200)
322
321
  curl -i \
323
- -H "Authorization: Bearer $RUNTIME_PROXY_BEARER_TOKEN" \
322
+ -H "Authorization: Bearer <jwt>" \
324
323
  http://localhost:7830/v1/assistants/test/health
325
324
 
326
325
  # Telegram still uses webhook secret flow, not bearer auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.4.31",
3
+ "version": "0.4.33",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./twilio/verify": "./src/twilio/verify.ts",
@@ -21,7 +21,6 @@ function withEnv(
21
21
  "GATEWAY_RUNTIME_PROXY_ENABLED",
22
22
  "GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH",
23
23
  "RUNTIME_BEARER_TOKEN",
24
- "RUNTIME_PROXY_BEARER_TOKEN",
25
24
  "GATEWAY_ASSISTANT_ROUTING_JSON",
26
25
  "GATEWAY_DEFAULT_ASSISTANT_ID",
27
26
  "GATEWAY_UNMAPPED_POLICY",
@@ -0,0 +1,347 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { buildSchema } from "../schema.js";
5
+
6
+ /** A route extracted from source: path + optional HTTP method. */
7
+ interface ExtractedRoute {
8
+ path: string;
9
+ method: string | null; // null means "any method"
10
+ }
11
+
12
+ /**
13
+ * Extracts route paths from the gateway index.ts source code.
14
+ *
15
+ * Routes are defined in two places:
16
+ * 1. The `routes` array (RouteDefinition[]) — matched by the router
17
+ * 2. Pre-router paths in the `fetch()` handler (healthz, readyz, schema, WS upgrades)
18
+ *
19
+ * We parse the source text rather than importing index.ts because it calls
20
+ * `main()` at module scope which starts the server.
21
+ */
22
+ function extractRoutesFromSource(): ExtractedRoute[] {
23
+ const src = readFileSync(
24
+ join(import.meta.dirname!, "..", "index.ts"),
25
+ "utf-8",
26
+ );
27
+
28
+ const lines = src.split("\n");
29
+ const routes: ExtractedRoute[] = [];
30
+ const seenPreRouterPaths = new Set<string>();
31
+
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i];
34
+
35
+ // Match string literal paths: `path: "/some/path"`
36
+ const stringMatch = line.match(/path:\s*"([^"]+)"/);
37
+ if (stringMatch) {
38
+ const method = findMethodNearPath(lines, i);
39
+ routes.push({ path: stringMatch[1], method });
40
+ continue;
41
+ }
42
+
43
+ // Match regex paths: `path: /^\/v1\/contacts\/([^/]+)$/`
44
+ const regexMatch = line.match(/path:\s*\/\^(.*?)\$\//);
45
+ if (regexMatch) {
46
+ const converted = regexToOpenApiPath(regexMatch[1]);
47
+ if (converted) {
48
+ const method = findMethodNearPath(lines, i);
49
+ routes.push({ path: converted, method });
50
+ }
51
+ continue;
52
+ }
53
+
54
+ // Pre-router paths matched via `url.pathname === "/..."` in the fetch handler
55
+ const preRouterMatch = line.match(/url\.pathname\s*===\s*"([^"]+)"/);
56
+ if (preRouterMatch && !seenPreRouterPaths.has(preRouterMatch[1])) {
57
+ seenPreRouterPaths.add(preRouterMatch[1]);
58
+ routes.push({ path: preRouterMatch[1], method: null });
59
+ }
60
+ }
61
+
62
+ return routes;
63
+ }
64
+
65
+ /**
66
+ * Looks for a `method: "..."` declaration near a `path:` line.
67
+ * In the route table, method is always declared within a few lines
68
+ * of path (same object literal). We scan up to 3 lines after path.
69
+ */
70
+ function findMethodNearPath(
71
+ lines: string[],
72
+ pathLineIndex: number,
73
+ ): string | null {
74
+ // method can appear before or after path within the same object.
75
+ // Scan a small window around the path line, stopping at object boundaries.
76
+ for (let offset = -3; offset <= 3; offset++) {
77
+ const idx = pathLineIndex + offset;
78
+ if (idx < 0 || idx >= lines.length) continue;
79
+ const methodMatch = lines[idx].match(/method:\s*"([A-Z]+)"/);
80
+ if (methodMatch) return methodMatch[1];
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** Deduplicated, sorted list of unique route paths. */
86
+ function extractRoutePathsFromSource(): string[] {
87
+ const paths = new Set(extractRoutesFromSource().map((r) => r.path));
88
+ return [...paths].sort();
89
+ }
90
+
91
+ /**
92
+ * Converts an escaped regex path to an OpenAPI-style path.
93
+ * e.g. `\/v1\/contacts\/([^/]+)` → `/v1/contacts/{id}`
94
+ *
95
+ * Each capture group `([^/]+)` is replaced with `{paramN}` where N is the
96
+ * 1-based index of the group.
97
+ */
98
+ function regexToOpenApiPath(escaped: string): string | null {
99
+ // Unescape forward slashes
100
+ let path = escaped.replace(/\\\//g, "/");
101
+
102
+ // Replace capture groups with numbered params.
103
+ // Handles both `([^/]+)` (single segment) and `(.+)` (greedy) patterns.
104
+ let paramIndex = 0;
105
+ path = path.replace(/\(\[\^\/\]\+\)|\(\.\+\)/g, () => {
106
+ paramIndex++;
107
+ return `{param${paramIndex}}`;
108
+ });
109
+
110
+ // If there are remaining regex constructs we can't convert, skip
111
+ if (/[\\()\[\].*+?{}|^$]/.test(path.replace(/\{param\d+\}/g, ""))) {
112
+ return null;
113
+ }
114
+
115
+ return path;
116
+ }
117
+
118
+ // ── Routes that are intentionally undocumented in the OpenAPI schema ──
119
+ // Each entry must have a comment explaining why it's excluded.
120
+ const EXCLUDED_FROM_SCHEMA = new Set([
121
+ // Internal-only route, not reachable from the public internet
122
+ "/internal/telegram/reconcile",
123
+
124
+ // Duplicate webhook paths for Twilio call routing — the canonical
125
+ // paths under /webhooks/ are documented instead
126
+ "/v1/calls/twilio/voice-webhook",
127
+ "/v1/calls/twilio/status",
128
+ "/v1/calls/twilio/connect-action",
129
+ "/v1/calls/relay",
130
+
131
+ // Browser relay WebSocket upgrade — handled pre-router, not a REST endpoint
132
+ "/v1/browser-relay",
133
+
134
+ // Runtime proxy catch-all — documented as /{path} in the schema
135
+ "catch-all",
136
+ ]);
137
+
138
+ // ── Schema paths that don't map to a discrete route definition ──
139
+ // These are documented in the schema but correspond to pre-router logic
140
+ // or catch-all behavior rather than an explicit route table entry.
141
+ const SCHEMA_ONLY_PATHS = new Set([
142
+ // Served by the catch-all runtime proxy, not a dedicated route
143
+ "/{path}",
144
+ ]);
145
+
146
+ describe("route-schema sync guard", () => {
147
+ const schema = buildSchema() as { paths: Record<string, unknown> };
148
+ const schemaPaths = new Set(Object.keys(schema.paths));
149
+ const routePaths = extractRoutePathsFromSource();
150
+
151
+ test("every route path should have a corresponding schema entry", () => {
152
+ const missing: string[] = [];
153
+
154
+ for (const routePath of routePaths) {
155
+ if (EXCLUDED_FROM_SCHEMA.has(routePath)) continue;
156
+
157
+ // The catch-all regex `/^\//` matches everything — it maps to /{path} in the schema
158
+ if (routePath === "/") continue;
159
+
160
+ // Normalize regex-extracted parameterized paths to match schema naming.
161
+ // Route regexes use positional params ({param1}, {param2}) while the
162
+ // schema uses semantic names. We check if any schema path matches
163
+ // structurally (same segments, params in same positions).
164
+ const matched = findMatchingSchemaPath(routePath, schemaPaths);
165
+ if (!matched) {
166
+ missing.push(routePath);
167
+ }
168
+ }
169
+
170
+ expect(missing).toEqual([]);
171
+ });
172
+
173
+ test("every schema path should have a corresponding route", () => {
174
+ const orphaned: string[] = [];
175
+
176
+ for (const schemaPath of schemaPaths) {
177
+ if (SCHEMA_ONLY_PATHS.has(schemaPath)) continue;
178
+
179
+ const matched = findMatchingRoutePath(schemaPath, routePaths);
180
+ if (!matched) {
181
+ orphaned.push(schemaPath);
182
+ }
183
+ }
184
+
185
+ expect(orphaned).toEqual([]);
186
+ });
187
+
188
+ test("HTTP methods for each path should match between routes and schema", () => {
189
+ const routes = extractRoutesFromSource();
190
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
191
+ type HttpMethod = (typeof HTTP_METHODS)[number];
192
+
193
+ const mismatches: string[] = [];
194
+
195
+ // Build a map of path → set of methods from the route table.
196
+ // Routes without an explicit method match any method — skip those
197
+ // since the guard can't know which methods they actually handle.
198
+ const routeMethodsByPath = new Map<string, Set<HttpMethod>>();
199
+ for (const route of routes) {
200
+ if (EXCLUDED_FROM_SCHEMA.has(route.path)) continue;
201
+ if (route.path === "/") continue; // catch-all
202
+ if (!route.method) continue; // any-method routes can't be compared
203
+
204
+ const normalizedPath = resolveSchemaPath(route.path, schemaPaths);
205
+ if (!normalizedPath) continue;
206
+
207
+ let methods = routeMethodsByPath.get(normalizedPath);
208
+ if (!methods) {
209
+ methods = new Set();
210
+ routeMethodsByPath.set(normalizedPath, methods);
211
+ }
212
+ methods.add(route.method.toLowerCase() as HttpMethod);
213
+ }
214
+
215
+ // For each path that has explicit methods in the route table,
216
+ // verify the schema documents exactly the same set of methods.
217
+ for (const [path, routeMethods] of routeMethodsByPath) {
218
+ const schemaEntry = (
219
+ schema.paths as Record<string, Record<string, unknown>>
220
+ )[path];
221
+ if (!schemaEntry) continue; // path-level mismatch is caught by the other tests
222
+
223
+ const schemaMethods = new Set(
224
+ HTTP_METHODS.filter((m) => m in schemaEntry),
225
+ );
226
+
227
+ const missingFromSchema = [...routeMethods].filter(
228
+ (m) => !schemaMethods.has(m),
229
+ );
230
+ const extraInSchema = [...schemaMethods].filter(
231
+ (m) => !routeMethods.has(m),
232
+ );
233
+
234
+ for (const m of missingFromSchema) {
235
+ mismatches.push(
236
+ `${m.toUpperCase()} ${path}: in routes but not in schema`,
237
+ );
238
+ }
239
+ for (const m of extraInSchema) {
240
+ mismatches.push(
241
+ `${m.toUpperCase()} ${path}: in schema but not in routes`,
242
+ );
243
+ }
244
+ }
245
+
246
+ expect(mismatches).toEqual([]);
247
+ });
248
+
249
+ test("excluded routes list contains only paths that actually exist", () => {
250
+ // Catch-all is a special synthetic entry
251
+ const actualPaths = new Set(routePaths);
252
+ const stale = [...EXCLUDED_FROM_SCHEMA].filter(
253
+ (p) => p !== "catch-all" && !actualPaths.has(p),
254
+ );
255
+
256
+ expect(stale).toEqual([]);
257
+ });
258
+ });
259
+
260
+ /**
261
+ * Returns the schema path string that matches a route path, or null if none.
262
+ * Used by the method comparison test to look up schema entries by path.
263
+ */
264
+ function resolveSchemaPath(
265
+ routePath: string,
266
+ schemaPaths: Set<string>,
267
+ ): string | null {
268
+ if (schemaPaths.has(routePath)) return routePath;
269
+
270
+ const routeSegments = routePath.split("/");
271
+
272
+ for (const schemaPath of schemaPaths) {
273
+ const schemaSegments = schemaPath.split("/");
274
+ if (routeSegments.length !== schemaSegments.length) continue;
275
+
276
+ const matches = routeSegments.every((seg, i) => {
277
+ if (seg === schemaSegments[i]) return true;
278
+ if (seg.startsWith("{") && schemaSegments[i].startsWith("{")) return true;
279
+ return false;
280
+ });
281
+
282
+ if (matches) return schemaPath;
283
+ }
284
+
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Checks if a route path (possibly with {paramN} placeholders) matches
290
+ * any schema path (with semantic parameter names like {contactId}).
291
+ *
292
+ * Two paths match if they have the same number of segments and every
293
+ * non-parameter segment is identical.
294
+ */
295
+ function findMatchingSchemaPath(
296
+ routePath: string,
297
+ schemaPaths: Set<string>,
298
+ ): boolean {
299
+ // Direct match
300
+ if (schemaPaths.has(routePath)) return true;
301
+
302
+ const routeSegments = routePath.split("/");
303
+
304
+ for (const schemaPath of schemaPaths) {
305
+ const schemaSegments = schemaPath.split("/");
306
+ if (routeSegments.length !== schemaSegments.length) continue;
307
+
308
+ const matches = routeSegments.every((seg, i) => {
309
+ if (seg === schemaSegments[i]) return true;
310
+ // Both are parameters
311
+ if (seg.startsWith("{") && schemaSegments[i].startsWith("{")) return true;
312
+ return false;
313
+ });
314
+
315
+ if (matches) return true;
316
+ }
317
+
318
+ return false;
319
+ }
320
+
321
+ /**
322
+ * Checks if a schema path matches any route path, accounting for
323
+ * parameterized segments.
324
+ */
325
+ function findMatchingRoutePath(
326
+ schemaPath: string,
327
+ routePaths: string[],
328
+ ): boolean {
329
+ if (routePaths.includes(schemaPath)) return true;
330
+
331
+ const schemaSegments = schemaPath.split("/");
332
+
333
+ for (const routePath of routePaths) {
334
+ const routeSegments = routePath.split("/");
335
+ if (schemaSegments.length !== routeSegments.length) continue;
336
+
337
+ const matches = schemaSegments.every((seg, i) => {
338
+ if (seg === routeSegments[i]) return true;
339
+ if (seg.startsWith("{") && routeSegments[i].startsWith("{")) return true;
340
+ return false;
341
+ });
342
+
343
+ if (matches) return true;
344
+ }
345
+
346
+ return false;
347
+ }
@@ -6,7 +6,7 @@
6
6
  * policy decision, not a runtime configuration.
7
7
  */
8
8
 
9
- import type { AuthContext, Scope, ScopeProfile } from "./types.js";
9
+ import type { Scope, ScopeProfile } from "./types.js";
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // Profile -> scope mapping
@@ -48,13 +48,3 @@ const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
48
48
  export function resolveScopeProfile(profile: ScopeProfile): ReadonlySet<Scope> {
49
49
  return PROFILE_SCOPES[profile];
50
50
  }
51
-
52
- /** Check whether the auth context includes a specific scope. */
53
- export function hasScope(ctx: AuthContext, scope: Scope): boolean {
54
- return ctx.scopes.has(scope);
55
- }
56
-
57
- /** Check whether the auth context includes all of the given scopes. */
58
- export function hasAllScopes(ctx: AuthContext, ...scopes: Scope[]): boolean {
59
- return scopes.every((s) => ctx.scopes.has(s));
60
- }
@@ -6,12 +6,7 @@
6
6
  * ~/.vellum/protected/actor-token-signing-key.
7
7
  */
8
8
 
9
- import {
10
- createHash,
11
- createHmac,
12
- randomBytes,
13
- timingSafeEqual,
14
- } from "node:crypto";
9
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
15
10
  import {
16
11
  chmodSync,
17
12
  existsSync,
@@ -81,16 +76,6 @@ export function initSigningKey(key: Buffer): void {
81
76
  signingKey = key;
82
77
  }
83
78
 
84
- /**
85
- * Check whether the signing key has been initialized.
86
- *
87
- * Useful for test setup code that needs to ensure initSigningKey()
88
- * has been called before minting tokens.
89
- */
90
- export function isSigningKeyInitialized(): boolean {
91
- return signingKey !== null;
92
- }
93
-
94
79
  function getSigningKey(): Buffer {
95
80
  if (!signingKey) {
96
81
  throw new Error(
@@ -218,11 +203,3 @@ export function verifyToken(
218
203
 
219
204
  return { ok: true, claims };
220
205
  }
221
-
222
- // ---------------------------------------------------------------------------
223
- // Hash
224
- // ---------------------------------------------------------------------------
225
-
226
- export function hashToken(token: string): string {
227
- return createHash("sha256").update(token).digest("hex");
228
- }
@@ -21,15 +21,6 @@ export function parseChannelId(value: unknown): ChannelId | null {
21
21
  return isChannelId(value) ? value : null;
22
22
  }
23
23
 
24
- export function assertChannelId(value: unknown, field: string): ChannelId {
25
- if (!isChannelId(value)) {
26
- throw new Error(
27
- `Invalid channel ID for ${field}: ${String(value)}. Valid values: ${CHANNEL_IDS.join(", ")}`,
28
- );
29
- }
30
- return value;
31
- }
32
-
33
24
  export const INTERFACE_IDS = [
34
25
  "macos",
35
26
  "ios",
@@ -56,15 +47,6 @@ export function parseInterfaceId(value: unknown): InterfaceId | null {
56
47
  return isInterfaceId(value) ? value : null;
57
48
  }
58
49
 
59
- export function assertInterfaceId(value: unknown, field: string): InterfaceId {
60
- if (!isInterfaceId(value)) {
61
- throw new Error(
62
- `Invalid interface ID for ${field}: ${String(value)}. Valid values: ${INTERFACE_IDS.join(", ")}`,
63
- );
64
- }
65
- return value;
66
- }
67
-
68
50
  export interface TurnInterfaceContext {
69
51
  userMessageInterface: InterfaceId;
70
52
  assistantMessageInterface: InterfaceId;
@@ -42,11 +42,3 @@ function migrate(db: Database): void {
42
42
  )
43
43
  `);
44
44
  }
45
-
46
- /** Reset the singleton — used by tests. */
47
- export function resetGatewayDb(): void {
48
- if (db) {
49
- db.close();
50
- db = null;
51
- }
52
- }