Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/public-token-expiration-seconds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Correct the `expirationTime` docs on `auth.createPublicToken` and the trigger-token helpers: a number is a Unix timestamp in seconds, not milliseconds.
Comment on lines +1 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Release notes will announce an SDK change that this update does not actually contain

A release note entry is added for the SDK package (.changeset/public-token-expiration-seconds.md:1-5) even though this change set does not touch any SDK source or documentation files, so the next SDK release will be published with a note describing something that did not change.
Impact: Users reading the SDK changelog will see an entry for a correction that was never shipped, and an otherwise unnecessary SDK version bump is triggered.

Why the changeset does not correspond to any change in this branch

The changeset declares "@trigger.dev/sdk": patch and describes correcting the expirationTime docs on auth.createPublicToken and the trigger-token helpers. However, the full diff against the merge base contains no files under packages/trigger-sdk/ (only apps/webapp/**, packages/plugins/src/rbac.ts, tests, and the two .server-changes/ notes). packages/plugins is private: true, so it needs no changeset either.

CONTRIBUTING.md / AGENTS.md tie changesets to "contributing a change to any packages in this monorepo"; a changeset for a package with no changes produces a release and changelog entry with no corresponding code. Either include the intended packages/trigger-sdk doc edits (e.g. the expirationTime JSDoc at packages/trigger-sdk/src/v3/auth.ts:131-134) or drop the changeset.

Prompt for agents
The changeset .changeset/public-token-expiration-seconds.md declares a patch release for @trigger.dev/sdk describing a documentation correction to expirationTime on auth.createPublicToken and the trigger-token helpers, but this branch contains no changes under packages/trigger-sdk. Either add the intended JSDoc corrections in packages/trigger-sdk/src/v3/auth.ts (where expirationTime is documented for createPublicToken, createTriggerPublicToken, and createBatchTriggerPublicToken) so the changeset matches shipped code, or remove the changeset since the webapp-only changes are already covered by the two .server-changes/ notes.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

6 changes: 6 additions & 0 deletions .server-changes/api-key-deploy-envvars-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Self-hosted deployments can now create multiple full-access API keys for each environment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Release note promises self-hosted key creation, but issuance defaults to off

This note says self-hosted deployments "can now create multiple full-access API keys for each environment", but creation is gated by resolveAdditionalApiKeyIssuance (apps/webapp/app/services/additionalApiKeyIssuance.ts:7-16), which requires the system-wide additionalApiKeyIssuanceEnabled flag to be explicitly true. That flag has no default row and is strict-boolean, so out of the box canIssueAdditionalApiKeys returns false and the "New API key" button is not rendered (apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx:356). Self-hosters reading the release note will not find the feature until they flip a global feature flag; the note should say how to enable it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

6 changes: 6 additions & 0 deletions .server-changes/public-token-additional-api-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Additional environment API keys can now create scoped public access tokens.
1 change: 1 addition & 0 deletions apps/webapp/app/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const RUN_CHUNK_EXECUTION_BUFFER = 350;
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
export const MAX_BATCH_TRIGGER_ITEMS = 100;
export const MAX_API_KEY_TASK_IDENTIFIERS = 10;
export const MAX_TASK_RUN_ATTEMPTS = 250;
export const BULK_ACTION_RUN_LIMIT = 250;
export const MAX_JOB_RUN_EXECUTION_COUNT = 250;
182 changes: 179 additions & 3 deletions apps/webapp/app/models/api-key.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { RuntimeEnvironment } from "@trigger.dev/database";
import { prisma } from "~/db.server";
import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
import type { HostRbacController } from "@trigger.dev/rbac";
import { trail } from "agentcrumbs"; // @crumbs
import { customAlphabet } from "nanoid";
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
import { prisma } from "~/db.server";
import { RuntimeEnvironmentType } from "~/database-types";
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
import { rbac } from "~/services/rbac.server";
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";

const crumb = trail("webapp"); // @crumbs

const apiKeyId = customAlphabet(
"1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
12
Expand Down Expand Up @@ -94,8 +103,175 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
return updatedEnviroment;
}

export async function createEnvironmentApiKey(
{
environmentId,
taskEnvironmentId,
userId,
name,
expiresAt,
presetId,
taskIdentifiers,
}: {
environmentId: string;
taskEnvironmentId: string;
userId: string;
name: string;
expiresAt?: Date;
presetId: string;
taskIdentifiers?: string[];
},
{
prismaClient = prisma,
rbacController = rbac,
issuanceAllowed,
telemetryRecorder = apiKeyTelemetry,
}: {
prismaClient?: Pick<
PrismaClient,
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
>;
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
telemetryRecorder?: ApiKeyTelemetry;
} = {}
) {
const environment = await prismaClient.runtimeEnvironment.findFirst({
where: {
id: environmentId,
organization: { members: { some: { userId } } },
},
select: { id: true, type: true, organizationId: true },
});

if (!environment) {
throw new Error("Environment not found");
}

const canIssue =
issuanceAllowed ??
((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient));
if (!(await canIssue(environment.organizationId))) {
throw new Error("Creating additional API keys is not enabled.");
}

if (expiresAt && expiresAt.getTime() <= Date.now()) {
throw new Error("Expiration must be in the future");
}

const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))];

if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) {
throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`);
}
if (selectedTasks.length > 0) {
const matchingTasks = await prismaClient.taskIdentifier.count({
where: {
runtimeEnvironmentId: taskEnvironmentId,
slug: { in: selectedTasks },
runtimeEnvironment: {
OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }],
},
},
});

if (matchingTasks !== selectedTasks.length) {
throw new Error("One or more selected tasks are not available in this environment");
}
}

Comment on lines +168 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Task validation resolves identifiers against the requesting env, not the key env

createEnvironmentApiKey counts TaskIdentifier rows on taskEnvironmentId while additionally requiring that env to be the key env or a child of it. Since the key is stored on the parent (keyEnvironmentId) but tasks are validated against the branch the user is viewing, a preview-branch-scoped key can be created naming tasks that exist only on that branch; the resulting scopes then apply across every branch sharing the parent key env. That appears intentional given the parent-keyed model, but it's worth confirming the scope semantics are what you want for preview branches.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

let prepared: Awaited<ReturnType<typeof rbacController.prepareApiKeyPolicy>>;
try {
prepared = await rbacController.prepareApiKeyPolicy({
organizationId: environment.organizationId,
presetId,
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
});
} catch (error) {
telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error");
throw error;
}

if (!prepared.ok) {
telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected");
throw new Error(prepared.error);
}
telemetryRecorder.recordOperation("prepare_policy", "success");

const generated = generateAdditionalApiKey(environment.type);
const apiKey = await (async () => {
try {
return await prismaClient.apiKey.create({
data: {
name,
keyHash: generated.keyHash,
lastFour: generated.lastFour,
runtimeEnvironmentId: environment.id,
createdByUserId: userId,
expiresAt,
presetId: prepared.policy.presetId,
scopes: prepared.policy.scopes,
},
});
} catch (error) {
telemetryRecorder.recordOperation("create", "error", "database_error");
throw error;
}
})();
telemetryRecorder.recordOperation("create", "success");

crumb("environment API key created", {
apiKeyId: apiKey.id,
environmentId,
presetId: apiKey.presetId,
}); // @crumbs

return { apiKey, plaintext: generated.apiKey };
}

export async function revokeEnvironmentApiKey(
{
environmentId,
apiKeyId,
}: {
environmentId: string;
apiKeyId: string;
},
{
prismaClient = prisma,
telemetryRecorder = apiKeyTelemetry,
}: {
prismaClient?: Pick<PrismaClient, "apiKey">;
telemetryRecorder?: ApiKeyTelemetry;
} = {}
) {
const result = await (async () => {
try {
return await prismaClient.apiKey.updateMany({
where: {
id: apiKeyId,
runtimeEnvironmentId: environmentId,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
} catch (error) {
telemetryRecorder.recordOperation("revoke", "error", "database_error");
throw error;
}
})();

if (result.count !== 1) {
telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked");
throw new Error("API key not found or already revoked");
}

telemetryRecorder.recordOperation("revoke", "success");
crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs
}

export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
return generateRootApiKey(envType).apiKey;
}

export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
Expand Down
Loading
Loading