Skip to content

Commit 59378cf

Browse files
committed
feat(webapp): let the agent's environment JWT read a queue's own row
1 parent 60a0c4c commit 59378cf

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

apps/webapp/app/routes/api.v1.queues.$queueParam.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export const loader = createLoaderApiRoute(
1414
queueParam: z.string().transform((val) => val.replace(/%2F/g, "/")),
1515
}),
1616
searchParams: SearchParamsSchema,
17+
// The agent's environment JWT reads a queue's own row — name, depth, limit, paused —
18+
// the way it already reads that queue's metrics. The `queues` scope still gates it.
19+
allowJWT: true,
1720
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
1821
authorization: {
1922
action: "read",
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { generateJWT } from "@trigger.dev/core/v3/jwt";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
/**
5+
* The agent reads a queue's live row — paused, depth, limit — through the environment JWT it
6+
* exchanges its delegated token for. Metrics already answer that JWT; without the same on the
7+
* retrieve route the agent got a 401, which reaches the model as absent data and had it
8+
* telling users a queue of thousands of runs did not exist.
9+
*
10+
* These drive the real loader with a real signed environment JWT: the route builder
11+
* authenticates it, compiles its scopes into an ability, and gates on `read:queues`.
12+
*/
13+
14+
const ENVIRONMENT_ID = "env_1234";
15+
const API_KEY = "tr_dev_abcdefghijklmnop";
16+
17+
const environment = {
18+
id: ENVIRONMENT_ID,
19+
type: "DEVELOPMENT",
20+
slug: "dev",
21+
branchName: null,
22+
apiKey: API_KEY,
23+
organizationId: "org_1",
24+
projectId: "proj_1",
25+
archivedAt: null,
26+
concurrencyLimitBurstFactor: 1,
27+
maximumConcurrencyLimit: 10,
28+
project: { id: "proj_1", externalRef: "proj_ref", deletedAt: null },
29+
organization: { id: "org_1" },
30+
orgMember: null,
31+
parentEnvironment: null,
32+
};
33+
34+
const queueRow = {
35+
id: "tq_1",
36+
friendlyId: "queue_1234",
37+
name: "task/my-task",
38+
type: "VIRTUAL",
39+
runtimeEnvironmentId: ENVIRONMENT_ID,
40+
paused: true,
41+
concurrencyLimit: 5,
42+
concurrencyLimitBase: 5,
43+
concurrencyLimitOverriddenAt: null,
44+
concurrencyLimitOverriddenBy: null,
45+
concurrencyLimitOverridePercent: null,
46+
};
47+
48+
const mocks = vi.hoisted(() => ({
49+
runtimeEnvironmentFindFirst: vi.fn(),
50+
taskQueueFindFirst: vi.fn(),
51+
revokedApiKeyFindMany: vi.fn(),
52+
}));
53+
54+
vi.mock("~/db.server", () => {
55+
const client = {
56+
runtimeEnvironment: { findFirst: mocks.runtimeEnvironmentFindFirst },
57+
taskQueue: { findFirst: mocks.taskQueueFindFirst },
58+
revokedApiKey: { findMany: mocks.revokedApiKeyFindMany, findFirst: async () => null },
59+
};
60+
return { prisma: client, $replica: client };
61+
});
62+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
63+
vi.mock("~/v3/engineVersion.server", () => ({ determineEngineVersion: async () => "V2" }));
64+
vi.mock("~/v3/runEngine.server", () => ({
65+
engine: {
66+
lengthOfQueues: async () => ({ "task/my-task": 1234 }),
67+
currentConcurrencyOfQueues: async () => ({ "task/my-task": 2 }),
68+
},
69+
}));
70+
vi.mock("~/services/logger.server", () => ({
71+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
72+
}));
73+
vi.mock("~/v3/services/worker/workerGroupTokenService.server", () => ({
74+
WorkerGroupTokenService: class {},
75+
}));
76+
77+
import { loader } from "~/routes/api.v1.queues.$queueParam";
78+
79+
/** The claims the env-JWT exchange mints (api.v1.projects.$projectRef.$env.jwt.ts). */
80+
function mintEnvJwt(scopes: string[]) {
81+
return generateJWT({
82+
secretKey: API_KEY,
83+
payload: {
84+
sub: ENVIRONMENT_ID,
85+
pub: true,
86+
scopes,
87+
act: { sub: "usr_1", client: "dashboard-agent" },
88+
},
89+
expirationTime: "1h",
90+
});
91+
}
92+
93+
async function retrieveQueue(token: string) {
94+
const response = await loader({
95+
request: new Request("https://api.trigger.dev/api/v1/queues/my-task?type=task", {
96+
headers: { Authorization: `Bearer ${token}` },
97+
}),
98+
params: { queueParam: "my-task" },
99+
context: {},
100+
} as any);
101+
return { status: response.status, body: await response.json() };
102+
}
103+
104+
describe("queue retrieve through an environment JWT", () => {
105+
beforeEach(() => {
106+
// Only the JWT's own `sub` lookup resolves — a bearer read as an API key finds nothing.
107+
mocks.runtimeEnvironmentFindFirst
108+
.mockReset()
109+
.mockImplementation(async ({ where }: any) =>
110+
where?.id === ENVIRONMENT_ID ? environment : null
111+
);
112+
mocks.taskQueueFindFirst.mockReset().mockResolvedValue(queueRow);
113+
mocks.revokedApiKeyFindMany.mockReset().mockResolvedValue([]);
114+
});
115+
116+
it("answers a JWT carrying read:queues with the queue's live row", async () => {
117+
const result = await retrieveQueue(await mintEnvJwt(["read:runs", "read:queues"]));
118+
119+
expect(result.status).toBe(200);
120+
expect(result.body).toMatchObject({
121+
id: "queue_1234",
122+
name: "my-task",
123+
paused: true,
124+
queued: 1234,
125+
});
126+
});
127+
128+
it("refuses a JWT without it — widening who may ask must not widen what they may read", async () => {
129+
const result = await retrieveQueue(await mintEnvJwt(["read:runs", "read:query"]));
130+
131+
expect(result.status).toBe(403);
132+
expect(mocks.taskQueueFindFirst).not.toHaveBeenCalled();
133+
});
134+
});

0 commit comments

Comments
 (0)