Skip to content

Commit 608eb0f

Browse files
committed
fix(core,sdk,webapp): allow task-scoped keys to create batches
1 parent dd68bc9 commit 608eb0f

7 files changed

Lines changed: 149 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Allow task-scoped environment API keys to use batch operations for their permitted tasks.

apps/webapp/app/routes/api.v3.batches.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,19 @@ import { getOneTimeUseToken } from "~/services/apiAuth.server";
1010
import { logger } from "~/services/logger.server";
1111
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
1212
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
13-
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
13+
import {
14+
anyResource,
15+
createActionApiRoute,
16+
everyResource,
17+
} from "~/services/routeBuilders/apiBuilder.server";
1418
import { batchPublicAccessScopes } from "~/utils/batchItemAuthorization";
1519
import { canWriteParentRun } from "~/utils/parentRunAuthorization.server";
1620
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
1721
import {
1822
handleRequestIdempotency,
1923
saveRequestIdempotency,
2024
} from "~/utils/requestIdempotency.server";
25+
import { scopeRequestIdempotencyKey } from "~/utils/requestIdempotencyKey";
2126
import { sanitizeTriggerSource } from "~/utils/triggerSource";
2227
import { ServiceValidationError } from "~/v3/services/baseService.server";
2328
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
@@ -40,9 +45,21 @@ const { action, loader } = createActionApiRoute(
4045
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
4146
authorization: {
4247
action: "batchTrigger",
43-
// No specific tasks exist yet. This grant only creates the batch shell;
44-
// phase two authorizes every streamed task before enqueueing it.
45-
resource: () => anyResource([{ type: "batch" }, { type: "tasks" }]),
48+
resource: (_params, _searchParams, _headers, body) => {
49+
// Newer clients declare the distinct task identifiers before creating
50+
// the batch, so selected-task credentials can be authorized before the
51+
// shell is created. Older clients omit them and retain the existing
52+
// collection-level behavior: broad credentials pass, selected-task
53+
// credentials fail closed.
54+
if (!body.taskIdentifiers) {
55+
return anyResource([{ type: "batch" }, { type: "tasks" }]);
56+
}
57+
58+
return everyResource(
59+
body.taskIdentifiers.map((id) => ({ type: "tasks" as const, id })),
60+
[{ type: "batch" }, { type: "tasks" }]
61+
);
62+
},
4663
},
4764
corsStrategy: "all",
4865
},
@@ -96,11 +113,18 @@ const { action, loader } = createActionApiRoute(
96113
triggerClient,
97114
});
98115

99-
// Handle idempotency for the batch creation
116+
// Keep create-batch retries isolated by environment and the task set that
117+
// was authorized above. Sorting makes the scope stable when callers send
118+
// the same identifiers in a different order.
119+
const scopedIdempotencyKey = scopeRequestIdempotencyKey(body.idempotencyKey, [
120+
authentication.environment.id,
121+
...(body.taskIdentifiers ? [...new Set(body.taskIdentifiers)].sort() : []),
122+
]);
123+
100124
const cachedResponse = await handleRequestIdempotency<
101125
{ friendlyId: string; runCount: number },
102126
CreateBatchResponse
103-
>(body.idempotencyKey, {
127+
>(scopedIdempotencyKey, {
104128
requestType: "create-batch",
105129
findCachedEntity: async (cachedRequestId) => {
106130
const batch = await engine.runStore.findBatchTaskRunById(cachedRequestId);
@@ -133,7 +157,7 @@ const { action, loader } = createActionApiRoute(
133157
const service = new CreateBatchService();
134158

135159
service.onBatchTaskRunCreated.attachOnce(async (batch) => {
136-
await saveRequestIdempotency(body.idempotencyKey, "create-batch", batch.id);
160+
await saveRequestIdempotency(scopedIdempotencyKey, "create-batch", batch.id);
137161
});
138162

139163
try {

apps/webapp/test/auth-api.e2e.full.test.ts

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -744,13 +744,16 @@ describe("API", () => {
744744
});
745745
});
746746

747-
// v3 batches use a collection-level resource { type: "tasks" } with
748-
// no id — items are validated per-row when streamed. So id-specific
749-
// scopes (write:tasks:foo) shouldn't grant blanket access; only
750-
// type-level write:tasks (or admin/write:all) should.
751-
describe("Trigger task — batch v3 (api.v3.batches) collection-level", () => {
747+
// v3 batch creation accepts an optional declaration of the distinct task
748+
// identifiers that will be streamed. New clients send it so selected-task
749+
// credentials can authorize the batch before its shell is created. Older
750+
// clients omit it and retain the collection-level, fail-closed behavior.
751+
describe("Trigger task — batch v3 (api.v3.batches)", () => {
752752
const path = "/api/v3/batches";
753-
const buildBody = () => ({ runCount: 1 });
753+
const buildBody = (taskIdentifiers?: string[]) => ({
754+
runCount: taskIdentifiers?.length ?? 1,
755+
taskIdentifiers,
756+
});
754757

755758
it("missing auth: 401", async () => {
756759
const server = getTestServer();
@@ -779,6 +782,67 @@ describe("API", () => {
779782
expect(res.status).not.toBe(403);
780783
});
781784

785+
it("JWT with batchTrigger:tasks:taskA + declared taskA: auth passes", async () => {
786+
const server = getTestServer();
787+
const seed = await seedTestEnvironment(server.prisma);
788+
const jwt = await generateJWT({
789+
secretKey: seed.apiKey,
790+
payload: {
791+
pub: true,
792+
sub: seed.environment.id,
793+
scopes: ["batchTrigger:tasks:taskA"],
794+
},
795+
expirationTime: "15m",
796+
});
797+
const res = await server.webapp.fetch(path, {
798+
method: "POST",
799+
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
800+
body: JSON.stringify(buildBody(["taskA"])),
801+
});
802+
expect(res.status).not.toBe(401);
803+
expect(res.status).not.toBe(403);
804+
});
805+
806+
it("JWT with batchTrigger:tasks:taskA + a mixed declaration: 403", async () => {
807+
const server = getTestServer();
808+
const seed = await seedTestEnvironment(server.prisma);
809+
const jwt = await generateJWT({
810+
secretKey: seed.apiKey,
811+
payload: {
812+
pub: true,
813+
sub: seed.environment.id,
814+
scopes: ["batchTrigger:tasks:taskA"],
815+
},
816+
expirationTime: "15m",
817+
});
818+
const res = await server.webapp.fetch(path, {
819+
method: "POST",
820+
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
821+
body: JSON.stringify(buildBody(["taskA", "taskB"])),
822+
});
823+
expect(res.status).toBe(403);
824+
});
825+
826+
it("JWT with batchTrigger:tasks:taskA + no declaration: 403", async () => {
827+
const server = getTestServer();
828+
const seed = await seedTestEnvironment(server.prisma);
829+
const jwt = await generateJWT({
830+
secretKey: seed.apiKey,
831+
payload: {
832+
pub: true,
833+
sub: seed.environment.id,
834+
scopes: ["batchTrigger:tasks:taskA"],
835+
},
836+
expirationTime: "15m",
837+
});
838+
const res = await server.webapp.fetch(path, {
839+
method: "POST",
840+
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
841+
body: JSON.stringify(buildBody()),
842+
});
843+
expect(res.status).toBe(403);
844+
});
845+
782846
it("JWT with read:tasks: 403", async () => {
783847
const server = getTestServer();
784848
const seed = await seedTestEnvironment(server.prisma);

packages/core/src/v3/schemas/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,8 @@ export type BatchTriggerTaskV3Response = z.infer<typeof BatchTriggerTaskV3Respon
430430
export const CreateBatchRequestBody = z.object({
431431
/** Expected number of items in the batch */
432432
runCount: z.number().int().positive(),
433+
/** Distinct task identifiers expected in the item stream */
434+
taskIdentifiers: z.array(z.string().min(1)).min(1).optional(),
433435
/** Parent run ID for batchTriggerAndWait (friendly ID) */
434436
parentRunId: z.string().optional(),
435437
/** Whether to resume parent on completion (true for batchTriggerAndWait) */

packages/core/src/v3/schemas/idempotencyKey.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@ describe("idempotencyKey length validation", () => {
9797

9898
expect(result.success).toBe(true);
9999
});
100+
101+
it("accepts a non-empty task identifier declaration", () => {
102+
const result = CreateBatchRequestBody.safeParse({
103+
runCount: 2,
104+
taskIdentifiers: ["task-a", "task-b"],
105+
});
106+
107+
expect(result.success).toBe(true);
108+
});
109+
110+
it("rejects an empty task identifier declaration", () => {
111+
const result = CreateBatchRequestBody.safeParse({
112+
runCount: 1,
113+
taskIdentifiers: [],
114+
});
115+
116+
expect(result.success).toBe(false);
117+
});
100118
});
101119

102120
describe("CreateWaitpointTokenRequestBody", () => {

packages/trigger-sdk/src/v3/shared.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import { ApiClient } from "@trigger.dev/core/v3";
22
import { describe, it, expect } from "vitest";
3-
import { offloadBatchItemPayloads, readableStreamToAsyncIterable } from "./shared.js";
3+
import {
4+
offloadBatchItemPayloads,
5+
readableStreamToAsyncIterable,
6+
uniqueBatchTaskIdentifiers,
7+
} from "./shared.js";
8+
9+
describe("uniqueBatchTaskIdentifiers", () => {
10+
it("returns a stable, deduplicated declaration for batch creation", () => {
11+
expect(
12+
uniqueBatchTaskIdentifiers([
13+
{ index: 0, task: "task-b", payload: "{}" },
14+
{ index: 1, task: "task-a", payload: "{}" },
15+
{ index: 2, task: "task-b", payload: "{}" },
16+
])
17+
).toEqual(["task-a", "task-b"]);
18+
});
19+
});
420

521
describe("offloadBatchItemPayloads", () => {
622
// A real client is required for conditionallyExportPacket's truthy check; small payloads

packages/trigger-sdk/src/v3/shared.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,6 +1644,10 @@ export async function batchTriggerAndWaitTasks<TTasks extends readonly AnyTask[]
16441644
* @param requestOptions - Optional request options
16451645
* @internal
16461646
*/
1647+
export function uniqueBatchTaskIdentifiers(items: BatchItemNDJSON[]): string[] {
1648+
return Array.from(new Set(items.map((item) => item.task))).sort();
1649+
}
1650+
16471651
async function executeBatchTwoPhase(
16481652
apiClient: ReturnType<typeof apiClientManager.clientOrThrow>,
16491653
items: BatchItemNDJSON[],
@@ -1678,6 +1682,7 @@ async function executeBatchTwoPhase(
16781682
batch = await apiClient.createBatch(
16791683
{
16801684
runCount: items.length,
1685+
taskIdentifiers: uniqueBatchTaskIdentifiers(items),
16811686
parentRunId: options.parentRunId,
16821687
resumeParentOnCompletion: options.resumeParentOnCompletion,
16831688
idempotencyKey: options.idempotencyKey,

0 commit comments

Comments
 (0)