Skip to content

Commit efd0ee8

Browse files
authored
feat(core,sdk): support additional environment API keys (#4387)
## Summary Additional environment API keys can use SDK APIs that require public access tokens. The SDK detects the additional-key format and asks the Trigger.dev server to mint scoped tokens instead of attempting to sign them locally. Root environment keys retain their existing local-signing behavior. Trigger and batch clients also prefer server-issued tokens returned in response headers while preserving compatibility with older servers. ## Deployment notes This package update is safe to publish before servers expose additional key creation. Existing root keys continue to use the current path, while an additional key used with an older server fails with an actionable upgrade error.
1 parent 3e53404 commit efd0ee8

9 files changed

Lines changed: 464 additions & 24 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Allow additional environment API keys to create scoped public access tokens through the Trigger.dev API. Use server-issued public access tokens for batch operations so environment-scoped API keys can read batch results.

packages/core/src/v3/apiClient/index.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { nanoid } from "nanoid";
22
import { z } from "zod";
33
import { VERSION } from "../../version.js";
4+
import { isAdditionalApiKey } from "../apiKeys.js";
45
import type { ApiClientConfiguration } from "../apiClientManager-api.js";
56
import { generateJWT } from "../jwt.js";
67
import {
@@ -201,6 +202,15 @@ export type {
201202

202203
export * from "./getBranch.js";
203204

205+
export type CreatePublicTokenRequestBody = {
206+
scopes: string[];
207+
expirationTime?: string | number;
208+
oneTimeUse?: boolean;
209+
realtime?: { skipColumns?: string[] };
210+
};
211+
212+
const CreatePublicTokenResponseBody = z.object({ token: z.string() });
213+
204214
/**
205215
* Trigger.dev v3 API client
206216
*/
@@ -230,6 +240,21 @@ export class ApiClient {
230240
this.futureFlags = futureFlags;
231241
}
232242

243+
/**
244+
* Key for signing a public access token locally. Only root keys can do this —
245+
* an additional key isn't the environment's signing material, so a token
246+
* signed with one would never verify. Throw rather than return a dead token.
247+
*/
248+
get #selfSigningKey(): string {
249+
if (isAdditionalApiKey(this.accessToken)) {
250+
throw new Error(
251+
"This additional API key cannot self-sign public tokens, and the server did not return one. Upgrade the server or use the root API key."
252+
);
253+
}
254+
255+
return this.accessToken;
256+
}
257+
233258
get fetchClient(): typeof fetch {
234259
const headers = this.#getHeaders(false);
235260

@@ -325,7 +350,7 @@ export class ApiClient {
325350
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
326351

327352
const jwt = await generateJWT({
328-
secretKey: this.accessToken,
353+
secretKey: this.#selfSigningKey,
329354
payload: {
330355
...claims,
331356
scopes: [`read:runs:${data.id}`],
@@ -359,11 +384,20 @@ export class ApiClient {
359384
)
360385
.withResponse()
361386
.then(async ({ data, response }) => {
387+
const jwtHeader = response.headers.get("x-trigger-jwt");
388+
389+
if (typeof jwtHeader === "string") {
390+
return {
391+
...data,
392+
publicAccessToken: jwtHeader,
393+
};
394+
}
395+
362396
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
363397
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
364398

365399
const jwt = await generateJWT({
366-
secretKey: this.accessToken,
400+
secretKey: this.#selfSigningKey,
367401
payload: {
368402
...claims,
369403
scopes: [`read:batch:${data.id}`],
@@ -407,11 +441,20 @@ export class ApiClient {
407441
)
408442
.withResponse()
409443
.then(async ({ data, response }) => {
444+
const jwtHeader = response.headers.get("x-trigger-jwt");
445+
446+
if (typeof jwtHeader === "string") {
447+
return {
448+
...data,
449+
publicAccessToken: jwtHeader,
450+
};
451+
}
452+
410453
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
411454
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
412455

413456
const jwt = await generateJWT({
414-
secretKey: this.accessToken,
457+
secretKey: this.#selfSigningKey,
415458
payload: {
416459
...claims,
417460
scopes: [`read:batch:${data.id}`],
@@ -1107,7 +1150,7 @@ export class ApiClient {
11071150
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
11081151

11091152
const jwt = await generateJWT({
1110-
secretKey: this.accessToken,
1153+
secretKey: this.#selfSigningKey,
11111154
payload: {
11121155
...claims,
11131156
scopes: [`write:waitpoints:${data.id}`],
@@ -1870,6 +1913,22 @@ export class ApiClient {
18701913
);
18711914
}
18721915

1916+
async createPublicToken(
1917+
body: CreatePublicTokenRequestBody,
1918+
requestOptions?: ZodFetchOptions
1919+
): Promise<{ token: string }> {
1920+
return zodfetch(
1921+
CreatePublicTokenResponseBody,
1922+
`${this.baseUrl}/api/v1/auth/public-tokens`,
1923+
{
1924+
method: "POST",
1925+
headers: this.#getHeaders(false),
1926+
body: JSON.stringify(body),
1927+
},
1928+
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
1929+
);
1930+
}
1931+
18731932
retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) {
18741933
return zodfetch(
18751934
RetrieveBatchV2Response,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isAdditionalApiKey } from "./apiKeys.js";
3+
4+
describe("isAdditionalApiKey", () => {
5+
it.each(["dev", "stg", "prod", "preview"])("recognizes %s additional keys", (environment) => {
6+
expect(isAdditionalApiKey(`tr_${environment}_sk_0123456789abcdefghijklmn`)).toBe(true);
7+
});
8+
9+
it.each([
10+
"tr_prod_0123456789abcdefghijklmn",
11+
"tr_prod_sk_too-short",
12+
"tr_prod_sk_0123456789abcdefghijklmn_extra",
13+
"tr_test_sk_0123456789abcdefghijklmn",
14+
"tr_prod_sk_0123456789abcdefghijkl_",
15+
])("rejects %s", (key) => {
16+
expect(isAdditionalApiKey(key)).toBe(false);
17+
});
18+
});

packages/core/src/v3/apiKeys.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const ADDITIONAL_API_KEY_PATTERN = /^tr_(dev|stg|prod|preview)_sk_[0-9a-zA-Z]{24}$/;
2+
3+
/**
4+
* Returns whether a key has the additional environment API key format.
5+
*
6+
* This is only a routing hint. It must never be used as a security boundary;
7+
* servers authenticate additional keys by resolving their stored hash.
8+
*/
9+
export function isAdditionalApiKey(key: string): boolean {
10+
return ADDITIONAL_API_KEY_PATTERN.test(key);
11+
}

packages/core/src/v3/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export * from "./resource-catalog-api.js";
3030
export * from "./types/index.js";
3131
export { links } from "./links.js";
3232
export * from "./jwt.js";
33+
export * from "./apiKeys.js";
3334
export * from "./workloadDeploymentToken.js";
3435
export * from "./idempotencyKeys.js";
3536
export * from "./streams/asyncIterableStream.js";

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type inferSchemaOut,
1515
InputStreamOncePromise,
1616
type InputStreamOnceResult,
17+
isAdditionalApiKey,
1718
isSchemaZodEsque,
1819
logger,
1920
type MachinePresetName,
@@ -10600,24 +10601,53 @@ async function mintPublicTokenWithOverride(args: {
1060010601
throw new Error("chat.createStartSessionAction: no API access token configured for JWT mint.");
1060110602
}
1060210603
const ctx: ChatStartSessionEndpointContext = { endpoint: "auth", chatId: args.chatId };
10603-
const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}/api/v1/auth/jwt/claims`;
10604+
const scopes = [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`];
10605+
const serverMint = isAdditionalApiKey(accessToken);
10606+
const endpoint = serverMint ? "/api/v1/auth/public-tokens" : "/api/v1/auth/jwt/claims";
10607+
const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}${endpoint}`;
1060410608
const init: RequestInit = {
1060510609
method: "POST",
1060610610
headers: overrideRequestHeaders(accessToken),
10611+
...(serverMint
10612+
? {
10613+
body: JSON.stringify({
10614+
scopes,
10615+
expirationTime:
10616+
args.expirationTime instanceof Date
10617+
? Math.floor(args.expirationTime.getTime() / 1000)
10618+
: args.expirationTime,
10619+
}),
10620+
}
10621+
: {}),
1060710622
};
1060810623
const response = args.fetchOverride
1060910624
? await args.fetchOverride(url, init, ctx)
1061010625
: await fetch(url, init);
1061110626
if (!response.ok) {
10627+
// An additional API key cannot self-sign, so it must use the server mint
10628+
// endpoint. On a server too old to expose it, explain the recovery path
10629+
// instead of surfacing a bare 404 (mirrors auth.createServerPublicToken).
10630+
if (serverMint && response.status === 404) {
10631+
throw new Error(
10632+
"This additional API key cannot self-sign public tokens, and the server does not support public-token minting. Upgrade the server or use the root API key."
10633+
);
10634+
}
1061210635
const text = await response.text().catch(() => "");
1061310636
throw new Error(`auth.createPublicToken failed: ${response.status} ${text}`);
1061410637
}
10615-
const claims = (await response.json()) as Record<string, unknown>;
10638+
const responseBody = (await response.json()) as Record<string, unknown>;
10639+
if (serverMint) {
10640+
if (typeof responseBody.token !== "string") {
10641+
throw new Error("auth.createPublicToken failed: server response did not include a token");
10642+
}
10643+
return responseBody.token;
10644+
}
10645+
1061610646
return generateJWT({
1061710647
secretKey: accessToken,
1061810648
payload: {
10619-
...claims,
10620-
scopes: [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`],
10649+
...responseBody,
10650+
scopes,
1062110651
},
1062210652
expirationTime: args.expirationTime,
1062310653
});

0 commit comments

Comments
 (0)