Skip to content

Commit d45c149

Browse files
authored
Merge branch 'main' into fix/debounce-max-duration-ceiling
2 parents 41fb9d4 + c084fa6 commit d45c149

37 files changed

Lines changed: 4057 additions & 429 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/react-hooks": patch
4+
---
5+
6+
`debounce` now works when you pass an array of items to `batchTrigger` or `batchTriggerAndWait`, and when you trigger from `useTaskTrigger`. Previously the option was accepted by the types and dropped before the request was sent, so every trigger created its own run instead of collapsing onto the debounce key.
7+
8+
```ts
9+
await myTask.batchTrigger([
10+
{ payload: { id: "a" }, options: { debounce: { key: "same-key", delay: "30s" } } },
11+
{ payload: { id: "b" }, options: { debounce: { key: "same-key", delay: "30s" } } },
12+
]);
13+
```
14+
15+
The streaming (async iterable) forms of the batch calls were already forwarding `debounce` correctly.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
API rate limits now apply per environment, so creating extra API keys no longer increases how many requests an environment can make.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Self-hosted deployments can now create multiple full-access API keys for each environment.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Additional environment API keys can now create scoped public access tokens.

apps/webapp/app/consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const RUN_CHUNK_EXECUTION_BUFFER = 350;
1010
export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes
1111
export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504];
1212
export const MAX_BATCH_TRIGGER_ITEMS = 100;
13+
export const MAX_API_KEY_TASK_IDENTIFIERS = 10;
1314
export const MAX_TASK_RUN_ATTEMPTS = 250;
1415
export const BULK_ACTION_RUN_LIMIT = 250;
1516
export const MAX_JOB_RUN_EXECUTION_COUNT = 250;

apps/webapp/app/models/api-key.server.ts

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import type { RuntimeEnvironment } from "@trigger.dev/database";
2-
import { prisma } from "~/db.server";
1+
import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
2+
import type { HostRbacController } from "@trigger.dev/rbac";
33
import { customAlphabet } from "nanoid";
4+
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
5+
import { prisma } from "~/db.server";
46
import { RuntimeEnvironmentType } from "~/database-types";
7+
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
8+
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
9+
import { rbac } from "~/services/rbac.server";
10+
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
511
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
612

713
const apiKeyId = customAlphabet(
@@ -94,8 +100,168 @@ export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIK
94100
return updatedEnviroment;
95101
}
96102

103+
export async function createEnvironmentApiKey(
104+
{
105+
environmentId,
106+
taskEnvironmentId,
107+
userId,
108+
name,
109+
expiresAt,
110+
presetId,
111+
taskIdentifiers,
112+
}: {
113+
environmentId: string;
114+
taskEnvironmentId: string;
115+
userId: string;
116+
name: string;
117+
expiresAt?: Date;
118+
presetId: string;
119+
taskIdentifiers?: string[];
120+
},
121+
{
122+
prismaClient = prisma,
123+
rbacController = rbac,
124+
issuanceAllowed,
125+
telemetryRecorder = apiKeyTelemetry,
126+
}: {
127+
prismaClient?: Pick<
128+
PrismaClient,
129+
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
130+
>;
131+
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
132+
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
133+
telemetryRecorder?: ApiKeyTelemetry;
134+
} = {}
135+
) {
136+
const environment = await prismaClient.runtimeEnvironment.findFirst({
137+
where: {
138+
id: environmentId,
139+
organization: { members: { some: { userId } } },
140+
},
141+
select: { id: true, type: true, organizationId: true },
142+
});
143+
144+
if (!environment) {
145+
throw new Error("Environment not found");
146+
}
147+
148+
const canIssue =
149+
issuanceAllowed ??
150+
((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient));
151+
if (!(await canIssue(environment.organizationId))) {
152+
throw new Error("Creating additional API keys is not enabled.");
153+
}
154+
155+
if (expiresAt && expiresAt.getTime() <= Date.now()) {
156+
throw new Error("Expiration must be in the future");
157+
}
158+
159+
const selectedTasks = [...new Set(taskIdentifiers?.map((task) => task.trim()).filter(Boolean))];
160+
161+
if (selectedTasks.length > MAX_API_KEY_TASK_IDENTIFIERS) {
162+
throw new Error(`You can select at most ${MAX_API_KEY_TASK_IDENTIFIERS} tasks for an API key`);
163+
}
164+
if (selectedTasks.length > 0) {
165+
const matchingTasks = await prismaClient.taskIdentifier.count({
166+
where: {
167+
runtimeEnvironmentId: taskEnvironmentId,
168+
slug: { in: selectedTasks },
169+
runtimeEnvironment: {
170+
OR: [{ id: environment.id }, { parentEnvironmentId: environment.id }],
171+
},
172+
},
173+
});
174+
175+
if (matchingTasks !== selectedTasks.length) {
176+
throw new Error("One or more selected tasks are not available in this environment");
177+
}
178+
}
179+
180+
let prepared: Awaited<ReturnType<typeof rbacController.prepareApiKeyPolicy>>;
181+
try {
182+
prepared = await rbacController.prepareApiKeyPolicy({
183+
organizationId: environment.organizationId,
184+
presetId,
185+
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
186+
});
187+
} catch (error) {
188+
telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error");
189+
throw error;
190+
}
191+
192+
if (!prepared.ok) {
193+
telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected");
194+
throw new Error(prepared.error);
195+
}
196+
telemetryRecorder.recordOperation("prepare_policy", "success");
197+
198+
const generated = generateAdditionalApiKey(environment.type);
199+
const apiKey = await (async () => {
200+
try {
201+
return await prismaClient.apiKey.create({
202+
data: {
203+
name,
204+
keyHash: generated.keyHash,
205+
lastFour: generated.lastFour,
206+
runtimeEnvironmentId: environment.id,
207+
createdByUserId: userId,
208+
expiresAt,
209+
presetId: prepared.policy.presetId,
210+
scopes: prepared.policy.scopes,
211+
},
212+
});
213+
} catch (error) {
214+
telemetryRecorder.recordOperation("create", "error", "database_error");
215+
throw error;
216+
}
217+
})();
218+
telemetryRecorder.recordOperation("create", "success");
219+
220+
return { apiKey, plaintext: generated.apiKey };
221+
}
222+
223+
export async function revokeEnvironmentApiKey(
224+
{
225+
environmentId,
226+
apiKeyId,
227+
}: {
228+
environmentId: string;
229+
apiKeyId: string;
230+
},
231+
{
232+
prismaClient = prisma,
233+
telemetryRecorder = apiKeyTelemetry,
234+
}: {
235+
prismaClient?: Pick<PrismaClient, "apiKey">;
236+
telemetryRecorder?: ApiKeyTelemetry;
237+
} = {}
238+
) {
239+
const result = await (async () => {
240+
try {
241+
return await prismaClient.apiKey.updateMany({
242+
where: {
243+
id: apiKeyId,
244+
runtimeEnvironmentId: environmentId,
245+
revokedAt: null,
246+
},
247+
data: { revokedAt: new Date() },
248+
});
249+
} catch (error) {
250+
telemetryRecorder.recordOperation("revoke", "error", "database_error");
251+
throw error;
252+
}
253+
})();
254+
255+
if (result.count !== 1) {
256+
telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked");
257+
throw new Error("API key not found or already revoked");
258+
}
259+
260+
telemetryRecorder.recordOperation("revoke", "success");
261+
}
262+
97263
export function createApiKeyForEnv(envType: RuntimeEnvironment["type"]) {
98-
return `tr_${envSlug(envType)}_${apiKeyId(20)}`;
264+
return generateRootApiKey(envType).apiKey;
99265
}
100266

101267
export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) {

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,89 @@ export async function findEnvironmentByApiKeyWithResolution(
301301
return resolveEnvironmentByApiKey(apiKey, branchName, tx, additionalApiKeyLookupEnabled);
302302
}
303303

304+
export type PrivateApiKeyRateLimitScope = {
305+
environmentId: string;
306+
apiRateLimiterConfig: unknown;
307+
};
308+
309+
export async function resolvePrivateApiKeyRateLimitScope(
310+
apiKey: string,
311+
tx: PrismaClientOrTransaction = $replica
312+
): Promise<PrivateApiKeyRateLimitScope | null> {
313+
const now = new Date();
314+
315+
if (isAdditionalApiKey(apiKey)) {
316+
const match = await tx.apiKey.findFirst({
317+
where: {
318+
keyHash: hashApiKey(apiKey),
319+
revokedAt: null,
320+
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
321+
},
322+
select: {
323+
runtimeEnvironment: {
324+
select: {
325+
id: true,
326+
project: { select: { deletedAt: true } },
327+
organization: { select: { apiRateLimiterConfig: true } },
328+
},
329+
},
330+
},
331+
});
332+
333+
if (!match?.runtimeEnvironment || match.runtimeEnvironment.project.deletedAt) {
334+
return null;
335+
}
336+
337+
return {
338+
environmentId: match.runtimeEnvironment.id,
339+
apiRateLimiterConfig: match.runtimeEnvironment.organization.apiRateLimiterConfig,
340+
};
341+
}
342+
343+
const environment = await tx.runtimeEnvironment.findFirst({
344+
where: { apiKey },
345+
select: {
346+
id: true,
347+
project: { select: { deletedAt: true } },
348+
organization: { select: { apiRateLimiterConfig: true } },
349+
},
350+
});
351+
352+
if (environment) {
353+
if (environment.project.deletedAt) {
354+
return null;
355+
}
356+
357+
return {
358+
environmentId: environment.id,
359+
apiRateLimiterConfig: environment.organization.apiRateLimiterConfig,
360+
};
361+
}
362+
363+
const revokedApiKey = await tx.revokedApiKey.findFirst({
364+
where: { apiKey, expiresAt: { gt: now } },
365+
select: {
366+
runtimeEnvironment: {
367+
select: {
368+
id: true,
369+
project: { select: { deletedAt: true } },
370+
organization: { select: { apiRateLimiterConfig: true } },
371+
},
372+
},
373+
},
374+
});
375+
376+
const revokedEnvironment = revokedApiKey?.runtimeEnvironment;
377+
if (!revokedEnvironment || revokedEnvironment.project.deletedAt) {
378+
return null;
379+
}
380+
381+
return {
382+
environmentId: revokedEnvironment.id,
383+
apiRateLimiterConfig: revokedEnvironment.organization.apiRateLimiterConfig,
384+
};
385+
}
386+
304387
/**
305388
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
306389
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).

0 commit comments

Comments
 (0)