Skip to content
14 changes: 8 additions & 6 deletions app/api/tasks/runs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ export async function OPTIONS() {
/**
* GET /api/tasks/runs
*
* Retrieves the status of a Trigger.dev task run.
* Returns one of three possible statuses:
* - pending: Task is still running
* - complete: Task completed successfully with data
* - failed: Task failed with error message
* Retrieves task runs from Trigger.dev.
* Supports two modes:
* - Retrieve mode: when `runId` is provided, returns a single run in `runs: [run]`
* - List mode: when `runId` is omitted, returns recent runs for the authorized account in `runs: []`
*
* Query parameters:
* - runId (required): The unique identifier of the task run
* - runId (optional): The unique identifier of the task run
* - account_id (optional): Scope to this account when allowed: admin, org-authorized member, or
* personal key with account_id equal to the authenticated account (self-access).
* - limit (optional): Number of runs to return in list mode (default 20, max 100)
*
* @param request - The request object containing query parameters.
* @returns A NextResponse with task run status.
Expand Down
33 changes: 30 additions & 3 deletions lib/tasks/__tests__/getTaskRunHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ describe("getTaskRunHandler", () => {

describe("retrieve mode", () => {
it("wraps a single run in { status, runs[] }", async () => {
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "retrieve", runId: "run_123" });
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({
mode: "retrieve",
runId: "run_123",
accountId: "acc_123",
});
vi.mocked(retrieveTaskRun).mockResolvedValue(mockRun);

const response = await getTaskRunHandler(createMockRequest());
Expand All @@ -74,22 +78,45 @@ describe("getTaskRunHandler", () => {
});

it("returns 404 when run is not found", async () => {
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "retrieve", runId: "run_x" });
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({
mode: "retrieve",
runId: "run_x",
accountId: "acc_123",
});
vi.mocked(retrieveTaskRun).mockResolvedValue(null);

const response = await getTaskRunHandler(createMockRequest());
expect(response.status).toBe(404);
});

it("returns 500 when retrieveTaskRun throws", async () => {
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({ mode: "retrieve", runId: "run_123" });
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({
mode: "retrieve",
runId: "run_123",
accountId: "acc_123",
});
vi.mocked(retrieveTaskRun).mockRejectedValue(new Error("API error"));

const response = await getTaskRunHandler(createMockRequest());
expect(response.status).toBe(500);
const json = await response.json();
expect(json.error).toBe("API error");
});

it("returns 404 when run does not belong to the authorized account (no enumeration)", async () => {
vi.mocked(validateGetTaskRunQuery).mockResolvedValue({
mode: "retrieve",
runId: "run_123",
accountId: "acc_999",
});
vi.mocked(retrieveTaskRun).mockResolvedValue(mockRun);

const response = await getTaskRunHandler(createMockRequest());
const json = await response.json();

expect(response.status).toBe(404);
expect(json.error).toBe("Task run not found");
});
});

describe("list mode", () => {
Expand Down
23 changes: 21 additions & 2 deletions lib/tasks/__tests__/validateGetTaskRunQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe("validateGetTaskRunQuery", () => {
const result = await validateGetTaskRunQuery(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ mode: "retrieve", runId: "run_abc123" });
expect(result).toEqual({ mode: "retrieve", runId: "run_abc123", accountId: "acc_123" });
});

it("trims whitespace from runId", async () => {
Expand All @@ -111,7 +111,7 @@ describe("validateGetTaskRunQuery", () => {
const result = await validateGetTaskRunQuery(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ mode: "retrieve", runId: "run_abc123" });
expect(result).toEqual({ mode: "retrieve", runId: "run_abc123", accountId: "acc_123" });
});

it("returns list mode with custom limit", async () => {
Expand Down Expand Up @@ -210,5 +210,24 @@ describe("validateGetTaskRunQuery", () => {
expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ mode: "list", accountId: "acc_123", limit: 20 });
});

it("applies account_id override in retrieve mode for admin", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "admin_acc",
orgId: null,
authToken: "bearer-token",
});
vi.mocked(checkIsAdmin).mockResolvedValue(true);

const request = createMockRequest(
"http://localhost:3000/api/tasks/runs?runId=run_123&account_id=other_acc",
);

const result = await validateGetTaskRunQuery(request);

expect(result).not.toBeInstanceOf(NextResponse);
expect(result).toEqual({ mode: "retrieve", runId: "run_123", accountId: "other_acc" });
expect(validateAccountIdOverride).not.toHaveBeenCalled();
});
});
});
11 changes: 11 additions & 0 deletions lib/tasks/getTaskRunHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ export async function getTaskRunHandler(request: NextRequest): Promise<NextRespo
);
}

const tags = Array.isArray((result as { tags?: unknown }).tags)
? ((result as { tags?: unknown[] }).tags ?? [])
: [];
const hasAccountAccess = tags.includes(`account:${validatedQuery.accountId}`);
if (!hasAccountAccess) {
return NextResponse.json(
{ status: "error", error: "Task run not found" },
{ status: 404, headers: getCorsHeaders() },
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.

SRP

  • actual: validation logic included in the handler.
  • required: validation logic is placed in the validator function.


return NextResponse.json(
{ status: "success", runs: [result] },
{ status: 200, headers: getCorsHeaders() },
Expand Down
12 changes: 6 additions & 6 deletions lib/tasks/validateGetTaskRunQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ const getTaskRunQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
});

export type ValidatedRetrieveQuery = { mode: "retrieve"; runId: string };
export type ValidatedRetrieveQuery = { mode: "retrieve"; runId: string; accountId: string };
export type ValidatedListQuery = { mode: "list"; accountId: string; limit: number };
export type GetTaskRunQuery = ValidatedRetrieveQuery | ValidatedListQuery;

/**
* Validates auth context and query parameters for GET /api/tasks/runs.
*
* Returns a discriminated union:
* - `{ mode: "retrieve", runId }` when runId is provided
* - `{ mode: "retrieve", runId, accountId }` when runId is provided
* - `{ mode: "list", accountId, limit }` when runId is omitted
*
* When account_id is provided:
Expand Down Expand Up @@ -68,10 +68,6 @@ export async function validateGetTaskRunQuery(
);
}

if (result.data.runId) {
return { mode: "retrieve", runId: result.data.runId };
}

// Resolve the target account ID
let targetAccountId = authResult.accountId;

Expand All @@ -95,5 +91,9 @@ export async function validateGetTaskRunQuery(
}
}

if (result.data.runId) {
return { mode: "retrieve", runId: result.data.runId, accountId: targetAccountId };
}

return { mode: "list", accountId: targetAccountId, limit: result.data.limit };
}
Loading