Skip to content

Commit 67e834a

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): accept run tokens across runtime API routes
Expand short-lived run-token authentication across runtime APIs, with permission checks for packets, metadata, streams and waits, bounded stream credentials, and server-minted public tokens. Preserve production and staging branch-header compatibility while rejecting mismatched branches in preview and development environments. Mono-RevId: f588439dd6eec395c0be7e2b363b6eae124921c1
1 parent ffc9075 commit 67e834a

52 files changed

Lines changed: 2252 additions & 585 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: breaking
4+
---
5+
6+
Query dashboard APIs reject run tokens and public access tokens without user delegation. Existing API key access, including restricted keys with the required query scopes, is unchanged. MCP dashboard access remains supported through delegated tokens for current organization members.
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+
Expand short-lived run-token support to task APIs, with permission checks for packets, metadata, streams and waits. Run tokens last 24 hours independently of root-key rotation and cannot be individually revoked. They can mint public access tokens for up to 30 days; those tokens may outlive the run token that minted them.

‎apps/webapp/app/routes/api.v1.auth.jwt.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export async function action({ request }: LoaderFunctionArgs) {
5959
if (!authenticationResult.ok) {
6060
return json({ error: "Invalid or Missing API key" }, { status: authenticationResult.status });
6161
}
62+
if (authenticationResult.subject.type === "runToken") {
63+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
64+
}
6265

6366
// A revoked key still authenticates through the grace window (by design,
6467
// for zero-downtime rotation). It must not mint new JWTs: those are signed
Lines changed: 71 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,87 @@
1-
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2-
import { json } from "@remix-run/server-runtime";
3-
import { z } from "zod";
4-
import { authenticateApiRequest } from "~/services/apiAuth.server";
1+
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { isPublicJWT } from "@trigger.dev/core/v3/jwt";
3+
import type { ApiAuthenticationResultSuccess } from "~/services/apiAuth.server";
4+
import { authenticateBearerWithTelemetry } from "~/services/authTelemetry.server";
55
import { logger } from "~/services/logger.server";
6+
import { apiCors } from "~/utils/apiCors";
7+
import {
8+
assertPacketNamespaceAccess,
9+
createPacketUploadRoute,
10+
PacketParamsSchema,
11+
} from "~/services/packetApi.server";
612
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
713
import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server";
814

9-
const ParamsSchema = z.object({
10-
"*": z.string(),
11-
});
15+
const { action } = createPacketUploadRoute({ forceNoPrefix: true });
16+
export { action };
1217

13-
export async function action({ request, params }: ActionFunctionArgs) {
14-
// Ensure this is a POST request
15-
if (request.method.toUpperCase() !== "PUT") {
16-
return { status: 405, body: "Method Not Allowed" };
17-
}
18-
19-
// Next authenticate the request
20-
const authenticationResult = await authenticateApiRequest(request);
21-
22-
if (!authenticationResult) {
23-
return json({ error: "Invalid or Missing API key" }, { status: 401 });
18+
const scopedLoader = createLoaderApiRoute(
19+
{
20+
params: PacketParamsSchema,
21+
allowJWT: true,
22+
corsStrategy: "all",
23+
findResource: async () => ({ type: "packets" }),
24+
authorization: {
25+
action: "read",
26+
resource: (packets) => packets,
27+
},
28+
},
29+
async ({ params, authentication }) => {
30+
assertPacketNamespaceAccess(authentication);
31+
return downloadPacket(authentication, params["*"]);
2432
}
33+
);
2534

26-
const parsedParams = ParamsSchema.parse(params);
27-
const filename = parsedParams["*"];
28-
35+
async function downloadPacket(authentication: ApiAuthenticationResultSuccess, filename: string) {
2936
const signed = await generatePresignedUrl(
30-
authenticationResult.environment.project.externalRef,
31-
authenticationResult.environment.slug,
37+
authentication.environment.project.externalRef,
38+
authentication.environment.slug,
3239
filename,
33-
"PUT",
34-
{ forceNoPrefix: true }
40+
"GET"
3541
);
36-
37-
if (!signed.success) {
38-
return jsonPacketPresignFailure(signed);
39-
}
40-
41-
// Caller can now use this URL to upload to that object.
42-
return json({ presignedUrl: signed.url });
42+
return signed.success ? json({ presignedUrl: signed.url }) : jsonPacketPresignFailure(signed);
4343
}
4444

45-
export const loader = createLoaderApiRoute(
46-
{
47-
params: ParamsSchema,
48-
allowJWT: true,
49-
corsStrategy: "all",
50-
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
51-
},
52-
async ({ params, authentication }) => {
53-
const filename = params["*"];
45+
export async function loader(args: LoaderFunctionArgs) {
46+
const { request, params } = args;
47+
const bearer = request.headers
48+
.get("Authorization")
49+
?.replace(/^Bearer /, "")
50+
.trim();
51+
if (request.method === "OPTIONS" || !bearer || !isPublicJWT(bearer)) {
52+
return scopedLoader(args);
53+
}
5454

55-
// Public tokens should use /api/v1/runs/:runId/packets/:field, which authorizes
56-
// against the owning run. Access here is kept for clients on older SDKs.
57-
if (authentication.type === "PUBLIC_JWT") {
58-
logger.info("Legacy packet download with public token", {
59-
environmentId: authentication.environment.id,
60-
projectId: authentication.environment.projectId,
61-
});
55+
// ponytail: legacy public-JWT GET remains in every environment, including DEVELOPMENT's shared
56+
// packet namespace, until the SEC-762 diagnostic log drains. Other credentials use packet abilities.
57+
try {
58+
const result = await authenticateBearerWithTelemetry(request, { allowJWT: true });
59+
if (!result.ok) {
60+
return apiCors(request, json({ error: result.error }, { status: result.status }));
6261
}
63-
64-
const signed = await generatePresignedUrl(
65-
authentication.environment.project.externalRef,
66-
authentication.environment.slug,
67-
filename,
68-
"GET"
69-
);
70-
71-
if (!signed.success) {
72-
return jsonPacketPresignFailure(signed);
62+
if (result.subject.type !== "publicJWT") {
63+
return apiCors(request, json({ error: "Invalid Public Access Token" }, { status: 401 }));
7364
}
74-
75-
// Caller can now use this URL to fetch that object.
76-
return json({ presignedUrl: signed.url });
65+
const authentication: ApiAuthenticationResultSuccess = {
66+
ok: true,
67+
apiKey: result.environment.apiKey,
68+
type: "PUBLIC_JWT",
69+
environmentScoped: true,
70+
credentialKind: result.resolution.credentialKind,
71+
environment: result.environment,
72+
};
73+
const parsed = PacketParamsSchema.safeParse(params);
74+
if (!parsed.success) {
75+
return apiCors(request, json({ error: "Params Error" }, { status: 400 }));
76+
}
77+
logger.info("Legacy packet download with public token", {
78+
environmentId: authentication.environment.id,
79+
projectId: authentication.environment.projectId,
80+
environmentType: authentication.environment.type,
81+
});
82+
return apiCors(request, await downloadPacket(authentication, parsed.data["*"]));
83+
} catch (error) {
84+
if (error instanceof Response) return apiCors(request, error);
85+
throw error;
7786
}
78-
);
87+
}

‎apps/webapp/app/routes/api.v1.query.dashboards.$dashboardId.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { assertDashboardApiAccess } from "~/services/metricDashboardApiAccess.server";
23
import { tryCatch } from "@trigger.dev/core/utils";
34
import {
45
ReplaceDashboardRequestBody,
@@ -32,6 +33,7 @@ export const loader = createLoaderApiRoute(
3233
},
3334
},
3435
async ({ params, authentication }) => {
36+
await assertDashboardApiAccess(authentication);
3537
const [error, dashboard] = await tryCatch(
3638
new MetricDashboardService().get({
3739
organizationId: authentication.environment.organizationId,
@@ -58,6 +60,7 @@ const { action } = createMultiMethodApiRoute({
5860
PATCH: {
5961
body: UpdateDashboardRequestBody,
6062
handler: async ({ params, body, authentication }) => {
63+
await assertDashboardApiAccess(authentication);
6164
const input = body as z.infer<typeof UpdateDashboardRequestBody>;
6265
const [error, dashboard] = await tryCatch(
6366
new MetricDashboardService().update({
@@ -79,6 +82,7 @@ const { action } = createMultiMethodApiRoute({
7982
PUT: {
8083
body: ReplaceDashboardRequestBody,
8184
handler: async ({ params, body, authentication }) => {
85+
await assertDashboardApiAccess(authentication);
8286
const input = body as z.infer<typeof ReplaceDashboardRequestBody>;
8387
const [error, dashboard] = await tryCatch(
8488
(async () => {
@@ -104,6 +108,7 @@ const { action } = createMultiMethodApiRoute({
104108
},
105109
DELETE: {
106110
handler: async ({ params, authentication }) => {
111+
await assertDashboardApiAccess(authentication);
107112
const [error, dashboard] = await tryCatch(
108113
new MetricDashboardService().delete({
109114
organizationId: authentication.environment.organizationId,

‎apps/webapp/app/routes/api.v1.query.dashboards.$dashboardId.widgets.$widgetId.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { assertDashboardApiAccess } from "~/services/metricDashboardApiAccess.server";
23
import { tryCatch } from "@trigger.dev/core/utils";
34
import { UpdateDashboardWidgetRequestBody } from "@trigger.dev/core/v3/schemas";
45
import { z } from "zod";
@@ -31,6 +32,7 @@ const { action } = createMultiMethodApiRoute({
3132
PATCH: {
3233
body: UpdateDashboardWidgetRequestBody,
3334
handler: async ({ params, body, authentication }) => {
35+
await assertDashboardApiAccess(authentication);
3436
const { revision, ...changes } = body as z.infer<typeof UpdateDashboardWidgetRequestBody>;
3537
const service = new MetricDashboardService();
3638
const ref = {
@@ -75,6 +77,7 @@ const { action } = createMultiMethodApiRoute({
7577
},
7678
DELETE: {
7779
handler: async ({ params, searchParams, authentication }) => {
80+
await assertDashboardApiAccess(authentication);
7881
const [error, result] = await tryCatch(
7982
new MetricDashboardService().deleteWidget({
8083
organizationId: authentication.environment.organizationId,

‎apps/webapp/app/routes/api.v1.query.dashboards.$dashboardId.widgets._index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { assertDashboardApiAccess } from "~/services/metricDashboardApiAccess.server";
23
import { tryCatch } from "@trigger.dev/core/utils";
34
import { AddDashboardWidgetRequestBody } from "@trigger.dev/core/v3/schemas";
45
import { z } from "zod";
@@ -26,6 +27,7 @@ const { action } = createActionApiRoute(
2627
},
2728
},
2829
async ({ params, body, authentication }) => {
30+
await assertDashboardApiAccess(authentication);
2931
const { revision, ...widget } = body;
3032

3133
const [error, result] = await tryCatch(

‎apps/webapp/app/routes/api.v1.query.dashboards._index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { assertDashboardApiAccess } from "~/services/metricDashboardApiAccess.server";
23
import { tryCatch } from "@trigger.dev/core/utils";
34
import {
45
CreateDashboardRequestBody,
@@ -57,6 +58,7 @@ export const loader = createLoaderApiRoute(
5758
},
5859
},
5960
async ({ authentication }) => {
61+
await assertDashboardApiAccess(authentication);
6062
const builtIn = BUILT_IN_DASHBOARD_KEYS.map((key) => {
6163
try {
6264
return serializeDashboard(builtInDashboard(key));
@@ -84,6 +86,7 @@ const { action } = createActionApiRoute(
8486
},
8587
},
8688
async ({ body, authentication }) => {
89+
await assertDashboardApiAccess(authentication);
8790
const [error, dashboard] = await tryCatch(
8891
(async () => {
8992
if (body.widgets) {

‎apps/webapp/app/routes/api.v1.query.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const { action, loader } = createActionApiRoute(
5858

5959
// The credential's own scope is the ceiling, not the body's. See queryScope.ts.
6060
const resolvedScope = resolveQueryScope({
61-
ceiling: queryScopeCeilingFor(authentication.type),
61+
ceiling: queryScopeCeilingFor(authentication),
6262
requested: scope as QueryScope,
6363
});
6464
if (!resolvedScope.ok) {

‎apps/webapp/app/routes/api.v1.runs.$runFriendlyId.input-streams.wait.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
} from "~/services/inputStreamWaitpointCache.server";
1515
import { logger } from "~/services/logger.server";
1616
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
17-
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
17+
import { createActionApiRoute, everyResource } from "~/services/routeBuilders/apiBuilder.server";
1818
import { parseDelay } from "~/utils/delays";
1919
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
2020
import { engine } from "~/v3/runEngine.server";
@@ -30,8 +30,17 @@ const { action, loader } = createActionApiRoute(
3030
body: CreateInputStreamWaitpointRequestBody,
3131
maxContentLength: 1024 * 10, // 10KB
3232
method: "POST",
33+
authorization: {
34+
action: "write",
35+
resource: (params) =>
36+
everyResource([{ type: "runs", id: params.runFriendlyId }, { type: "waitpoints" }]),
37+
},
3338
},
34-
async ({ authentication, body, params }) => {
39+
async ({ authentication, body, params, ability }) => {
40+
if (!ability.can("read", { type: "inputStreams", id: params.runFriendlyId })) {
41+
return json({ error: "Unauthorized" }, { status: 403 });
42+
}
43+
3544
try {
3645
const where = {
3746
friendlyId: params.runFriendlyId,

0 commit comments

Comments
 (0)