@vellumai/assistant 0.12.2-dev.202609212215.5512e77 → 0.12.2-dev.202609212314.ad5e307
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/README.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
package/README.md
CHANGED
|
@@ -183,7 +183,7 @@ Internal forwarding routes (`/v1/internal/twilio/*`) are unaffected — these ac
|
|
|
183
183
|
The `/channels/inbound` endpoint requires a JWT with the `svc_gateway` principal type and `ingress.write` scope to prove the request originated from the gateway. This ensures channel messages can only arrive via the gateway (which performs webhook-level verification) and not via direct HTTP calls that bypass signature checks.
|
|
184
184
|
|
|
185
185
|
- **JWT-based enforcement:** The route policy in `route-policy.ts` restricts `/channels/inbound` to the `svc_gateway` principal type with `ingress.write` scope. Actor and local principals are rejected with 403.
|
|
186
|
-
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments),
|
|
186
|
+
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments), a request that supplies no `Authorization` header gets a synthetic context. A request that supplies one is verified normally, and a token that fails verification is rejected.
|
|
187
187
|
|
|
188
188
|
## Twilio Setup Primitive
|
|
189
189
|
|
package/package.json
CHANGED
|
@@ -241,8 +241,10 @@ function makeConfirmationEmittingSession(opts?: {
|
|
|
241
241
|
// Tests
|
|
242
242
|
// ---------------------------------------------------------------------------
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
245
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
246
|
+
// verified against the signing key, which these tests do not set up.
|
|
247
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
246
248
|
|
|
247
249
|
describe("standalone approval endpoints — HTTP layer", () => {
|
|
248
250
|
let server: RuntimeHttpServer;
|
|
@@ -82,8 +82,10 @@ await initializeDb();
|
|
|
82
82
|
// Helpers
|
|
83
83
|
// ---------------------------------------------------------------------------
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
86
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
87
|
+
// verified against the signing key, which these tests do not set up.
|
|
88
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
87
89
|
|
|
88
90
|
let ensuredConvIds = new Set<string>();
|
|
89
91
|
|
|
@@ -55,8 +55,10 @@ afterAll(() => {
|
|
|
55
55
|
resetDbForTesting();
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
59
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
60
|
+
// verified against the signing key, which these tests do not set up.
|
|
61
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
60
62
|
|
|
61
63
|
describe("Runtime attachment metadata", () => {
|
|
62
64
|
let server: RuntimeHttpServer;
|
|
@@ -302,8 +302,10 @@ function makePendingApprovalConversation(
|
|
|
302
302
|
// Tests
|
|
303
303
|
// ---------------------------------------------------------------------------
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
// This suite mocks isHttpAuthDisabled() to true, so a request carrying no
|
|
306
|
+
// bearer authenticates through the dev bypass. A bearer that is present is
|
|
307
|
+
// verified against the signing key, which these tests do not set up.
|
|
308
|
+
const AUTH_HEADERS: Record<string, string> = {};
|
|
307
309
|
|
|
308
310
|
describe("POST /v1/messages — queue-if-busy and hub publishing", () => {
|
|
309
311
|
let server: RuntimeHttpServer;
|
|
@@ -439,6 +439,23 @@ describe("GeminiEmbeddingBackend: batched text inputs", () => {
|
|
|
439
439
|
expect(vectors.map((v) => v[0])).toEqual(texts(250).map(textIndex));
|
|
440
440
|
});
|
|
441
441
|
|
|
442
|
+
test("managed text inputs use single calls from the first request and preserve vector order", async () => {
|
|
443
|
+
const fetchMock = routedFetch();
|
|
444
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
445
|
+
const backend = new GeminiEmbeddingBackend("test-key", "test-model", {
|
|
446
|
+
managedBaseUrl: "https://proxy.example.com/v1/runtime-proxy/gemini",
|
|
447
|
+
interCallDelayMs: 0,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(await backend.embed(texts(3))).toEqual([[0], [1], [2]]);
|
|
451
|
+
expect(await backend.embed(["t4", "t5"])).toEqual([[4], [5]]);
|
|
452
|
+
expect(calledUrls(fetchMock)).toEqual(
|
|
453
|
+
Array(5).fill(
|
|
454
|
+
"https://proxy.example.com/v1/runtime-proxy/gemini/v1beta/models/test-model:embedContent",
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
442
459
|
test("each batched request names the model with the models/ prefix and carries taskType and outputDimensionality", async () => {
|
|
443
460
|
const fetchMock = routedFetch();
|
|
444
461
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
@@ -71,7 +71,8 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Embed `inputs` in order.
|
|
74
|
+
* Embed `inputs` in order. Managed requests use single `embedContent`
|
|
75
|
+
* calls because Vertex does not expose the batch route. Direct text runs use
|
|
75
76
|
* `batchEmbedContents`, {@link GEMINI_EMBED_BATCH_SIZE} texts per round
|
|
76
77
|
* trip, so a corpus re-embed costs one request per hundred sections rather
|
|
77
78
|
* than one per section; a lone text and every multimodal input take the
|
|
@@ -99,6 +100,7 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
|
|
|
99
100
|
// Gather the run of text inputs starting here, up to one batch.
|
|
100
101
|
let end = i;
|
|
101
102
|
while (
|
|
103
|
+
!this.managed &&
|
|
102
104
|
!this.batchRouteUnavailable &&
|
|
103
105
|
end < normalized.length &&
|
|
104
106
|
end - i < GEMINI_EMBED_BATCH_SIZE &&
|
|
@@ -325,6 +325,144 @@ describe("authenticateRequest for /v1/host-browser-result", () => {
|
|
|
325
325
|
});
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Dev bypass covers only a request that supplied no Authorization header. A
|
|
330
|
+
// bearer sent to a platform pod (the gateway's exchange token, or a trusted
|
|
331
|
+
// contact's) is verified on its own terms.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
describe("authenticateRequest with auth disabled and a bearer present", () => {
|
|
335
|
+
beforeEach(() => {
|
|
336
|
+
authDisabled = true;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("uses the token's claims rather than the dev-bypass context", () => {
|
|
340
|
+
const token = mintValidToken({ sub: "actor:self:contact-principal" });
|
|
341
|
+
|
|
342
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const result = authenticateRequest(req);
|
|
348
|
+
expect(result.ok).toBe(true);
|
|
349
|
+
if (result.ok) {
|
|
350
|
+
expect(result.context.actorPrincipalId).toBe("contact-principal");
|
|
351
|
+
expect(result.context.policyEpoch).toBe(CURRENT_POLICY_EPOCH);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("accepts the gateway's exchange token", () => {
|
|
356
|
+
const token = mintValidToken({
|
|
357
|
+
sub: "svc:gateway:self",
|
|
358
|
+
scope_profile: "gateway_service_v1",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const result = authenticateRequest(req);
|
|
367
|
+
expect(result.ok).toBe(true);
|
|
368
|
+
if (result.ok) {
|
|
369
|
+
expect(result.context.principalType).toBe("svc_gateway");
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("accepts a gateway-audience token through the fallback", () => {
|
|
374
|
+
const token = mintValidToken({
|
|
375
|
+
aud: "vellum-gateway",
|
|
376
|
+
sub: "actor:self:local-client",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const result = authenticateRequest(req);
|
|
385
|
+
expect(result.ok).toBe(true);
|
|
386
|
+
if (result.ok) {
|
|
387
|
+
expect(result.context.actorPrincipalId).toBe("local-client");
|
|
388
|
+
expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test.each([
|
|
393
|
+
["malformed", () => "not-a-token.xxxxxxxxxxxxx"],
|
|
394
|
+
[
|
|
395
|
+
"expired",
|
|
396
|
+
() => mintValidToken({ exp: Math.floor(Date.now() / 1000) - 100 }),
|
|
397
|
+
],
|
|
398
|
+
[
|
|
399
|
+
"wrong-audience",
|
|
400
|
+
() => mintValidToken({ aud: "vellum-other" as TokenAudience }),
|
|
401
|
+
],
|
|
402
|
+
["unparseable-sub", () => mintValidToken({ sub: "garbage" })],
|
|
403
|
+
])("refuses a %s token rather than falling back", (_label, build) => {
|
|
404
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { Authorization: `Bearer ${build()}` },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const result = authenticateRequest(req);
|
|
410
|
+
expect(result.ok).toBe(false);
|
|
411
|
+
if (!result.ok) {
|
|
412
|
+
expect(result.response.status).toBe(401);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("still returns the dev-bypass context when no bearer is sent", () => {
|
|
417
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const result = authenticateRequest(req);
|
|
422
|
+
expect(result.ok).toBe(true);
|
|
423
|
+
if (result.ok) {
|
|
424
|
+
expect(result.context.actorPrincipalId).toBe("dev-bypass");
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("verifies a lowercase bearer scheme rather than bypassing it", () => {
|
|
429
|
+
const token = mintValidToken({ sub: "actor:self:contact-principal" });
|
|
430
|
+
|
|
431
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: { Authorization: `bearer ${token}` },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const result = authenticateRequest(req);
|
|
437
|
+
expect(result.ok).toBe(true);
|
|
438
|
+
if (result.ok) {
|
|
439
|
+
expect(result.context.actorPrincipalId).toBe("contact-principal");
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test.each([
|
|
444
|
+
["lowercase scheme with a bad token", "bearer not-a-jwt.xxxxxxxx"],
|
|
445
|
+
["empty credential", "Bearer "],
|
|
446
|
+
["non-bearer scheme", "Basic dXNlcjpwYXNz"],
|
|
447
|
+
["an empty header value", ""],
|
|
448
|
+
["a whitespace-only header value", " "],
|
|
449
|
+
])(
|
|
450
|
+
"refuses %s instead of granting the dev-bypass context",
|
|
451
|
+
(_label, header) => {
|
|
452
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: { Authorization: header },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const result = authenticateRequest(req);
|
|
458
|
+
expect(result.ok).toBe(false);
|
|
459
|
+
if (!result.ok) {
|
|
460
|
+
expect(result.response.status).toBe(401);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
328
466
|
// ---------------------------------------------------------------------------
|
|
329
467
|
// oauth_proxy_v1 grants: the narrow bearer a third-party CLI presents to the
|
|
330
468
|
// OAuth passthrough route. Accepted on either audience, and carrying nothing
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* Replaces both the legacy bearer shared-secret check and the
|
|
18
18
|
* actor-token HMAC middleware with a single JWT verification path.
|
|
19
19
|
*
|
|
20
|
-
* When DISABLE_HTTP_AUTH is set (platform-managed deployments),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* When DISABLE_HTTP_AUTH is set (platform-managed deployments), a request
|
|
21
|
+
* carrying no bearer gets a synthetic AuthContext so downstream code always
|
|
22
|
+
* has a typed context to consume. A request that carries one is verified
|
|
23
|
+
* normally, and a bad token is refused rather than falling back.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
@@ -73,17 +74,18 @@ function buildDevBypassContext(): AuthContext {
|
|
|
73
74
|
* The caller should return the error Response directly to the client.
|
|
74
75
|
*/
|
|
75
76
|
export function authenticateRequest(req: Request): AuthenticateResult {
|
|
76
|
-
// Dev bypass: skip JWT verification entirely
|
|
77
|
-
if (isHttpAuthDisabled()) {
|
|
78
|
-
return { ok: true, context: buildDevBypassContext() };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const path = new URL(req.url).pathname;
|
|
82
|
-
|
|
83
77
|
const rawToken = extractBearerToken(req);
|
|
84
78
|
if (!rawToken) {
|
|
79
|
+
// Dev bypass covers only a request that supplied no Authorization header
|
|
80
|
+
// at all. A header that is present but unusable (empty or whitespace-only
|
|
81
|
+
// value, a non-bearer scheme, a malformed credential) is refused rather
|
|
82
|
+
// than handed the guardian context. `has` rather than `get`: an empty
|
|
83
|
+
// value reads back as "" and would otherwise pass as absent.
|
|
84
|
+
if (isHttpAuthDisabled() && !req.headers.has("authorization")) {
|
|
85
|
+
return { ok: true, context: buildDevBypassContext() };
|
|
86
|
+
}
|
|
85
87
|
log.warn(
|
|
86
|
-
{ reason: "missing_token", path },
|
|
88
|
+
{ reason: "missing_token", path: new URL(req.url).pathname },
|
|
87
89
|
"Auth denied: missing Authorization header",
|
|
88
90
|
);
|
|
89
91
|
return {
|
|
@@ -100,6 +102,8 @@ export function authenticateRequest(req: Request): AuthenticateResult {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
const path = new URL(req.url).pathname;
|
|
106
|
+
|
|
103
107
|
// Verify the JWT — prefer vellum-daemon audience (gateway-proxied requests
|
|
104
108
|
// and daemon-minted tokens), but also accept vellum-gateway audience for
|
|
105
109
|
// direct local clients (macOS SettingsStore) that hold a guardian-issued JWT
|
|
@@ -156,8 +156,14 @@ export function isPrivateNetworkOrigin(req: Request): boolean {
|
|
|
156
156
|
/**
|
|
157
157
|
* Extract and validate a bearer token from the Authorization header.
|
|
158
158
|
* Returns the token string if present, or null.
|
|
159
|
+
*
|
|
160
|
+
* The scheme is matched case-insensitively, per RFC 7235 and to match the
|
|
161
|
+
* gateway's own parsing. An empty credential returns null.
|
|
159
162
|
*/
|
|
160
163
|
export function extractBearerToken(req: Request): string | null {
|
|
161
164
|
const authHeader = req.headers.get("authorization");
|
|
162
|
-
|
|
165
|
+
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return authHeader.slice(7) || null;
|
|
163
169
|
}
|