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.
This commit is contained in:
@@ -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 {
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`],
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
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}`],
|
||||
@@ -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}`],
|
||||
@@ -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}`],
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type inferSchemaOut,
|
||||
InputStreamOncePromise,
|
||||
type InputStreamOnceResult,
|
||||
isAdditionalApiKey,
|
||||
isSchemaZodEsque,
|
||||
logger,
|
||||
type MachinePresetName,
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { apiClientManager } from "@trigger.dev/core/v3";
|
||||
import { validateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { auth } from "./auth.js";
|
||||
|
||||
type ReceivedRequest = {
|
||||
method: string;
|
||||
url: string;
|
||||
authorization?: string;
|
||||
body: unknown;
|
||||
};
|
||||
|
||||
describe("public token API key routing", () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let requests: ReceivedRequest[];
|
||||
let publicTokenStatus: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
requests = [];
|
||||
publicTokenStatus = 200;
|
||||
server = createServer((request, response) => {
|
||||
void handleRequest(request, response, requests, () => publicTokenStatus);
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("uses server minting for additional keys", async () => {
|
||||
const key = "tr_prod_sk_0123456789abcdefghijklmn";
|
||||
const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
|
||||
auth.createPublicToken({
|
||||
scopes: { read: { runs: ["run_123"] } },
|
||||
expirationTime: new Date("2030-01-01T00:00:00.000Z"),
|
||||
realtime: { skipColumns: ["payload"] },
|
||||
})
|
||||
);
|
||||
|
||||
expect(token).toBe("server-minted-token");
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/public-tokens",
|
||||
authorization: `Bearer ${key}`,
|
||||
body: {
|
||||
scopes: ["read:runs:run_123"],
|
||||
expirationTime: 1893456000,
|
||||
realtime: { skipColumns: ["payload"] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("server-mints trigger tokens with one-time-use semantics", async () => {
|
||||
const key = "tr_dev_sk_0123456789abcdefghijklmn";
|
||||
await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
|
||||
auth.createTriggerPublicToken(["task-one", "task-two"], { multipleUse: true })
|
||||
);
|
||||
|
||||
expect(requests[0]?.body).toEqual({
|
||||
scopes: ["trigger:tasks:task-one", "trigger:tasks:task-two"],
|
||||
oneTimeUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("server-mints batch trigger tokens for additional keys", async () => {
|
||||
const key = "tr_stg_sk_0123456789abcdefghijklmn";
|
||||
await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
|
||||
auth.createBatchTriggerPublicToken("batch-task", {
|
||||
expirationTime: "2h",
|
||||
multipleUse: false,
|
||||
realtime: { skipColumns: ["payload"] },
|
||||
})
|
||||
);
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/public-tokens",
|
||||
authorization: `Bearer ${key}`,
|
||||
body: {
|
||||
scopes: ["batchTrigger:tasks:batch-task"],
|
||||
expirationTime: "2h",
|
||||
oneTimeUse: true,
|
||||
realtime: { skipColumns: ["payload"] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps root key self-minting unchanged", async () => {
|
||||
const key = "tr_prod_0123456789abcdefghijklmn";
|
||||
const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
|
||||
auth.createPublicToken({ scopes: { read: { runs: true } } })
|
||||
);
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]);
|
||||
const validation = await validateJWT(token, key);
|
||||
expect(validation.ok).toBe(true);
|
||||
if (!validation.ok) return;
|
||||
expect(validation.payload).toMatchObject({
|
||||
sub: "env_test",
|
||||
pub: true,
|
||||
scopes: ["read:runs"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no options at all", undefined],
|
||||
["an empty scopes object", {}],
|
||||
["a scope group with nothing selected", { read: {} }],
|
||||
])("keeps root-key behavior unchanged for %s", async (_label, scopes) => {
|
||||
const key = "tr_prod_0123456789abcdefghijklmn";
|
||||
const token = await apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
|
||||
auth.createPublicToken(scopes === undefined ? undefined : { scopes })
|
||||
);
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual(["/api/v1/auth/jwt/claims"]);
|
||||
const validation = await validateJWT(token, key);
|
||||
expect(validation.ok).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no options at all", undefined],
|
||||
["an empty scopes object", {}],
|
||||
["a scope group with nothing selected", { read: {} }],
|
||||
])("rejects %s clearly for additional keys", async (_label, scopes) => {
|
||||
const key = "tr_prod_sk_0123456789abcdefghijklmn";
|
||||
const promise = apiClientManager.runWithConfig({ baseURL: baseUrl, accessToken: key }, () =>
|
||||
auth.createPublicToken(scopes === undefined ? undefined : { scopes })
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"requires at least one scope when using an additional API key"
|
||||
);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("explains how to recover when the server lacks the mint endpoint", async () => {
|
||||
publicTokenStatus = 404;
|
||||
const promise = apiClientManager.runWithConfig(
|
||||
{
|
||||
baseURL: baseUrl,
|
||||
accessToken: "tr_prod_sk_0123456789abcdefghijklmn",
|
||||
},
|
||||
() => auth.createPublicToken({ scopes: { read: { runs: true } } })
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow("Upgrade the server or use the root API key");
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
requests: ReceivedRequest[],
|
||||
publicTokenStatus: () => number
|
||||
) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
const rawBody = Buffer.concat(chunks).toString();
|
||||
requests.push({
|
||||
method: request.method ?? "",
|
||||
url: request.url ?? "",
|
||||
authorization: request.headers.authorization,
|
||||
body: rawBody ? JSON.parse(rawBody) : undefined,
|
||||
});
|
||||
|
||||
if (request.url === "/api/v1/auth/jwt/claims") {
|
||||
return json(response, { sub: "env_test", pub: true });
|
||||
}
|
||||
if (request.url === "/api/v1/auth/public-tokens") {
|
||||
const status = publicTokenStatus();
|
||||
return json(
|
||||
response,
|
||||
status === 200 ? { token: "server-minted-token" } : { error: "Not found" },
|
||||
status
|
||||
);
|
||||
}
|
||||
|
||||
return json(response, { error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
function json(response: ServerResponse, body: unknown, status = 200) {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
type RealtimeRunSkipColumns,
|
||||
type ApiClient,
|
||||
type ApiClientConfiguration,
|
||||
type CreatePublicTokenRequestBody,
|
||||
apiClientManager,
|
||||
generateJWT as internal_generateJWT,
|
||||
isAdditionalApiKey,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import "@trigger.dev/core/v3/sdk-scope-storage";
|
||||
|
||||
@@ -102,7 +105,9 @@ export type PublicTokenPermissions = {
|
||||
|
||||
export type CreatePublicTokenOptions = {
|
||||
/**
|
||||
* A collection of permission scopes to be granted to the token.
|
||||
* A collection of permission scopes to be granted to the token. This remains
|
||||
* optional for root API key compatibility; additional API keys require at
|
||||
* least one scope.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
@@ -117,7 +122,8 @@ export type CreatePublicTokenOptions = {
|
||||
scopes?: PublicTokenPermissions;
|
||||
|
||||
/**
|
||||
* The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string.
|
||||
* The expiration time for the token: a duration string, a `Date`, or a Unix
|
||||
* timestamp in **seconds**.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
@@ -146,6 +152,35 @@ export type CreatePublicTokenOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// A `Date` becomes Unix seconds; duration strings and numbers pass through. A
|
||||
// bare number is already Unix seconds, matching how the local signing path
|
||||
// reads it, so both key types treat the option the same.
|
||||
function serverExpirationTime(
|
||||
expirationTime: number | Date | string | undefined
|
||||
): number | string | undefined {
|
||||
return expirationTime instanceof Date
|
||||
? Math.floor(expirationTime.getTime() / 1000)
|
||||
: expirationTime;
|
||||
}
|
||||
|
||||
async function createServerPublicToken(
|
||||
apiClient: ApiClient,
|
||||
body: CreatePublicTokenRequestBody
|
||||
): Promise<string> {
|
||||
try {
|
||||
const result = await apiClient.createPublicToken(body);
|
||||
return result.token;
|
||||
} catch (error) {
|
||||
if (error !== null && typeof error === "object" && "status" in error && error.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."
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a public token using the provided options.
|
||||
*
|
||||
@@ -170,15 +205,34 @@ export type CreatePublicTokenOptions = {
|
||||
* ```
|
||||
*/
|
||||
async function createPublicToken(options?: CreatePublicTokenOptions): Promise<string> {
|
||||
const scopes = options?.scopes ? flattenScopes(options.scopes) : [];
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
if (isAdditionalApiKey(apiClient.accessToken)) {
|
||||
// Additional keys cannot self-sign, and the server rejects empty scope
|
||||
// lists because they cannot authorize anything. Keep the legacy root-key
|
||||
// behaviour unchanged while failing clearly for this new credential type.
|
||||
if (scopes.length === 0) {
|
||||
throw new Error(
|
||||
"auth.createPublicToken() requires at least one scope when using an additional API key. " +
|
||||
'For example: auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })'
|
||||
);
|
||||
}
|
||||
|
||||
return createServerPublicToken(apiClient, {
|
||||
scopes,
|
||||
expirationTime: serverExpirationTime(options?.expirationTime),
|
||||
realtime: options?.realtime,
|
||||
});
|
||||
}
|
||||
|
||||
const claims = await apiClient.generateJWTClaims();
|
||||
|
||||
return await internal_generateJWT({
|
||||
secretKey: apiClient.accessToken,
|
||||
payload: {
|
||||
...claims,
|
||||
scopes: options?.scopes ? flattenScopes(options.scopes) : undefined,
|
||||
scopes: options?.scopes ? scopes : undefined,
|
||||
realtime: options?.realtime,
|
||||
},
|
||||
expirationTime: options?.expirationTime,
|
||||
@@ -199,7 +253,8 @@ async function withPublicToken(options: CreatePublicTokenOptions, fn: () => Prom
|
||||
|
||||
export type CreateTriggerTokenOptions = {
|
||||
/**
|
||||
* The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string.
|
||||
* The expiration time for the token: a duration string, a `Date`, or a Unix
|
||||
* timestamp in **seconds**.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
@@ -271,6 +326,21 @@ async function createTriggerPublicToken(
|
||||
options?: CreateTriggerTokenOptions
|
||||
): Promise<string> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const scopes = flattenScopes({
|
||||
trigger: {
|
||||
tasks: task,
|
||||
},
|
||||
});
|
||||
const oneTimeUse = typeof options?.multipleUse === "boolean" ? !options.multipleUse : true;
|
||||
|
||||
if (isAdditionalApiKey(apiClient.accessToken)) {
|
||||
return createServerPublicToken(apiClient, {
|
||||
scopes,
|
||||
expirationTime: serverExpirationTime(options?.expirationTime),
|
||||
oneTimeUse,
|
||||
realtime: options?.realtime,
|
||||
});
|
||||
}
|
||||
|
||||
const claims = await apiClient.generateJWTClaims();
|
||||
|
||||
@@ -278,13 +348,9 @@ async function createTriggerPublicToken(
|
||||
secretKey: apiClient.accessToken,
|
||||
payload: {
|
||||
...claims,
|
||||
otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true,
|
||||
otu: oneTimeUse,
|
||||
realtime: options?.realtime,
|
||||
scopes: flattenScopes({
|
||||
trigger: {
|
||||
tasks: task,
|
||||
},
|
||||
}),
|
||||
scopes,
|
||||
},
|
||||
expirationTime: options?.expirationTime,
|
||||
});
|
||||
@@ -343,6 +409,21 @@ async function createBatchTriggerPublicToken(
|
||||
options?: CreateTriggerTokenOptions
|
||||
): Promise<string> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const scopes = flattenScopes({
|
||||
batchTrigger: {
|
||||
tasks: task,
|
||||
},
|
||||
});
|
||||
const oneTimeUse = typeof options?.multipleUse === "boolean" ? !options.multipleUse : true;
|
||||
|
||||
if (isAdditionalApiKey(apiClient.accessToken)) {
|
||||
return createServerPublicToken(apiClient, {
|
||||
scopes,
|
||||
expirationTime: serverExpirationTime(options?.expirationTime),
|
||||
oneTimeUse,
|
||||
realtime: options?.realtime,
|
||||
});
|
||||
}
|
||||
|
||||
const claims = await apiClient.generateJWTClaims();
|
||||
|
||||
@@ -350,13 +431,9 @@ async function createBatchTriggerPublicToken(
|
||||
secretKey: apiClient.accessToken,
|
||||
payload: {
|
||||
...claims,
|
||||
otu: typeof options?.multipleUse === "boolean" ? !options.multipleUse : true,
|
||||
otu: oneTimeUse,
|
||||
realtime: options?.realtime,
|
||||
scopes: flattenScopes({
|
||||
batchTrigger: {
|
||||
tasks: task,
|
||||
},
|
||||
}),
|
||||
scopes,
|
||||
},
|
||||
expirationTime: options?.expirationTime,
|
||||
});
|
||||
|
||||
@@ -132,6 +132,44 @@ describe("chat.createStartSessionAction — runtime", () => {
|
||||
expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1");
|
||||
});
|
||||
|
||||
it("server-mints override tokens for additional API keys", async () => {
|
||||
const requests: Array<{ url: string; body: unknown }> = [];
|
||||
const start = chat.createStartSessionAction("fake-chat", {
|
||||
apiClient: {
|
||||
baseURL: "https://example.invalid",
|
||||
accessToken: "tr_prod_sk_0123456789abcdefghijklmn",
|
||||
},
|
||||
tokenTTL: "30m",
|
||||
fetch: async (url, init, context) => {
|
||||
requests.push({
|
||||
url,
|
||||
body: typeof init.body === "string" ? JSON.parse(init.body) : undefined,
|
||||
});
|
||||
|
||||
if (context.endpoint === "sessions") {
|
||||
return Response.json({
|
||||
id: "session_fixture",
|
||||
runId: "run_fixture",
|
||||
publicAccessToken: "session-token",
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ token: "server-minted-token" });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await start({ chatId: "chat-additional-key" });
|
||||
|
||||
expect(result.publicAccessToken).toBe("server-minted-token");
|
||||
expect(requests[1]).toEqual({
|
||||
url: "https://example.invalid/api/v1/auth/public-tokens",
|
||||
body: {
|
||||
scopes: ["read:sessions:chat-additional-key", "write:sessions:chat-additional-key"],
|
||||
expirationTime: "30m",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps session-level metadata distinct from per-turn clientData", async () => {
|
||||
installStartFixture();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user