diff --git a/.changeset/purple-snakes-divide.md b/.changeset/purple-snakes-divide.md new file mode 100644 index 0000000000..ec6b64ca7f --- /dev/null +++ b/.changeset/purple-snakes-divide.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/react-hooks": patch +"@trigger.dev/sdk": patch +--- + +Public access token scopes with just tags or just a batch can now access runs that have those tags or are in the batch. Previously, the only way to access a run was to have a specific scope for that exact run. diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts deleted file mode 100644 index 19e0b2973a..0000000000 --- a/apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RetrieveBatchResponse } from "@trigger.dev/core/v3"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { BasePresenter } from "./basePresenter.server"; - -export class ApiRetrieveBatchPresenter extends BasePresenter { - public async call( - friendlyId: string, - env: AuthenticatedEnvironment - ): Promise { - return this.traceWithEnv("call", env, async (span) => { - const batch = await this._replica.batchTaskRun.findFirst({ - where: { - friendlyId, - runtimeEnvironmentId: env.id, - }, - }); - - if (!batch) { - return; - } - - return { - id: batch.friendlyId, - status: batch.status, - idempotencyKey: batch.idempotencyKey ?? undefined, - createdAt: batch.createdAt, - updatedAt: batch.updatedAt, - runCount: batch.runCount, - }; - }); - } -} diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index daea48cf0c..b16d400c24 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -15,7 +15,7 @@ import assertNever from "assert-never"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { generatePresignedUrl } from "~/v3/r2.server"; import { BasePresenter } from "./basePresenter.server"; -import { prisma } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; // Build 'select' object const commonRunSelect = { @@ -59,48 +59,46 @@ type CommonRelatedRun = Prisma.Result< "findFirstOrThrow" >; +type FoundRun = NonNullable>>; + export class ApiRetrieveRunPresenter extends BasePresenter { - public async call( - friendlyId: string, - env: AuthenticatedEnvironment - ): Promise { - return this.traceWithEnv("call", env, async (span) => { - const taskRun = await this._replica.taskRun.findFirst({ - where: { - friendlyId, - runtimeEnvironmentId: env.id, - }, - include: { - attempts: true, - lockedToVersion: true, - schedule: true, - tags: true, - batch: { - select: { - id: true, - friendlyId: true, - }, + public static async findRun(friendlyId: string, env: AuthenticatedEnvironment) { + return $replica.taskRun.findFirst({ + where: { + friendlyId, + runtimeEnvironmentId: env.id, + }, + include: { + attempts: true, + lockedToVersion: true, + schedule: true, + tags: true, + batch: { + select: { + id: true, + friendlyId: true, }, - parentTaskRun: { - select: commonRunSelect, - }, - rootTaskRun: { - select: commonRunSelect, - }, - childRuns: { - select: { - ...commonRunSelect, - }, + }, + parentTaskRun: { + select: commonRunSelect, + }, + rootTaskRun: { + select: commonRunSelect, + }, + childRuns: { + select: { + ...commonRunSelect, }, }, - }); - - if (!taskRun) { - logger.debug("Task run not found", { friendlyId, envId: env.id }); - - return undefined; - } + }, + }); + } + public async call( + taskRun: FoundRun, + env: AuthenticatedEnvironment + ): Promise { + return this.traceWithEnv("call", env, async (span) => { let $payload: any; let $payloadPresignedUrl: string | undefined; let $output: any; diff --git a/apps/webapp/app/routes/api.v1.batches.$batchId.ts b/apps/webapp/app/routes/api.v1.batches.$batchId.ts index 02931a1a43..365f9caa22 100644 --- a/apps/webapp/app/routes/api.v1.batches.$batchId.ts +++ b/apps/webapp/app/routes/api.v1.batches.$batchId.ts @@ -1,6 +1,6 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { ApiRetrieveBatchPresenter } from "~/presenters/v3/ApiRetrieveBatchPresenter.server"; +import { $replica } from "~/db.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ @@ -12,20 +12,28 @@ export const loader = createLoaderApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: (params, auth) => { + return $replica.batchTaskRun.findFirst({ + where: { + friendlyId: params.batchId, + runtimeEnvironmentId: auth.environment.id, + }, + }); + }, authorization: { action: "read", - resource: (params) => ({ batch: params.batchId }), + resource: (batch) => ({ batch: batch.friendlyId }), superScopes: ["read:runs", "read:all", "admin"], }, }, - async ({ params, authentication }) => { - const presenter = new ApiRetrieveBatchPresenter(); - const result = await presenter.call(params.batchId, authentication.environment); - - if (!result) { - return json({ error: "Batch not found" }, { status: 404 }); - } - - return json(result); + async ({ resource: batch }) => { + return json({ + id: batch.friendlyId, + status: batch.status, + idempotencyKey: batch.idempotencyKey ?? undefined, + createdAt: batch.createdAt, + updatedAt: batch.updatedAt, + runCount: batch.runCount, + }); } ); diff --git a/apps/webapp/app/routes/api.v1.packets.$.ts b/apps/webapp/app/routes/api.v1.packets.$.ts index 4cbf76c6d3..d6fd54a011 100644 --- a/apps/webapp/app/routes/api.v1.packets.$.ts +++ b/apps/webapp/app/routes/api.v1.packets.$.ts @@ -45,6 +45,7 @@ export const loader = createLoaderApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, async ({ params, authentication }) => { const filename = params["*"]; diff --git a/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts b/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts index 547d92fdff..8a96f731ba 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runParam.reschedule.ts @@ -61,8 +61,17 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "An unknown error occurred" }, { status: 500 }); } + const run = await ApiRetrieveRunPresenter.findRun( + updatedRun.friendlyId, + authenticationResult.environment + ); + + if (!run) { + return json({ error: "Run not found" }, { status: 404 }); + } + const presenter = new ApiRetrieveRunPresenter(); - const result = await presenter.call(updatedRun.friendlyId, authenticationResult.environment); + const result = await presenter.call(run, authenticationResult.environment); if (!result) { return json({ error: "Run not found" }, { status: 404 }); diff --git a/apps/webapp/app/routes/api.v1.runs.ts b/apps/webapp/app/routes/api.v1.runs.ts index 8faed5bed2..2fd5348f78 100644 --- a/apps/webapp/app/routes/api.v1.runs.ts +++ b/apps/webapp/app/routes/api.v1.runs.ts @@ -12,9 +12,10 @@ export const loader = createLoaderApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (_, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }), + resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }), superScopes: ["read:runs", "read:all", "admin"], }, + findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, async ({ searchParams, authentication }) => { const presenter = new ApiRunListPresenter(); diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index a7f1c4ec95..fcd5edc39f 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -133,7 +133,7 @@ async function responseHeaders( const claims = { sub: environment.id, pub: true, - scopes: [`read:batch:${batch.id}`].concat(batch.runs.map((r) => `read:runs:${r.id}`)), + scopes: [`read:batch:${batch.id}`], }; const jwt = await generateJWT({ diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts index 66692827af..5d0a0e5d16 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -12,15 +12,23 @@ export const loader = createLoaderApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: (params, auth) => { + return ApiRetrieveRunPresenter.findRun(params.runId, auth.environment); + }, authorization: { action: "read", - resource: (params) => ({ runs: params.runId }), + resource: (run) => ({ + runs: run.friendlyId, + tags: run.runTags, + batch: run.batch?.friendlyId, + tasks: run.taskIdentifier, + }), superScopes: ["read:runs", "read:all", "admin"], }, }, - async ({ params, authentication }) => { + async ({ authentication, resource }) => { const presenter = new ApiRetrieveRunPresenter(); - const result = await presenter.call(params.runId, authentication.environment); + const result = await presenter.call(resource, authentication.environment); if (!result) { return json( diff --git a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts index ac105fddd2..cb7a891721 100644 --- a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts +++ b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts @@ -1,4 +1,3 @@ -import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; import { realtimeClient } from "~/services/realtimeClientGlobal.server"; @@ -13,24 +12,21 @@ export const loader = createLoaderApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: (params, auth) => { + return $replica.batchTaskRun.findFirst({ + where: { + friendlyId: params.batchId, + runtimeEnvironmentId: auth.environment.id, + }, + }); + }, authorization: { action: "read", - resource: (params) => ({ batch: params.batchId }), + resource: (batch) => ({ batch: batch.friendlyId }), superScopes: ["read:runs", "read:all", "admin"], }, }, - async ({ params, authentication, request }) => { - const batchRun = await $replica.batchTaskRun.findFirst({ - where: { - friendlyId: params.batchId, - runtimeEnvironmentId: authentication.environment.id, - }, - }); - - if (!batchRun) { - return json({ error: "Batch not found" }, { status: 404 }); - } - + async ({ authentication, request, resource: batchRun }) => { return realtimeClient.streamBatch( request.url, authentication.environment, diff --git a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts index 4836063d00..412a1ac85b 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts @@ -13,24 +13,33 @@ export const loader = createLoaderApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: async (params, authentication) => { + return $replica.taskRun.findFirst({ + where: { + friendlyId: params.runId, + runtimeEnvironmentId: authentication.environment.id, + }, + include: { + batch: { + select: { + friendlyId: true, + }, + }, + }, + }); + }, authorization: { action: "read", - resource: (params) => ({ runs: params.runId }), + resource: (run) => ({ + runs: run.friendlyId, + tags: run.runTags, + batch: run.batch?.friendlyId, + tasks: run.taskIdentifier, + }), superScopes: ["read:runs", "read:all", "admin"], }, }, - async ({ params, authentication, request }) => { - const run = await $replica.taskRun.findFirst({ - where: { - friendlyId: params.runId, - runtimeEnvironmentId: authentication.environment.id, - }, - }); - - if (!run) { - return json({ error: "Run not found" }, { status: 404 }); - } - + async ({ authentication, request, resource: run }) => { return realtimeClient.streamRun( request.url, authentication.environment, diff --git a/apps/webapp/app/routes/realtime.v1.runs.ts b/apps/webapp/app/routes/realtime.v1.runs.ts index ccb42c0054..2f4d36697f 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.ts @@ -16,9 +16,10 @@ export const loader = createLoaderApiRoute( searchParams: SearchParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: async () => 1, // This is a dummy value, it's not used authorization: { action: "read", - resource: (_, searchParams) => searchParams, + resource: (_, __, searchParams) => searchParams, superScopes: ["read:runs", "read:all", "admin"], }, }, diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts index 85ac49f61c..3bce319958 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -24,24 +24,33 @@ export const loader = createLoaderApiRoute( params: ParamsSchema, allowJWT: true, corsStrategy: "all", + findResource: async (params, auth) => { + return $replica.taskRun.findFirst({ + where: { + friendlyId: params.runId, + runtimeEnvironmentId: auth.environment.id, + }, + include: { + batch: { + select: { + friendlyId: true, + }, + }, + }, + }); + }, authorization: { action: "read", - resource: (params) => ({ runs: params.runId }), + resource: (run) => ({ + runs: run.friendlyId, + tags: run.runTags, + batch: run.batch?.friendlyId, + tasks: run.taskIdentifier, + }), superScopes: ["read:runs", "read:all", "admin"], }, }, - async ({ params, authentication, request }) => { - const run = await $replica.taskRun.findFirst({ - where: { - friendlyId: params.runId, - runtimeEnvironmentId: authentication.environment.id, - }, - }); - - if (!run) { - return new Response("Run not found", { status: 404 }); - } - + async ({ params, request, resource: run }) => { return realtimeStreams.streamResponse(run.friendlyId, params.streamId, request.signal); } ); diff --git a/apps/webapp/app/services/authorization.server.ts b/apps/webapp/app/services/authorization.server.ts index 66f89e9fcc..fbb949db38 100644 --- a/apps/webapp/app/services/authorization.server.ts +++ b/apps/webapp/app/services/authorization.server.ts @@ -88,34 +88,26 @@ export function checkAuthorization( for (const [resourceType, resourceValue] of Object.entries(filteredResource)) { const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue]; - let resourceAuthorized = false; for (const value of resourceValues) { // Check for specific resource permission const specificPermission = `${action}:${resourceType}:${value}`; // Check for general resource type permission const generalPermission = `${action}:${resourceType}`; + // If any permission matches, return authorized if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) { - resourceAuthorized = true; - break; + return { authorized: true }; } } - - // If any resource is not authorized, return false - if (!resourceAuthorized) { - return { - authorized: false, - reason: `Public Access Token is missing required permissions. Permissions required for ${resourceValues - .map((v) => `'${action}:${resourceType}:${v}'`) - .join(", ")} but token has the following permissions: ${entity.scopes - .map((s) => `'${s}'`) - .join( - ", " - )}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`, - }; - } } - // All resources are authorized - return { authorized: true }; + // No matching permissions found + return { + authorized: false, + reason: `Public Access Token is missing required permissions. Token has the following permissions: ${entity.scopes + .map((s) => `'${s}'`) + .join( + ", " + )}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`, + }; } diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index d15526d45c..a1314145b5 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -21,16 +21,22 @@ import { safeJsonParse } from "~/utils/json"; type ApiKeyRouteBuilderOptions< TParamsSchema extends z.AnyZodObject | undefined = undefined, TSearchParamsSchema extends z.AnyZodObject | undefined = undefined, - THeadersSchema extends z.AnyZodObject | undefined = undefined + THeadersSchema extends z.AnyZodObject | undefined = undefined, + TResource = never > = { params?: TParamsSchema; searchParams?: TSearchParamsSchema; headers?: THeadersSchema; allowJWT?: boolean; corsStrategy?: "all" | "none"; + findResource: ( + params: TParamsSchema extends z.AnyZodObject ? z.infer : undefined, + authentication: ApiAuthenticationResultSuccess + ) => Promise; authorization?: { action: AuthorizationAction; resource: ( + resource: NonNullable, params: TParamsSchema extends z.AnyZodObject ? z.infer : undefined, searchParams: TSearchParamsSchema extends z.AnyZodObject ? z.infer @@ -44,7 +50,8 @@ type ApiKeyRouteBuilderOptions< type ApiKeyHandlerFunction< TParamsSchema extends z.AnyZodObject | undefined, TSearchParamsSchema extends z.AnyZodObject | undefined, - THeadersSchema extends z.AnyZodObject | undefined = undefined + THeadersSchema extends z.AnyZodObject | undefined = undefined, + TResource = never > = (args: { params: TParamsSchema extends z.AnyZodObject ? z.infer : undefined; searchParams: TSearchParamsSchema extends z.AnyZodObject @@ -53,15 +60,17 @@ type ApiKeyHandlerFunction< headers: THeadersSchema extends z.AnyZodObject ? z.infer : undefined; authentication: ApiAuthenticationResultSuccess; request: Request; + resource: NonNullable; }) => Promise; export function createLoaderApiRoute< TParamsSchema extends z.AnyZodObject | undefined = undefined, TSearchParamsSchema extends z.AnyZodObject | undefined = undefined, - THeadersSchema extends z.AnyZodObject | undefined = undefined + THeadersSchema extends z.AnyZodObject | undefined = undefined, + TResource = never >( - options: ApiKeyRouteBuilderOptions, - handler: ApiKeyHandlerFunction + options: ApiKeyRouteBuilderOptions, + handler: ApiKeyHandlerFunction ) { return async function loader({ request, params }: LoaderFunctionArgs) { const { @@ -71,6 +80,7 @@ export function createLoaderApiRoute< allowJWT = false, corsStrategy = "none", authorization, + findResource, } = options; if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { @@ -146,13 +156,29 @@ export function createLoaderApiRoute< parsedHeaders = headers.data; } + // Find the resource + const resource = await findResource(parsedParams, authenticationResult); + + if (!resource) { + return await wrapResponse( + request, + json({ error: "Not found" }, { status: 404 }), + corsStrategy !== "none" + ); + } + if (authorization) { - const { action, resource, superScopes } = authorization; - const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders); + const { action, resource: authResource, superScopes } = authorization; + const $authResource = authResource( + resource, + parsedParams, + parsedSearchParams, + parsedHeaders + ); logger.debug("Checking authorization", { action, - resource: $resource, + resource: $authResource, superScopes, scopes: authenticationResult.scopes, }); @@ -160,7 +186,7 @@ export function createLoaderApiRoute< const authorizationResult = checkAuthorization( authenticationResult, action, - $resource, + $authResource, superScopes ); @@ -187,6 +213,7 @@ export function createLoaderApiRoute< headers: parsedHeaders, authentication: authenticationResult, request, + resource, }); return await wrapResponse(request, result, corsStrategy !== "none"); } catch (error) { diff --git a/apps/webapp/test/authorization.test.ts b/apps/webapp/test/authorization.test.ts index 51375b5f03..ad49e6e60e 100644 --- a/apps/webapp/test/authorization.test.ts +++ b/apps/webapp/test/authorization.test.ts @@ -65,7 +65,7 @@ describe("checkAuthorization", () => { expect(result.authorized).toBe(false); if (!result.authorized) { expect(result.reason).toBe( - "Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information." + "Public Access Token is missing required permissions. Token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information." ); } }); @@ -97,8 +97,8 @@ describe("checkAuthorization", () => { // @ts-expect-error nonexistent: "resource", }); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); + expect(result.authorized).toBe(false); + expect(result).toHaveProperty("reason"); }); }); @@ -167,28 +167,25 @@ describe("checkAuthorization", () => { } }); - it("should return unauthorized if any resource is not authorized", () => { + it("should return authorized if any resource is authorized", () => { const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { runs: "run_1234", // This is authorized tasks: "task_5678", // This is authorized (general permission) tags: "tag_3456", // This is not authorized }); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token is missing required permissions. Permissions required for 'read:tags:tag_3456' but token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } + expect(result.authorized).toBe(true); + expect(result).not.toHaveProperty("reason"); }); - it("should return authorized only if all resources are authorized", () => { + it("should return unauthorized only if no resources are authorized", () => { const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - runs: "run_1234", // This is authorized - tasks: "task_5678", // This is authorized (general permission) - tags: "tag_5678", // This is authorized + runs: "run_5678", // Not authorized + tags: "tag_3456", // Not authorized }); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); + expect(result.authorized).toBe(false); + if (!result.authorized) { + expect(result.reason).toContain("Public Access Token is missing required permissions"); + } }); }); @@ -244,7 +241,7 @@ describe("checkAuthorization", () => { expect(result.authorized).toBe(false); if (!result.authorized) { expect(result.reason).toBe( - "Public Access Token is missing required permissions. Permissions required for 'read:tasks:task_1234' but token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information." + "Public Access Token is missing required permissions. Token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information." ); } }); @@ -281,7 +278,7 @@ describe("checkAuthorization", () => { expect(result2.authorized).toBe(false); if (!result2.authorized) { expect(result2.reason).toBe( - "Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information." + "Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information." ); } }); @@ -314,7 +311,7 @@ describe("checkAuthorization", () => { expect(result.authorized).toBe(false); if (!result.authorized) { expect(result.reason).toBe( - "Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information." + "Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information." ); } }); diff --git a/docs/frontend/overview.mdx b/docs/frontend/overview.mdx index 242974fc38..e972a34eca 100644 --- a/docs/frontend/overview.mdx +++ b/docs/frontend/overview.mdx @@ -11,18 +11,18 @@ You can use our [React hooks](/frontend/react-hooks) in your frontend applicatio To create a Public Access Token, you can use the `auth.createPublicToken` function in your **backend** code: ```tsx -const publicToken = await auth.createPublicToken(); +const publicToken = await auth.createPublicToken(); // 👈 this public access token has no permissions, so is pretty useless! ``` ### Scopes -By default a Public Access Token has limited permissions. You can specify the scopes you need when creating a Public Access Token: +By default a Public Access Token has no permissions. You must specify the scopes you need when creating a Public Access Token: ```ts const publicToken = await auth.createPublicToken({ scopes: { read: { - runs: true, + runs: true, // ❌ this token can read all runs, possibly useful for debugging/testing }, }, }); @@ -34,7 +34,7 @@ This will allow the token to read all runs, which is probably not what you want. const publicToken = await auth.createPublicToken({ scopes: { read: { - runs: ["run_1234", "run_5678"], + runs: ["run_1234", "run_5678"], // ✅ this token can read only these runs }, }, }); @@ -46,7 +46,7 @@ You can scope the token to only read certain tasks: const publicToken = await auth.createPublicToken({ scopes: { read: { - tasks: ["my-task-1", "my-task-2"], + tasks: ["my-task-1", "my-task-2"], // 👈 this token can read all runs of these tasks }, }, }); @@ -58,7 +58,7 @@ Or tags: const publicToken = await auth.createPublicToken({ scopes: { read: { - tags: ["my-tag-1", "my-tag-2"], + tags: ["my-tag-1", "my-tag-2"], // 👈 this token can read all runs with these tags }, }, }); @@ -70,13 +70,13 @@ Or a specific batch of runs: const publicToken = await auth.createPublicToken({ scopes: { read: { - batch: "batch_1234", + batch: "batch_1234", // 👈 this token can read all runs in this batch }, }, }); ``` -You can also combine scopes. For example, to read only certain tasks and tags: +You can also combine scopes. For example, to read runs with specific tags and for specific tasks: ```ts const publicToken = await auth.createPublicToken({ @@ -105,6 +105,19 @@ const publicToken = await auth.createPublicToken({ This will allow the token to trigger the specified tasks. `tasks` is the only write scope available at the moment. +We **strongly** recommend creating short-lived tokens for write scopes, as they can be used to trigger tasks from your frontend application: + +```ts +const publicToken = await auth.createPublicToken({ + scopes: { + write: { + tasks: ["my-task-1"], // ✅ this token can trigger this task + }, + }, + expirationTime: "1m", // ✅ this token will expire after 1 minute +}); +``` + ### Expiration By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token: @@ -133,7 +146,7 @@ const handle = await tasks.trigger("my-task", { some: "data" }); console.log(handle.publicAccessToken); ``` -By default, tokens returned from the `trigger` function expire after 15 minutes and have a read scope for that specific run, and any tags associated with it. You can customize the expiration of the auto-generated tokens by passing a `publicTokenOptions` object to the `trigger` function: +By default, tokens returned from the `trigger` function expire after 15 minutes and have a read scope for that specific run. You can customize the expiration of the auto-generated tokens by passing a `publicTokenOptions` object to the `trigger` function: ```ts const handle = await tasks.trigger( diff --git a/docs/frontend/react-hooks.mdx b/docs/frontend/react-hooks.mdx index 5ad9677c72..3ce859db08 100644 --- a/docs/frontend/react-hooks.mdx +++ b/docs/frontend/react-hooks.mdx @@ -144,7 +144,7 @@ export async function startRun() { const handle = await tasks.trigger("example", { foo: "bar" }); // Set the auto-generated publicAccessToken in a cookie - cookies().set("publicAccessToken", handle.publicAccessToken); + cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run redirect(`/runs/${handle.id}`); } diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 23066f3639..a49db20c77 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -214,9 +214,7 @@ export class ApiClient { secretKey: this.accessToken, payload: { ...claims, - scopes: [`read:runs:${data.id}`].concat( - body.options?.tags ? Array.from(body.options?.tags).map((t) => `read:tags:${t}`) : [] - ), + scopes: [`read:runs:${data.id}`], }, expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h", }); @@ -255,7 +253,7 @@ export class ApiClient { secretKey: this.accessToken, payload: { ...claims, - scopes: [`read:batch:${data.id}`].concat(data.runs.map((r) => `read:runs:${r.id}`)), + scopes: [`read:batch:${data.id}`], }, expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h", }); diff --git a/packages/react-hooks/src/hooks/useTaskTrigger.ts b/packages/react-hooks/src/hooks/useTaskTrigger.ts index ba305f2d48..2878d06a88 100644 --- a/packages/react-hooks/src/hooks/useTaskTrigger.ts +++ b/packages/react-hooks/src/hooks/useTaskTrigger.ts @@ -26,7 +26,7 @@ import { */ export interface TriggerInstance { /** Function to submit the task with a payload */ - submit: (payload: TaskPayload) => void; + submit: (payload: TaskPayload, options?: TriggerOptions) => void; /** Whether the task is currently being submitted */ isLoading: boolean; /** The handle returned after successful task submission */ @@ -94,9 +94,9 @@ export function useTaskTrigger( const mutation = useSWRMutation(id as string, triggerTask); return { - submit: (payload) => { + submit: (payload, options) => { // trigger the task with the given payload - mutation.trigger({ payload }); + mutation.trigger({ payload, options }); }, isLoading: mutation.isMutating, handle: mutation.data as RunHandleFromTypes>, diff --git a/references/nextjs-realtime/src/components/TriggerButton.tsx b/references/nextjs-realtime/src/components/TriggerButton.tsx index c5d91b68f4..dfa844902c 100644 --- a/references/nextjs-realtime/src/components/TriggerButton.tsx +++ b/references/nextjs-realtime/src/components/TriggerButton.tsx @@ -25,11 +25,16 @@ export default function TriggerButton({ accessToken }: { accessToken: string }) disabled={isLoading} className="p-0 bg-transparent hover:bg-transparent hover:text-gray-200 text-gray-400" onClick={() => { - submit({ - model: "gpt-4o-mini", - prompt: - "Based on the temperature, will I need to wear extra clothes today in San Fransico? Please be detailed.", - }); + submit( + { + model: "gpt-4o-mini", + prompt: + "Based on the temperature, will I need to wear extra clothes today in San Fransico? Please be detailed.", + }, + { + tags: ["user:1234"], + } + ); }} > {isLoading ? "Triggering..." : "Trigger Task"} diff --git a/references/v3-catalog/package.json b/references/v3-catalog/package.json index a4fe7d8586..c9de9cf913 100644 --- a/references/v3-catalog/package.json +++ b/references/v3-catalog/package.json @@ -12,7 +12,7 @@ "queues": "ts-node -r dotenv/config -r tsconfig-paths/register ./src/queues.ts", "build:client": "tsup-node ./src/clientUsage.ts --format esm,cjs", "client": "tsx -r dotenv/config ./src/clientUsage.ts", - "triggerWithLargePayload": "ts-node -r dotenv/config -r tsconfig-paths/register ./src/triggerWithLargePayload.ts", + "triggerWithLargePayload": "tsx -r dotenv/config ./src/triggerWithLargePayload.ts", "generate:prisma": "prisma generate --sql" }, "dependencies": { diff --git a/references/v3-catalog/src/management.ts b/references/v3-catalog/src/management.ts index 4fc6c5cc76..fc0694b382 100644 --- a/references/v3-catalog/src/management.ts +++ b/references/v3-catalog/src/management.ts @@ -271,9 +271,26 @@ async function doBatchTrigger() { console.log("batch runs", $runs.data); } +async function doRescheduleRun() { + const run = await simpleChildTask.trigger({ message: "Hello, World!" }, { delay: "1h" }); + + console.log("run", run); + + const reschedule = await runs.reschedule(run.id, { + delay: "1s", + }); + + console.log("reschedule", reschedule); + + const rescheduledRun = await waitForRunToComplete(reschedule.id); + + console.log("rescheduled run", rescheduledRun); +} + // doRuns().catch(console.error); // doListRuns().catch(console.error); // doScheduleLists().catch(console.error); -doBatchTrigger().catch(console.error); +// doBatchTrigger().catch(console.error); // doEnvVars().catch(console.error); // doTriggerUnfriendlyTaskId().catch(console.error); +doRescheduleRun().catch(console.error);