@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 +0 -3
- package/README.md +32 -33
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +347 -0
- package/src/auth/scopes.ts +1 -11
- package/src/auth/token-service.ts +1 -24
- package/src/channels/types.ts +0 -18
- package/src/db/connection.ts +0 -8
- package/src/feature-flag-registry.json +5 -5
- package/src/http/routes/telegram-webhook.ts +23 -29
- package/src/http/routes/twilio-sms-webhook.ts +71 -91
- package/src/http/routes/whatsapp-webhook.ts +69 -88
- package/src/schema.ts +296 -0
- package/src/webhook-copy.ts +9 -0
- package/src/webhook-pipeline.ts +107 -0
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
|
|
30
|
-
| -------------------------------------- |
|
|
31
|
-
| `TELEGRAM_BOT_TOKEN` | No
|
|
32
|
-
| `TELEGRAM_WEBHOOK_SECRET` | No
|
|
33
|
-
| `TELEGRAM_API_BASE_URL` | No
|
|
34
|
-
| `ASSISTANT_RUNTIME_BASE_URL` | Yes
|
|
35
|
-
| `GATEWAY_ASSISTANT_ROUTING_JSON` | No
|
|
36
|
-
| `GATEWAY_DEFAULT_ASSISTANT_ID` | No
|
|
37
|
-
| `GATEWAY_UNMAPPED_POLICY` | No
|
|
38
|
-
| `GATEWAY_PORT` | No
|
|
39
|
-
| `GATEWAY_INTERNAL_BASE_URL` | No
|
|
40
|
-
| `INGRESS_PUBLIC_BASE_URL` | No
|
|
41
|
-
| `GATEWAY_RUNTIME_PROXY_ENABLED` | No
|
|
42
|
-
| `GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH` | No
|
|
43
|
-
| `RUNTIME_BEARER_TOKEN` | No
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
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,
|
|
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 <
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/auth/scopes.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* policy decision, not a runtime configuration.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
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
|
-
}
|
package/src/channels/types.ts
CHANGED
|
@@ -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;
|