@velum-labs/routekit-gateway 0.11.0 → 0.13.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.
package/dist/auth.d.ts CHANGED
@@ -1,10 +1,32 @@
1
1
  import type { IncomingMessage } from "node:http";
2
+ /** Trusted principal header injected by the switching proxy after auth. */
3
+ export declare const ROUTEKIT_PRINCIPAL_HEADER = "x-routekit-principal";
4
+ export type GatewayPrincipal = {
5
+ id: string;
6
+ label: string;
7
+ role: "owner" | "admin";
8
+ };
2
9
  /** Constant-time string equality (length-independent; no timing leaks). */
3
10
  export declare function timingSafeStringEqual(a: string, b: string): boolean;
4
11
  /** Verify an `Authorization: Bearer <token>` header value. */
5
12
  export declare function verifyBearerToken(header: string | undefined, expected: string): boolean;
13
+ /**
14
+ * Extract the presented credential from either `Authorization: Bearer` or
15
+ * `x-api-key`. Returns undefined when neither is present.
16
+ */
17
+ export declare function presentedCredential(req: IncomingMessage): string | undefined;
6
18
  /**
7
19
  * The gateway's request-authorization rule: a matching bearer token or a
8
20
  * matching `x-api-key` header.
9
21
  */
10
22
  export declare function authorizedRequest(req: IncomingMessage, token: string): boolean;
23
+ /**
24
+ * Resolve a principal from the request using a token registry. Falls back to
25
+ * a single shared token (legacy) when `legacyToken` matches.
26
+ */
27
+ export declare function resolvePrincipal(req: IncomingMessage, input: {
28
+ resolve?: (presented: string) => GatewayPrincipal | undefined;
29
+ legacyToken?: string;
30
+ }): GatewayPrincipal | undefined;
31
+ /** Parse a trusted principal header value injected by the switching proxy. */
32
+ export declare function parsePrincipalHeader(value: string | undefined): GatewayPrincipal | undefined;
package/dist/auth.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * neither content nor length differences are observable through timing.
5
5
  */
6
6
  import { createHash, timingSafeEqual } from "node:crypto";
7
+ /** Trusted principal header injected by the switching proxy after auth. */
8
+ export const ROUTEKIT_PRINCIPAL_HEADER = "x-routekit-principal";
7
9
  /** Constant-time string equality (length-independent; no timing leaks). */
8
10
  export function timingSafeStringEqual(a, b) {
9
11
  const aDigest = createHash("sha256").update(a, "utf8").digest();
@@ -16,6 +18,21 @@ export function timingSafeStringEqual(a, b) {
16
18
  export function verifyBearerToken(header, expected) {
17
19
  return typeof header === "string" && timingSafeStringEqual(header, `Bearer ${expected}`);
18
20
  }
21
+ /**
22
+ * Extract the presented credential from either `Authorization: Bearer` or
23
+ * `x-api-key`. Returns undefined when neither is present.
24
+ */
25
+ export function presentedCredential(req) {
26
+ const authorization = req.headers.authorization;
27
+ if (typeof authorization === "string" && authorization.startsWith("Bearer ")) {
28
+ const token = authorization.slice("Bearer ".length);
29
+ return token.length > 0 ? token : undefined;
30
+ }
31
+ const apiKey = req.headers["x-api-key"];
32
+ if (typeof apiKey === "string" && apiKey.length > 0)
33
+ return apiKey;
34
+ return undefined;
35
+ }
19
36
  /**
20
37
  * The gateway's request-authorization rule: a matching bearer token or a
21
38
  * matching `x-api-key` header.
@@ -26,3 +43,38 @@ export function authorizedRequest(req, token) {
26
43
  const apiKey = req.headers["x-api-key"];
27
44
  return typeof apiKey === "string" && timingSafeStringEqual(apiKey, token);
28
45
  }
46
+ /**
47
+ * Resolve a principal from the request using a token registry. Falls back to
48
+ * a single shared token (legacy) when `legacyToken` matches.
49
+ */
50
+ export function resolvePrincipal(req, input) {
51
+ const presented = presentedCredential(req);
52
+ if (presented === undefined)
53
+ return undefined;
54
+ if (input.resolve !== undefined) {
55
+ const principal = input.resolve(presented);
56
+ if (principal !== undefined)
57
+ return principal;
58
+ }
59
+ if (input.legacyToken !== undefined && timingSafeStringEqual(presented, input.legacyToken)) {
60
+ return { id: "default", label: "default", role: "owner" };
61
+ }
62
+ return undefined;
63
+ }
64
+ /** Parse a trusted principal header value injected by the switching proxy. */
65
+ export function parsePrincipalHeader(value) {
66
+ if (value === undefined || value.length === 0)
67
+ return undefined;
68
+ try {
69
+ const parsed = JSON.parse(value);
70
+ if (typeof parsed.id !== "string" ||
71
+ typeof parsed.label !== "string" ||
72
+ (parsed.role !== "owner" && parsed.role !== "admin")) {
73
+ return undefined;
74
+ }
75
+ return { id: parsed.id, label: parsed.label, role: parsed.role };
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
package/dist/index.d.ts CHANGED
@@ -35,7 +35,8 @@ export { DEFAULT_MODEL_PRICING, estimateCost, formatUsd, lookupPricing, meterCal
35
35
  export type { CallCostRecord, ModelPricing, ProviderCostMetadata, TokenUsage } from "./cost.js";
36
36
  export { buildModelCallRecord, MODEL_CALL_ID_HEADER, modelCallId, readProducerVersion, resolveProducerGitSha, responseBodyHash, UNKNOWN_GIT_SHA } from "./provenance.js";
37
37
  export type { GatewayDialect, ModelCallRecord, ModelGatewayCallContext, ModelGatewayCallResult, ProvenanceSink } from "./provenance.js";
38
- export { authorizedRequest } from "./auth.js";
38
+ export { authorizedRequest, parsePrincipalHeader, presentedCredential, resolvePrincipal, ROUTEKIT_PRINCIPAL_HEADER } from "./auth.js";
39
+ export type { GatewayPrincipal } from "./auth.js";
39
40
  export { errorEvent, finishChunk, noticeChunk, reasoningChunk, sseResponse } from "./sse-wire.js";
40
41
  export { ChatStreamAssembler } from "./sse/chat-assembler.js";
41
42
  export type { AssembledToolCall } from "./sse/chat-assembler.js";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ export { ACP_PROTOCOL_VERSION, runAcpAgent } from "./acp-agent.js";
18
18
  export { ACP_REGISTRY_URL, fetchAcpRegistry, installAcpAdapters } from "./acp-registry.js";
19
19
  export { DEFAULT_MODEL_PRICING, estimateCost, formatUsd, lookupPricing, meterCall, parseUsage, parseUsageFromSse } from "./cost.js";
20
20
  export { buildModelCallRecord, MODEL_CALL_ID_HEADER, modelCallId, readProducerVersion, resolveProducerGitSha, responseBodyHash, UNKNOWN_GIT_SHA } from "./provenance.js";
21
- export { authorizedRequest } from "./auth.js";
21
+ export { authorizedRequest, parsePrincipalHeader, presentedCredential, resolvePrincipal, ROUTEKIT_PRINCIPAL_HEADER } from "./auth.js";
22
22
  export { errorEvent, finishChunk, noticeChunk, reasoningChunk, sseResponse } from "./sse-wire.js";
23
23
  export { ChatStreamAssembler } from "./sse/chat-assembler.js";
24
24
  export { decodeBufferedSse, SseDecoder, SseParseError } from "./sse/parse.js";
@@ -153,6 +153,16 @@ export function buildModelCallRecord(context, result) {
153
153
  ...(context.attribution.account !== undefined
154
154
  ? { account: { seat: context.attribution.account.seat } }
155
155
  : {}),
156
+ ...(context.attribution.principal !== undefined
157
+ ? {
158
+ principal: {
159
+ token_id: context.attribution.principal.token_id,
160
+ ...(context.attribution.principal.label !== undefined
161
+ ? { label: context.attribution.principal.label }
162
+ : {})
163
+ }
164
+ }
165
+ : {}),
156
166
  attempts: context.attribution.attempts,
157
167
  retries: context.attribution.retries,
158
168
  account_failovers: context.attribution.account_failovers
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { createServer } from "node:http";
3
3
  import { ProviderFailureError } from "@velum-labs/routekit-contracts";
4
4
  import { anthropicModelsResponse, handleAnthropicMessages, handleCountTokens, resolveClaudeModelAlias } from "./adapters/anthropic.js";
5
5
  import { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
6
- import { authorizedRequest } from "./auth.js";
6
+ import { authorizedRequest, parsePrincipalHeader, ROUTEKIT_PRINCIPAL_HEADER } from "./auth.js";
7
7
  import { cursorModelVariants, isCursorChatBody, resolveCursorModelSelection, translateCursorRequest } from "./adapters/cursor.js";
8
8
  import { withReasoningSelection } from "./adapters/openai-chat-wire.js";
9
9
  import { handleResponses } from "./adapters/responses.js";
@@ -331,6 +331,16 @@ export async function startGateway(options) {
331
331
  return;
332
332
  }
333
333
  const requestContext = { headers: req.headers };
334
+ const headerPrincipal = parsePrincipalHeader(typeof req.headers[ROUTEKIT_PRINCIPAL_HEADER] === "string"
335
+ ? req.headers[ROUTEKIT_PRINCIPAL_HEADER]
336
+ : undefined);
337
+ const requestPrincipal = headerPrincipal === undefined
338
+ ? undefined
339
+ : { token_id: headerPrincipal.id, label: headerPrincipal.label };
340
+ const dispatchModelCall = (route) => handleModelCall(res, provenance, {
341
+ ...route,
342
+ ...(requestPrincipal !== undefined ? { principal: requestPrincipal } : {})
343
+ });
334
344
  if (method === "POST" && (path === "/v1/chat/completions" || path === "/chat/completions")) {
335
345
  const raw = await readJson(req, res);
336
346
  if (raw === NO_BODY)
@@ -338,7 +348,7 @@ export async function startGateway(options) {
338
348
  if (rejectInvalid(res, validateChatRequest(raw)))
339
349
  return;
340
350
  const body = withDefaultModel(raw, backend.defaultModel);
341
- await handleModelCall(res, provenance, {
351
+ await dispatchModelCall({
342
352
  dialect: "openai-chat",
343
353
  body,
344
354
  defaultModel: backend.defaultModel,
@@ -394,7 +404,7 @@ export async function startGateway(options) {
394
404
  if (rejectInvalid(res, validateChatRequest(translated)))
395
405
  return;
396
406
  const body = withDefaultModel(translated, backend.defaultModel);
397
- await handleModelCall(res, provenance, {
407
+ await dispatchModelCall({
398
408
  dialect: "openai-chat",
399
409
  body,
400
410
  defaultModel: backend.defaultModel,
@@ -412,7 +422,7 @@ export async function startGateway(options) {
412
422
  if (raw === NO_BODY)
413
423
  return;
414
424
  const body = withDefaultModel(raw, backend.defaultModel);
415
- await handleModelCall(res, provenance, {
425
+ await dispatchModelCall({
416
426
  dialect: "openai-embeddings",
417
427
  body,
418
428
  defaultModel: backend.defaultModel,
@@ -478,7 +488,7 @@ export async function startGateway(options) {
478
488
  if (anthropicRelay !== undefined &&
479
489
  route?.provider === "claude-code") {
480
490
  const relayBody = withModel(rawBody, route.nativeId);
481
- await handleModelCall(res, provenance, {
491
+ await dispatchModelCall({
482
492
  dialect: "anthropic-messages",
483
493
  body,
484
494
  defaultModel: backend.defaultModel,
@@ -497,7 +507,7 @@ export async function startGateway(options) {
497
507
  if (anthropicRelay !== undefined &&
498
508
  backend.resolveModelRoute === undefined &&
499
509
  anthropicRelay.shouldRelay(req.headers, requestedModel, (model) => backend.servesModel?.(model) ?? false)) {
500
- await handleModelCall(res, provenance, {
510
+ await dispatchModelCall({
501
511
  dialect: "anthropic-messages",
502
512
  body,
503
513
  defaultModel: backend.defaultModel,
@@ -515,7 +525,7 @@ export async function startGateway(options) {
515
525
  });
516
526
  return;
517
527
  }
518
- await handleModelCall(res, provenance, {
528
+ await dispatchModelCall({
519
529
  dialect: "anthropic-messages",
520
530
  body,
521
531
  defaultModel: backend.defaultModel,
@@ -548,7 +558,7 @@ export async function startGateway(options) {
548
558
  : resolveNativeModelRoute(backend, "codex", requestedModel);
549
559
  }
550
560
  catch (error) {
551
- await handleModelCall(res, provenance, {
561
+ await dispatchModelCall({
552
562
  dialect: "openai-responses",
553
563
  body,
554
564
  defaultModel: backend.defaultModel,
@@ -566,7 +576,7 @@ export async function startGateway(options) {
566
576
  if (codexProviderRelay !== undefined &&
567
577
  route?.provider === "codex") {
568
578
  const relayBody = withModel(body, route.nativeId);
569
- await handleModelCall(res, provenance, {
579
+ await dispatchModelCall({
570
580
  dialect: "openai-responses",
571
581
  body: canonicalBody,
572
582
  defaultModel: backend.defaultModel,
@@ -587,7 +597,7 @@ export async function startGateway(options) {
587
597
  (codexProviderRelay === undefined ||
588
598
  backend.resolveModelRoute === undefined) &&
589
599
  codexRequestRelay.shouldRelay(req.headers, requestedModel, (model) => backend.servesModel?.(model) ?? false)) {
590
- await handleModelCall(res, provenance, {
600
+ await dispatchModelCall({
591
601
  dialect: "openai-responses",
592
602
  body,
593
603
  defaultModel: backend.defaultModel,
@@ -605,7 +615,7 @@ export async function startGateway(options) {
605
615
  });
606
616
  return;
607
617
  }
608
- await handleModelCall(res, provenance, {
618
+ await dispatchModelCall({
609
619
  dialect: "openai-responses",
610
620
  body: canonicalBody,
611
621
  defaultModel: backend.defaultModel,
@@ -836,6 +846,7 @@ export function collectAttribution(seed) {
836
846
  provider: current.provider,
837
847
  billing_mode: current.billing_mode,
838
848
  ...(current.account !== undefined ? { account: current.account } : {}),
849
+ ...(current.principal !== undefined ? { principal: current.principal } : {}),
839
850
  attempts: Math.max(1, attempts),
840
851
  retries,
841
852
  account_failovers: accountFailovers
@@ -845,7 +856,10 @@ export function collectAttribution(seed) {
845
856
  }
846
857
  async function handleModelCall(res, sink, route) {
847
858
  const callId = modelCallId();
848
- const attribution = collectAttribution(route.attribution);
859
+ const attribution = collectAttribution({
860
+ ...route.attribution,
861
+ ...(route.principal !== undefined ? { principal: route.principal } : {})
862
+ });
849
863
  const started = Date.now();
850
864
  const startedAt = new Date(started).toISOString();
851
865
  const context = {
@@ -1,3 +1,4 @@
1
+ import { type GatewayPrincipal } from "./auth.js";
1
2
  export type SwitchingGatewayProxy = {
2
3
  url(): string;
3
4
  port(): number;
@@ -12,4 +13,6 @@ export declare function startSwitchingGatewayProxy(input: {
12
13
  host?: string;
13
14
  port?: number;
14
15
  authToken?: string;
16
+ /** Resolve a named data-plane principal; preferred over `authToken` alone. */
17
+ resolveDataPrincipal?: (presented: string) => GatewayPrincipal | undefined;
15
18
  }): Promise<SwitchingGatewayProxy>;
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createServer } from "node:http";
11
11
  import { assertAuthenticatedBind, trimTrailingSlashes } from "@velum-labs/routekit-runtime";
12
- import { authorizedRequest } from "./auth.js";
12
+ import { ROUTEKIT_PRINCIPAL_HEADER, authorizedRequest, resolvePrincipal } from "./auth.js";
13
13
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
14
14
  const HOP_BY_HOP = new Set([
15
15
  "connection",
@@ -21,11 +21,18 @@ const HOP_BY_HOP = new Set([
21
21
  "transfer-encoding",
22
22
  "upgrade"
23
23
  ]);
24
- function requestHeaders(headers) {
24
+ function requestHeaders(headers, principal) {
25
25
  const result = new Headers();
26
26
  for (const [name, value] of Object.entries(headers)) {
27
- if (value === undefined || HOP_BY_HOP.has(name.toLowerCase()) || name === "host")
27
+ const lower = name.toLowerCase();
28
+ if (value === undefined ||
29
+ HOP_BY_HOP.has(lower) ||
30
+ lower === "host" ||
31
+ // Never forward a client-supplied principal; the proxy is the only
32
+ // trusted source of identity for the inner gateway.
33
+ lower === ROUTEKIT_PRINCIPAL_HEADER) {
28
34
  continue;
35
+ }
29
36
  if (Array.isArray(value)) {
30
37
  for (const entry of value)
31
38
  result.append(name, entry);
@@ -34,6 +41,9 @@ function requestHeaders(headers) {
34
41
  result.set(name, value);
35
42
  }
36
43
  }
44
+ if (principal !== undefined) {
45
+ result.set(ROUTEKIT_PRINCIPAL_HEADER, JSON.stringify(principal));
46
+ }
37
47
  return result;
38
48
  }
39
49
  async function requestBody(req) {
@@ -92,6 +102,7 @@ async function pipe(res, upstream) {
92
102
  export async function startSwitchingGatewayProxy(input) {
93
103
  const host = input.host ?? "127.0.0.1";
94
104
  assertAuthenticatedBind(host, input.authToken);
105
+ const authEnabled = input.resolveDataPrincipal !== undefined || input.authToken !== undefined;
95
106
  let active = {
96
107
  url: trimTrailingSlashes(input.target),
97
108
  leases: 0,
@@ -119,11 +130,28 @@ export async function startSwitchingGatewayProxy(input) {
119
130
  });
120
131
  return;
121
132
  }
122
- if (input.authToken !== undefined && !authorizedRequest(req, input.authToken)) {
123
- writeJson(res, 401, {
124
- error: { message: "unauthorized", type: "auth_error" }
133
+ let principal;
134
+ if (authEnabled) {
135
+ principal = resolvePrincipal(req, {
136
+ ...(input.resolveDataPrincipal !== undefined
137
+ ? { resolve: input.resolveDataPrincipal }
138
+ : {}),
139
+ ...(input.authToken !== undefined ? { legacyToken: input.authToken } : {})
125
140
  });
126
- return;
141
+ if (principal === undefined) {
142
+ // Preserve the single-token path for callers that only pass authToken.
143
+ if (input.resolveDataPrincipal === undefined &&
144
+ input.authToken !== undefined &&
145
+ authorizedRequest(req, input.authToken)) {
146
+ principal = { id: "default", label: "default", role: "owner" };
147
+ }
148
+ else {
149
+ writeJson(res, 401, {
150
+ error: { message: "unauthorized", type: "auth_error" }
151
+ });
152
+ return;
153
+ }
154
+ }
127
155
  }
128
156
  const selected = active;
129
157
  selected.leases += 1;
@@ -137,7 +165,7 @@ export async function startSwitchingGatewayProxy(input) {
137
165
  const body = await requestBody(req);
138
166
  const upstream = await fetch(`${selected.url}${path}`, {
139
167
  method: req.method ?? "GET",
140
- headers: requestHeaders(req.headers),
168
+ headers: requestHeaders(req.headers, principal),
141
169
  ...(body !== undefined ? { body } : {}),
142
170
  signal: AbortSignal.any([
143
171
  aborter.signal,
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { authorizedRequest, timingSafeStringEqual, verifyBearerToken } from "../auth.js";
3
+ import { authorizedRequest, parsePrincipalHeader, resolvePrincipal, timingSafeStringEqual, verifyBearerToken } from "../auth.js";
4
4
  function requestWithHeaders(headers) {
5
5
  return { headers };
6
6
  }
@@ -23,3 +23,23 @@ test("authorizedRequest accepts bearer header or x-api-key, rejects otherwise",
23
23
  assert.equal(authorizedRequest(requestWithHeaders({ "x-api-key": "nope" }), "tok"), false);
24
24
  assert.equal(authorizedRequest(requestWithHeaders({}), "tok"), false);
25
25
  });
26
+ test("resolvePrincipal uses the registry and falls back to a legacy token", () => {
27
+ const principal = resolvePrincipal(requestWithHeaders({ authorization: "Bearer named" }), {
28
+ resolve: (presented) => presented === "named" ? { id: "abc", label: "bob", role: "admin" } : undefined
29
+ });
30
+ assert.deepEqual(principal, { id: "abc", label: "bob", role: "admin" });
31
+ assert.deepEqual(resolvePrincipal(requestWithHeaders({ "x-api-key": "legacy" }), { legacyToken: "legacy" }), { id: "default", label: "default", role: "owner" });
32
+ assert.equal(resolvePrincipal(requestWithHeaders({ authorization: "Bearer nope" }), {
33
+ resolve: () => undefined,
34
+ legacyToken: "legacy"
35
+ }), undefined);
36
+ });
37
+ test("parsePrincipalHeader accepts only well-formed JSON principals", () => {
38
+ assert.deepEqual(parsePrincipalHeader('{"id":"a","label":"bob","role":"admin"}'), {
39
+ id: "a",
40
+ label: "bob",
41
+ role: "admin"
42
+ });
43
+ assert.equal(parsePrincipalHeader('{"id":"a","label":"bob","role":"root"}'), undefined);
44
+ assert.equal(parsePrincipalHeader("not-json"), undefined);
45
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "0.11.0",
4
+ "version": "0.13.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -27,10 +27,10 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "zod": "4.4.3",
30
- "@velum-labs/routekit-contracts": "0.11.0",
31
- "@velum-labs/routekit-registry": "0.11.0",
32
- "@velum-labs/routekit-tracing": "0.11.0",
33
- "@velum-labs/routekit-runtime": "0.11.0"
30
+ "@velum-labs/routekit-contracts": "0.13.0",
31
+ "@velum-labs/routekit-registry": "0.13.0",
32
+ "@velum-labs/routekit-runtime": "0.13.0",
33
+ "@velum-labs/routekit-tracing": "0.13.0"
34
34
  },
35
35
  "keywords": [
36
36
  "routekit",