Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/friendly-keys-batch-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
Comment thread
carderne marked this conversation as resolved.
"@trigger.dev/sdk": patch
---

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.
67 changes: 63 additions & 4 deletions packages/core/src/v3/apiClient/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { nanoid } from "nanoid";
import { z } from "zod";
import { VERSION } from "../../version.js";
import { isAdditionalApiKey } from "../apiKeys.js";
import type { ApiClientConfiguration } from "../apiClientManager-api.js";
import { generateJWT } from "../jwt.js";
import {
Expand Down Expand Up @@ -201,6 +202,15 @@ export type {

export * from "./getBranch.js";

export type CreatePublicTokenRequestBody = {
scopes: string[];
expirationTime?: string | number;
oneTimeUse?: boolean;
realtime?: { skipColumns?: string[] };
};

const CreatePublicTokenResponseBody = z.object({ token: z.string() });

/**
* Trigger.dev v3 API client
*/
Expand Down Expand Up @@ -230,6 +240,21 @@ export class ApiClient {
this.futureFlags = futureFlags;
}

/**
* Key for signing a public access token locally. Only root keys can do this —
* an additional key isn't the environment's signing material, so a token
* signed with one would never verify. Throw rather than return a dead token.
*/
get #selfSigningKey(): string {
if (isAdditionalApiKey(this.accessToken)) {
throw new Error(
"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."
);
}

return this.accessToken;
}

get fetchClient(): typeof fetch {
const headers = this.#getHeaders(false);

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

const jwt = await generateJWT({
secretKey: this.accessToken,
secretKey: this.#selfSigningKey,
payload: {
...claims,
scopes: [`read:runs:${data.id}`],
Expand Down Expand Up @@ -359,11 +384,20 @@ export class ApiClient {
)
.withResponse()
.then(async ({ data, response }) => {
const jwtHeader = response.headers.get("x-trigger-jwt");

if (typeof jwtHeader === "string") {
return {
...data,
publicAccessToken: jwtHeader,
};
}
Comment thread
carderne marked this conversation as resolved.

const claimsHeader = response.headers.get("x-trigger-jwt-claims");
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;

const jwt = await generateJWT({
secretKey: this.accessToken,
secretKey: this.#selfSigningKey,
payload: {
...claims,
scopes: [`read:batch:${data.id}`],
Expand Down Expand Up @@ -407,11 +441,20 @@ export class ApiClient {
)
.withResponse()
.then(async ({ data, response }) => {
const jwtHeader = response.headers.get("x-trigger-jwt");

if (typeof jwtHeader === "string") {
return {
...data,
publicAccessToken: jwtHeader,
};
}

const claimsHeader = response.headers.get("x-trigger-jwt-claims");
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;

const jwt = await generateJWT({
secretKey: this.accessToken,
secretKey: this.#selfSigningKey,
payload: {
...claims,
scopes: [`read:batch:${data.id}`],
Expand Down Expand Up @@ -1107,7 +1150,7 @@ export class ApiClient {
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;

const jwt = await generateJWT({
secretKey: this.accessToken,
secretKey: this.#selfSigningKey,
payload: {
...claims,
scopes: [`write:waitpoints:${data.id}`],
Expand Down Expand Up @@ -1870,6 +1913,22 @@ export class ApiClient {
);
}

async createPublicToken(
body: CreatePublicTokenRequestBody,
requestOptions?: ZodFetchOptions
): Promise<{ token: string }> {
return zodfetch(
CreatePublicTokenResponseBody,
`${this.baseUrl}/api/v1/auth/public-tokens`,
{
method: "POST",
headers: this.#getHeaders(false),
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}

retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) {
return zodfetch(
RetrieveBatchV2Response,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/v3/apiKeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { isAdditionalApiKey } from "./apiKeys.js";

describe("isAdditionalApiKey", () => {
it.each(["dev", "stg", "prod", "preview"])("recognizes %s additional keys", (environment) => {
expect(isAdditionalApiKey(`tr_${environment}_sk_0123456789abcdefghijklmn`)).toBe(true);
});

it.each([
"tr_prod_0123456789abcdefghijklmn",
"tr_prod_sk_too-short",
"tr_prod_sk_0123456789abcdefghijklmn_extra",
"tr_test_sk_0123456789abcdefghijklmn",
"tr_prod_sk_0123456789abcdefghijkl_",
])("rejects %s", (key) => {
expect(isAdditionalApiKey(key)).toBe(false);
});
});
11 changes: 11 additions & 0 deletions packages/core/src/v3/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const ADDITIONAL_API_KEY_PATTERN = /^tr_(dev|stg|prod|preview)_sk_[0-9a-zA-Z]{24}$/;

/**
* Returns whether a key has the additional environment API key format.
*
* This is only a routing hint. It must never be used as a security boundary;
* servers authenticate additional keys by resolving their stored hash.
*/
export function isAdditionalApiKey(key: string): boolean {
return ADDITIONAL_API_KEY_PATTERN.test(key);
}
1 change: 1 addition & 0 deletions packages/core/src/v3/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export * from "./resource-catalog-api.js";
export * from "./types/index.js";
export { links } from "./links.js";
export * from "./jwt.js";
export * from "./apiKeys.js";
export * from "./workloadDeploymentToken.js";
export * from "./idempotencyKeys.js";
export * from "./streams/asyncIterableStream.js";
Expand Down
38 changes: 34 additions & 4 deletions packages/trigger-sdk/src/v3/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type inferSchemaOut,
InputStreamOncePromise,
type InputStreamOnceResult,
isAdditionalApiKey,
isSchemaZodEsque,
logger,
type MachinePresetName,
Expand Down Expand Up @@ -10600,24 +10601,53 @@ async function mintPublicTokenWithOverride(args: {
throw new Error("chat.createStartSessionAction: no API access token configured for JWT mint.");
}
const ctx: ChatStartSessionEndpointContext = { endpoint: "auth", chatId: args.chatId };
const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}/api/v1/auth/jwt/claims`;
const scopes = [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`];
const serverMint = isAdditionalApiKey(accessToken);
const endpoint = serverMint ? "/api/v1/auth/public-tokens" : "/api/v1/auth/jwt/claims";
const url = `${resolveChatStartBaseURL("auth", args.chatId, args.baseURLOption)}${endpoint}`;
const init: RequestInit = {
method: "POST",
headers: overrideRequestHeaders(accessToken),
...(serverMint
? {
body: JSON.stringify({
scopes,
expirationTime:
args.expirationTime instanceof Date
? Math.floor(args.expirationTime.getTime() / 1000)
: args.expirationTime,
}),
}
: {}),
};
const response = args.fetchOverride
? await args.fetchOverride(url, init, ctx)
: await fetch(url, init);
if (!response.ok) {
// An additional API key cannot self-sign, so it must use the server mint
// endpoint. On a server too old to expose it, explain the recovery path
// instead of surfacing a bare 404 (mirrors auth.createServerPublicToken).
if (serverMint && response.status === 404) {
throw new Error(
"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."
);
}
const text = await response.text().catch(() => "");
throw new Error(`auth.createPublicToken failed: ${response.status} ${text}`);
}
const claims = (await response.json()) as Record<string, unknown>;
const responseBody = (await response.json()) as Record<string, unknown>;
if (serverMint) {
if (typeof responseBody.token !== "string") {
throw new Error("auth.createPublicToken failed: server response did not include a token");
}
return responseBody.token;
}

return generateJWT({
secretKey: accessToken,
payload: {
...claims,
scopes: [`read:sessions:${args.chatId}`, `write:sessions:${args.chatId}`],
...responseBody,
scopes,
},
expirationTime: args.expirationTime,
});
Expand Down
Loading
Loading