Skip to content

Commit 82ccc62

Browse files
committed
feat(core,sdk): support additional environment API keys
1 parent be45cf9 commit 82ccc62

10 files changed

Lines changed: 442 additions & 18 deletions

File tree

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

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ export type {
201201

202202
export * from "./getBranch.js";
203203

204+
export type CreatePublicTokenRequestBody = {
205+
scopes: string[];
206+
expirationTime?: string | number;
207+
oneTimeUse?: boolean;
208+
realtime?: { skipColumns?: string[] };
209+
};
210+
211+
const CreatePublicTokenResponseBody = z.object({ token: z.string() });
212+
204213
/**
205214
* Trigger.dev v3 API client
206215
*/
@@ -359,6 +368,15 @@ export class ApiClient {
359368
)
360369
.withResponse()
361370
.then(async ({ data, response }) => {
371+
const jwtHeader = response.headers.get("x-trigger-jwt");
372+
373+
if (typeof jwtHeader === "string") {
374+
return {
375+
...data,
376+
publicAccessToken: jwtHeader,
377+
};
378+
}
379+
362380
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
363381
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
364382

@@ -407,6 +425,15 @@ export class ApiClient {
407425
)
408426
.withResponse()
409427
.then(async ({ data, response }) => {
428+
const jwtHeader = response.headers.get("x-trigger-jwt");
429+
430+
if (typeof jwtHeader === "string") {
431+
return {
432+
...data,
433+
publicAccessToken: jwtHeader,
434+
};
435+
}
436+
410437
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
411438
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
412439

@@ -1870,6 +1897,22 @@ export class ApiClient {
18701897
);
18711898
}
18721899

1900+
async createPublicToken(
1901+
body: CreatePublicTokenRequestBody,
1902+
requestOptions?: ZodFetchOptions
1903+
): Promise<{ token: string }> {
1904+
return zodfetch(
1905+
CreatePublicTokenResponseBody,
1906+
`${this.baseUrl}/api/v1/auth/public-tokens`,
1907+
{
1908+
method: "POST",
1909+
headers: this.#getHeaders(false),
1910+
body: JSON.stringify(body),
1911+
},
1912+
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
1913+
);
1914+
}
1915+
18731916
retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) {
18741917
return zodfetch(
18751918
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}_ak_0123456789abcdefghijklmn`)).toBe(true);
7+
});
8+
9+
it.each([
10+
"tr_prod_0123456789abcdefghijklmn",
11+
"tr_prod_ak_too-short",
12+
"tr_prod_ak_0123456789abcdefghijklmn_extra",
13+
"tr_test_ak_0123456789abcdefghijklmn",
14+
"tr_prod_ak_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)_ak_[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
});
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2+
import type { AddressInfo } from "node:net";
3+
import { apiClientManager } from "@trigger.dev/core/v3";
4+
import { validateJWT } from "@trigger.dev/core/v3/jwt";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { auth } from "./auth.js";
7+
8+
type ReceivedRequest = {
9+
method: string;
10+
url: string;
11+
authorization?: string;
12+
body: unknown;
13+
};
14+
15+
describe("public token API key routing", () => {
16+
let server: Server;
17+
let baseUrl: string;
18+
let requests: ReceivedRequest[];
19+
let publicTokenStatus: number;
20+
21+
beforeEach(async () => {
22+
requests = [];
23+
publicTokenStatus = 200;
24+
server = createServer((request, response) => {
25+
void handleRequest(request, response, requests, () => publicTokenStatus);
26+
});
27+
await new Promise<void>((resolve) => {
28+
server.listen(0, "127.0.0.1", () => {
29+
const address = server.address() as AddressInfo;
30+
baseUrl = `http://127.0.0.1:${address.port}`;
31+
resolve();
32+
});
33+
});
34+
});
35+
36+
afterEach(async () => {
37+
await new Promise<void>((resolve) => server.close(() => resolve()));
38+
});
39+
40+
it("uses server minting for additional keys", async () => {
41+
const key = "tr_prod_ak_0123456789abcdefghijklmn";
42+
const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
43+
auth.createPublicToken({
44+
scopes: { read: { runs: ["run_123"] } },
45+
expirationTime: new Date("2030-01-01T00:00:00.000Z"),
46+
realtime: { skipColumns: ["payload"] },
47+
})
48+
);
49+
50+
expect(token).toBe("server-minted-token");
51+
expect(requests).toEqual([
52+
{
53+
method: "POST",
54+
url: "/api/v1/auth/public-tokens",
55+
authorization: `Bearer ${key}`,
56+
body: {
57+
scopes: ["read:runs:run_123"],
58+
expirationTime: 1893456000,
59+
realtime: { skipColumns: ["payload"] },
60+
},
61+
},
62+
]);
63+
});
64+
65+
it("server-mints trigger tokens with one-time-use semantics", async () => {
66+
const key = "tr_dev_ak_0123456789abcdefghijklmn";
67+
await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
68+
auth.createTriggerPublicToken(["task-one", "task-two"], { multipleUse: true })
69+
);
70+
71+
expect(requests[0]?.body).toEqual({
72+
scopes: ["trigger:tasks:task-one", "trigger:tasks:task-two"],
73+
oneTimeUse: false,
74+
});
75+
});
76+
77+
it("server-mints batch trigger tokens for additional keys", async () => {
78+
const key = "tr_stg_ak_0123456789abcdefghijklmn";
79+
await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
80+
auth.createBatchTriggerPublicToken("batch-task", {
81+
expirationTime: "2h",
82+
multipleUse: false,
83+
realtime: { skipColumns: ["payload"] },
84+
})
85+
);
86+
87+
expect(requests).toEqual([
88+
{
89+
method: "POST",
90+
url: "/api/v1/auth/public-tokens",
91+
authorization: `Bearer ${key}`,
92+
body: {
93+
scopes: ["batchTrigger:tasks:batch-task"],
94+
expirationTime: "2h",
95+
oneTimeUse: true,
96+
realtime: { skipColumns: ["payload"] },
97+
},
98+
},
99+
]);
100+
});
101+
102+
it("keeps root key self-minting unchanged", async () => {
103+
const key = "tr_prod_0123456789abcdefghijklmn";
104+
const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
105+
auth.createPublicToken({ scopes: { read: { runs: true } } })
106+
);
107+
108+
expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]);
109+
const validation = await validateJWT(token, key);
110+
expect(validation.ok).toBe(true);
111+
if (!validation.ok) return;
112+
expect(validation.payload).toMatchObject({
113+
sub: "env_test",
114+
pub: true,
115+
scopes: ["read:runs"],
116+
});
117+
});
118+
119+
it.each([
120+
["no options at all", undefined],
121+
["an empty scopes object", {}],
122+
["a scope group with nothing selected", { read: {} }],
123+
])("keeps root-key behavior unchanged for %s", async (_label, scopes) => {
124+
const key = "tr_prod_0123456789abcdefghijklmn";
125+
const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
126+
auth.createPublicToken(scopes === undefined ? undefined : { scopes })
127+
);
128+
129+
expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]);
130+
const validation = await validateJWT(token, key);
131+
expect(validation.ok).toBe(true);
132+
});
133+
134+
it.each([
135+
["no options at all", undefined],
136+
["an empty scopes object", {}],
137+
["a scope group with nothing selected", { read: {} }],
138+
])("rejects %s clearly for additional keys", async (_label, scopes) => {
139+
const key = "tr_prod_ak_0123456789abcdefghijklmn";
140+
const promise = apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
141+
auth.createPublicToken(scopes === undefined ? undefined : { scopes })
142+
);
143+
144+
await expect(promise).rejects.toThrow(
145+
"requires at least one scope when using an additional API key"
146+
);
147+
expect(requests).toEqual([]);
148+
});
149+
150+
it("explains how to recover when the server lacks the mint endpoint", async () => {
151+
publicTokenStatus = 404;
152+
const promise = apiClientManager.runWithConfig(
153+
{
154+
baseURL: baseUrl,
155+
accessToken: "tr_prod_ak_0123456789abcdefghijklmn",
156+
},
157+
() => auth.createPublicToken({ scopes: { read: { runs: true } } })
158+
);
159+
160+
await expect(promise).rejects.toThrow("Upgrade the server or use the root API key");
161+
});
162+
});
163+
164+
async function handleRequest(
165+
request: IncomingMessage,
166+
response: ServerResponse,
167+
requests: ReceivedRequest[],
168+
publicTokenStatus: () => number
169+
) {
170+
const chunks: Buffer[] = [];
171+
for await (const chunk of request) {
172+
chunks.push(Buffer.from(chunk));
173+
}
174+
const rawBody = Buffer.concat(chunks).toString();
175+
requests.push({
176+
method: request.method ?? "",
177+
url: request.url ?? "",
178+
authorization: request.headers.authorization,
179+
body: rawBody ? JSON.parse(rawBody) : undefined,
180+
});
181+
182+
if (request.url === "/api/v1/auth/jwt/claims") {
183+
return json(response, { sub: "env_test", pub: true });
184+
}
185+
if (request.url === "/api/v1/auth/public-tokens") {
186+
const status = publicTokenStatus();
187+
return json(
188+
response,
189+
status === 200 ? { token: "server-minted-token" } : { error: "Not found" },
190+
status
191+
);
192+
}
193+
194+
return json(response, { error: "Not found" }, 404);
195+
}
196+
197+
function json(response: ServerResponse, body: unknown, status = 200) {
198+
response.writeHead(status, { "content-type": "application/json" });
199+
response.end(JSON.stringify(body));
200+
}

0 commit comments

Comments
 (0)