-
+
Failed to load Vercel settings
-
- There was an error loading the Vercel integration settings. Please refresh the page to try again.
+
+ There was an error loading the Vercel integration settings. Please refresh the page to
+ try again.
@@ -1066,27 +1160,38 @@ function VercelSettingsPanel({
if (data.connectedProject) {
return (
<>
- {showAuthInvalid &&
}
+ {showAuthInvalid && (
+
+ )}
{showGitHubWarning &&
}
- {!showAuthInvalid && (
)}
+ {!showAuthInvalid && (
+
+ )}
>
);
}
return (
- {showAuthInvalid && }
+ {showAuthInvalid && (
+
+ )}
{!showAuthInvalid && (
<>
{data.hasOrgIntegration
@@ -1105,8 +1211,8 @@ function VercelSettingsPanel({
{!data.isGitHubConnected && (
- GitHub integration is not connected. Vercel integration cannot sync environment variables and
- link deployments without a properly installed GitHub integration.
+ GitHub integration is not connected. Vercel integration cannot sync environment
+ variables and link deployments without a properly installed GitHub integration.
)}
>
@@ -1115,7 +1221,6 @@ function VercelSettingsPanel({
);
}
-
import { VercelOnboardingModal } from "~/components/integrations/VercelOnboardingModal";
export { VercelSettingsPanel, VercelOnboardingModal };
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
index 2ba2c761d70..39e514bbe4f 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx
@@ -6,7 +6,6 @@ import {
} from "@heroicons/react/20/solid";
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { Form, useLocation, useNavigation } from "@remix-run/react";
-import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { uiComponent } from "@team-plain/typescript-sdk";
import { GitHubLightIcon } from "@trigger.dev/companyicons";
import {
@@ -38,11 +37,11 @@ import { Spinner } from "~/components/primitives/Spinner";
import { TextArea } from "~/components/primitives/TextArea";
import { TextLink } from "~/components/primitives/TextLink";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
-import { prisma } from "~/db.server";
+import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { setPlan } from "~/services/platform.v3.server";
-import { requireUser } from "~/services/session.server";
+import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { engine } from "~/v3/runEngine.server";
import { cn } from "~/utils/cn";
import { sendToPlain } from "~/utils/plain.server";
@@ -61,105 +60,115 @@ const schema = z.object({
message: z.string().optional(),
});
-export async function action({ request, params }: ActionFunctionArgs) {
- if (request.method.toLowerCase() !== "post") {
- return new Response("Method not allowed", { status: 405 });
- }
+async function resolveOrgIdFromSlug(slug: string): Promise {
+ const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
+ return org?.id ?? null;
+}
- const { organizationSlug } = Params.parse(params);
- const user = await requireUser(request);
- const formData = await request.formData();
- const reasons = formData.getAll("reasons");
- const message = formData.get("message");
+export const action = dashboardAction(
+ {
+ params: Params,
+ context: async (params) => {
+ const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
+ return organizationId ? { organizationId } : {};
+ },
+ authorization: { action: "manage", resource: { type: "billing" } },
+ },
+ async ({ request, params, user }) => {
+ const { organizationSlug } = params;
+ const formData = await request.formData();
+ const reasons = formData.getAll("reasons");
+ const message = formData.get("message");
- const form = schema.parse({
- ...Object.fromEntries(formData),
- reasons,
- message: message || undefined,
- });
+ const form = schema.parse({
+ ...Object.fromEntries(formData),
+ reasons,
+ message: message || undefined,
+ });
- const organization = await prisma.organization.findFirst({
- where: { slug: organizationSlug, members: { some: { userId: user.id } } },
- });
+ const organization = await prisma.organization.findFirst({
+ where: { slug: organizationSlug, members: { some: { userId: user.id } } },
+ });
- if (!organization) {
- throw redirectWithErrorMessage(form.callerPath, request, "Organization not found");
- }
+ if (!organization) {
+ throw redirectWithErrorMessage(form.callerPath, request, "Organization not found");
+ }
- let payload: SetPlanBody;
+ let payload: SetPlanBody;
- switch (form.type) {
- case "free": {
- try {
- if (reasons.length > 0 || (message && message.toString().trim() !== "")) {
- await sendToPlain({
- userId: user.id,
- email: user.email,
- name: user.name ?? "",
- title: "Plan cancelation feedback",
- components: [
- uiComponent.text({
- text: `${user.name} (${user.email}) just canceled their plan.`,
- }),
- uiComponent.divider({ spacingSize: "M" }),
- ...(reasons.length > 0
- ? [
- uiComponent.spacer({ size: "L" }),
- uiComponent.text({
- size: "L",
- color: "NORMAL",
- text: "Reasons:",
- }),
- uiComponent.text({
- text: reasons.join(", "),
- }),
- ]
- : []),
- ...(message
- ? [
- uiComponent.spacer({ size: "L" }),
- uiComponent.text({
- size: "L",
- color: "NORMAL",
- text: "Comment:",
- }),
- uiComponent.text({
- text: message.toString(),
- }),
- ]
- : []),
- ],
- });
+ switch (form.type) {
+ case "free": {
+ try {
+ if (reasons.length > 0 || (message && message.toString().trim() !== "")) {
+ await sendToPlain({
+ userId: user.id,
+ email: user.email,
+ name: user.name ?? "",
+ title: "Plan cancelation feedback",
+ components: [
+ uiComponent.text({
+ text: `${user.name} (${user.email}) just canceled their plan.`,
+ }),
+ uiComponent.divider({ spacingSize: "M" }),
+ ...(reasons.length > 0
+ ? [
+ uiComponent.spacer({ size: "L" }),
+ uiComponent.text({
+ size: "L",
+ color: "NORMAL",
+ text: "Reasons:",
+ }),
+ uiComponent.text({
+ text: reasons.join(", "),
+ }),
+ ]
+ : []),
+ ...(message
+ ? [
+ uiComponent.spacer({ size: "L" }),
+ uiComponent.text({
+ size: "L",
+ color: "NORMAL",
+ text: "Comment:",
+ }),
+ uiComponent.text({
+ text: message.toString(),
+ }),
+ ]
+ : []),
+ ],
+ });
+ }
+ } catch (e) {
+ logger.error("Failed to submit to Plain the unsubscribe reason", { error: e });
}
- } catch (e) {
- logger.error("Failed to submit to Plain the unsubscribe reason", { error: e });
+ payload = {
+ type: "free" as const,
+ userId: user.id,
+ };
+ break;
}
- payload = {
- type: "free" as const,
- userId: user.id,
- };
- break;
- }
- case "paid": {
- if (form.planCode === undefined) {
- throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
+ case "paid": {
+ if (form.planCode === undefined) {
+ throw redirectWithErrorMessage(form.callerPath, request, "Not a valid plan");
+ }
+ payload = {
+ type: "paid" as const,
+ planCode: form.planCode,
+ userId: user.id,
+ };
+ break;
+ }
+ default: {
+ throw new Error("Invalid form type");
}
- payload = {
- type: "paid" as const,
- planCode: form.planCode,
- userId: user.id,
- };
- break;
- }
- default: {
- throw new Error("Invalid form type");
}
- }
- return await setPlan(organization, request, form.callerPath, payload, {
- invalidateBillingCache: engine.invalidateBillingCache.bind(engine),
- });
-}
+ return await setPlan(organization, request, form.callerPath, payload, {
+ invalidateBillingCache: engine.invalidateBillingCache.bind(engine),
+ });
+ }
+);
const pricingDefinitions = {
usage: {
diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts
index fa6ee29f3db..3179ac022e7 100644
--- a/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts
+++ b/apps/webapp/app/routes/resources.taskruns.$runParam.cancel.ts
@@ -1,10 +1,10 @@
import { parse } from "@conform-to/zod";
-import { type ActionFunction, json } from "@remix-run/node";
+import { json } from "@remix-run/node";
import { z } from "zod";
-import { prisma } from "~/db.server";
+import { $replica, prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
@@ -16,104 +16,130 @@ const ParamSchema = z.object({
runParam: z.string(),
});
-export const action: ActionFunction = async ({ request, params }) => {
- const userId = await requireUserId(request);
- const { runParam } = ParamSchema.parse(params);
+// Resolve the run's organization so the RBAC auth scope can resolve the
+// user's role in it. The run may not be in Postgres yet (buffered during a
+// burst), so fall back to the buffer entry's org.
+async function resolveRunOrganizationId(runParam: string): Promise {
+ const run = await $replica.taskRun.findFirst({
+ where: { friendlyId: runParam },
+ select: { project: { select: { organizationId: true } } },
+ });
+ if (run) {
+ return run.project.organizationId;
+ }
- const formData = await request.formData();
- const submission = parse(formData, { schema: cancelSchema });
+ const buffer = getMollifierBuffer();
+ const entry = buffer ? await buffer.getEntry(runParam) : null;
+ return entry?.orgId ?? null;
+}
- if (!submission.value) {
- return json(submission);
- }
+export const action = dashboardAction(
+ {
+ params: ParamSchema,
+ context: async (params) => {
+ const organizationId = await resolveRunOrganizationId(params.runParam);
+ return organizationId ? { organizationId } : {};
+ },
+ authorization: { action: "write", resource: { type: "runs" } },
+ },
+ async ({ request, params, user }) => {
+ const { runParam } = params;
+
+ const formData = await request.formData();
+ const submission = parse(formData, { schema: cancelSchema });
+
+ if (!submission.value) {
+ return json(submission);
+ }
- try {
- const taskRun = await prisma.taskRun.findFirst({
- where: {
- friendlyId: runParam,
- project: {
- organization: {
- members: {
- some: {
- userId,
+ try {
+ const taskRun = await prisma.taskRun.findFirst({
+ where: {
+ friendlyId: runParam,
+ project: {
+ organization: {
+ members: {
+ some: {
+ userId: user.id,
+ },
},
},
},
},
- },
- });
+ });
- if (taskRun) {
- const cancelRunService = new CancelTaskRunService();
- await cancelRunService.call(taskRun);
- return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
- }
+ if (taskRun) {
+ const cancelRunService = new CancelTaskRunService();
+ await cancelRunService.call(taskRun);
+ return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
+ }
- // PG miss — try the mollifier buffer. The customer can hit cancel
- // on a buffered run from the dashboard during the burst window.
- // Snapshot a `mark_cancelled` patch; the drainer's
- // bifurcation routes the run to `engine.createCancelledRun` on
- // next pop.
- const buffer = getMollifierBuffer();
- const entry = buffer ? await buffer.getEntry(runParam) : null;
- if (!entry) {
- submission.error = { runParam: ["Run not found"] };
- return json(submission);
- }
+ // PG miss — try the mollifier buffer. The customer can hit cancel
+ // on a buffered run from the dashboard during the burst window.
+ // Snapshot a `mark_cancelled` patch; the drainer's
+ // bifurcation routes the run to `engine.createCancelledRun` on
+ // next pop.
+ const buffer = getMollifierBuffer();
+ const entry = buffer ? await buffer.getEntry(runParam) : null;
+ if (!entry) {
+ submission.error = { runParam: ["Run not found"] };
+ return json(submission);
+ }
- // Dashboard auth: verify the requesting user is a member of the
- // buffered run's org. The API path scopes by env id from the
- // authenticated request; the dashboard route uses org-membership
- // because the URL doesn't carry an envId.
- const member = await prisma.orgMember.findFirst({
- where: { userId, organizationId: entry.orgId },
- select: { id: true },
- });
- if (!member) {
- submission.error = { runParam: ["Run not found"] };
- return json(submission);
- }
+ // Tenancy: verify the requesting user is a member of the buffered
+ // run's org. The API path scopes by env id from the authenticated
+ // request; the dashboard route uses org-membership because the URL
+ // doesn't carry an envId.
+ const member = await prisma.orgMember.findFirst({
+ where: { userId: user.id, organizationId: entry.orgId },
+ select: { id: true },
+ });
+ if (!member) {
+ submission.error = { runParam: ["Run not found"] };
+ return json(submission);
+ }
- const result = await buffer!.mutateSnapshot(runParam, {
- type: "mark_cancelled",
- cancelledAt: new Date().toISOString(),
- cancelReason: "Canceled by user",
- });
- if (result === "applied_to_snapshot") {
- return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
- }
- // "not_found" or "busy" — both indicate the drainer raced us between
- // the getEntry check above and mutateSnapshot. On "not_found" the
- // entry was just popped and the PG row is in flight; on "busy" the
- // drainer is mid-materialisation. Either way the customer should
- // retry — by then the PG row exists and the regular cancel path at
- // the top of this action takes over.
- return redirectWithErrorMessage(
- submission.value.redirectUrl,
- request,
- "Run is materialising — retry in a moment"
- );
- } catch (error) {
- if (error instanceof Error) {
- logger.error("Failed to cancel run", {
- error: {
- name: error.name,
- message: error.message,
- stack: error.stack,
- },
+ const result = await buffer!.mutateSnapshot(runParam, {
+ type: "mark_cancelled",
+ cancelledAt: new Date().toISOString(),
+ cancelReason: "Canceled by user",
});
+ if (result === "applied_to_snapshot") {
+ return redirectWithSuccessMessage(submission.value.redirectUrl, request, `Canceled run`);
+ }
+ // "not_found" or "busy" — both indicate the drainer raced us between
+ // the getEntry check above and mutateSnapshot. On "not_found" the
+ // entry was just popped and the PG row is in flight; on "busy" the
+ // drainer is mid-materialisation. Either way the customer should
+ // retry — by then the PG row exists and the regular cancel path at
+ // the top of this action takes over.
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
- `Failed to cancel run, ${error.message}`
- );
- } else {
- logger.error("Failed to cancel run", { error });
- return redirectWithErrorMessage(
- submission.value.redirectUrl,
- request,
- `Failed to cancel run, ${JSON.stringify(error)}`
+ "Run is materialising — retry in a moment"
);
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error("Failed to cancel run", {
+ error: {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ },
+ });
+ return redirectWithErrorMessage(
+ submission.value.redirectUrl,
+ request,
+ `Failed to cancel run, ${error.message}`
+ );
+ } else {
+ logger.error("Failed to cancel run", { error });
+ return redirectWithErrorMessage(
+ submission.value.redirectUrl,
+ request,
+ `Failed to cancel run, ${JSON.stringify(error)}`
+ );
+ }
}
}
-};
+);
diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
index 631bd5ece52..30158e61609 100644
--- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
+++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts
@@ -1,5 +1,5 @@
import { parse } from "@conform-to/zod";
-import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/node";
+import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { type EnvironmentType, prettyPrintPacket } from "@trigger.dev/core/v3";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
@@ -8,6 +8,7 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
+import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { sortEnvironments } from "~/utils/environmentSort";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
@@ -247,169 +248,190 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
});
}
-export const action: ActionFunction = async ({ request, params }) => {
- // Dashboard auth: identical pattern to resources.taskruns.$runParam.cancel.ts.
- // The loader above this action already gates with `requireUser`, but
- // Remix's action runs independently — without this call any request
- // with a valid runParam could submit a replay. The PG findFirst below
- // also adds the org-membership filter so a PAT can't replay another
- // org's run, and the buffered fallback verifies org membership via
- // orgMember.findFirst against the snapshot's orgId.
- const user = await requireUser(request);
- const userId = user.id;
- const { runParam } = ParamSchema.parse(params);
+// Resolve the run's organization so the RBAC auth scope can resolve the
+// user's role in it. The run may not be in Postgres yet (buffered during a
+// burst), so fall back to the buffer entry's org.
+async function resolveRunOrganizationId(runParam: string): Promise {
+ const run = await $replica.taskRun.findFirst({
+ where: { friendlyId: runParam },
+ select: { project: { select: { organizationId: true } } },
+ });
+ if (run) {
+ return run.project.organizationId;
+ }
- const formData = await request.formData();
- const submission = parse(formData, { schema: ReplayRunData });
+ const buffer = getMollifierBuffer();
+ const entry = buffer ? await buffer.getEntry(runParam) : null;
+ return entry?.orgId ?? null;
+}
- if (!submission.value) {
- return json(submission);
- }
+export const action = dashboardAction(
+ {
+ params: ParamSchema,
+ context: async (params) => {
+ const organizationId = await resolveRunOrganizationId(params.runParam);
+ return organizationId ? { organizationId } : {};
+ },
+ authorization: { action: "write", resource: { type: "runs" } },
+ },
+ // The PG findFirst below keeps the org-membership filter so a user can't
+ // replay another org's run, and the buffered fallback verifies membership
+ // via orgMember.findFirst against the snapshot's orgId.
+ async ({ request, params, user }) => {
+ const { runParam } = params;
- try {
- const pgRun = await prisma.taskRun.findFirst({
- where: {
- friendlyId: runParam,
- project: {
- organization: {
- members: {
- some: {
- userId,
+ const formData = await request.formData();
+ const submission = parse(formData, { schema: ReplayRunData });
+
+ if (!submission.value) {
+ return json(submission);
+ }
+
+ try {
+ const pgRun = await prisma.taskRun.findFirst({
+ where: {
+ friendlyId: runParam,
+ project: {
+ organization: {
+ members: {
+ some: {
+ userId: user.id,
+ },
},
},
},
},
- },
- include: {
- runtimeEnvironment: {
- select: {
- slug: true,
+ include: {
+ runtimeEnvironment: {
+ select: {
+ slug: true,
+ },
},
- },
- project: {
- include: {
- organization: true,
+ project: {
+ include: {
+ organization: true,
+ },
},
},
- },
- });
+ });
- // Mollifier read-fallback: if the original isn't in PG yet,
- // synthesise a TaskRun from the buffered snapshot. The B4-extended
- // SyntheticRun carries every field ReplayTaskRunService reads. We
- // also need projectSlug + orgSlug + envSlug for the redirect path,
- // so look those up via the snapshot's runtimeEnvironmentId.
- let taskRun: SyntheticReplayTaskRun | null = pgRun ?? null;
- if (!taskRun) {
- const buffer = getMollifierBuffer();
- const entry = buffer ? await buffer.getEntry(runParam) : null;
- if (entry) {
- // Same org-membership gate as the PG path above. Without this
- // any authenticated user who knows a runId could replay the
- // buffered run across orgs.
- const member = await prisma.orgMember.findFirst({
- where: { userId, organizationId: entry.orgId },
- select: { id: true },
- });
- if (!member) {
- return redirectWithErrorMessage(
- submission.value.failedRedirect,
- request,
- "Run not found"
- );
- }
- const synthetic = await findRunByIdWithMollifierFallback({
- runId: runParam,
- environmentId: entry.envId,
- organizationId: entry.orgId,
- });
- if (synthetic) {
- const envRow = await prisma.runtimeEnvironment.findFirst({
- where: { id: entry.envId },
- select: {
- slug: true,
- project: { select: { slug: true, organization: { select: { slug: true } } } },
- },
+ // Mollifier read-fallback: if the original isn't in PG yet,
+ // synthesise a TaskRun from the buffered snapshot. The B4-extended
+ // SyntheticRun carries every field ReplayTaskRunService reads. We
+ // also need projectSlug + orgSlug + envSlug for the redirect path,
+ // so look those up via the snapshot's runtimeEnvironmentId.
+ let taskRun: SyntheticReplayTaskRun | null = pgRun ?? null;
+ if (!taskRun) {
+ const buffer = getMollifierBuffer();
+ const entry = buffer ? await buffer.getEntry(runParam) : null;
+ if (entry) {
+ // Same org-membership gate as the PG path above. Without this
+ // any authenticated user who knows a runId could replay the
+ // buffered run across orgs.
+ const member = await prisma.orgMember.findFirst({
+ where: { userId: user.id, organizationId: entry.orgId },
+ select: { id: true },
+ });
+ if (!member) {
+ return redirectWithErrorMessage(
+ submission.value.failedRedirect,
+ request,
+ "Run not found"
+ );
+ }
+ const synthetic = await findRunByIdWithMollifierFallback({
+ runId: runParam,
+ environmentId: entry.envId,
+ organizationId: entry.orgId,
});
- if (envRow) {
- taskRun = buildSyntheticReplayTaskRun({ synthetic, envRow });
+ if (synthetic) {
+ const envRow = await prisma.runtimeEnvironment.findFirst({
+ where: { id: entry.envId },
+ select: {
+ slug: true,
+ project: { select: { slug: true, organization: { select: { slug: true } } } },
+ },
+ });
+ if (envRow) {
+ taskRun = buildSyntheticReplayTaskRun({ synthetic, envRow });
+ }
}
}
}
- }
- if (!taskRun) {
- return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found");
- }
+ if (!taskRun) {
+ return redirectWithErrorMessage(submission.value.failedRedirect, request, "Run not found");
+ }
- const replayRunService = new ReplayTaskRunService();
- const newRun = await replayRunService.call(taskRun, {
- environmentId: submission.value.environment,
- payload: submission.value.payload,
- metadata: submission.value.metadata,
- tags: submission.value.tags,
- queue: submission.value.queue,
- concurrencyKey: submission.value.concurrencyKey,
- maxAttempts: submission.value.maxAttempts,
- maxDurationSeconds: submission.value.maxDurationSeconds,
- machine: submission.value.machine,
- region: submission.value.region,
- delaySeconds: submission.value.delaySeconds,
- idempotencyKey: submission.value.idempotencyKey,
- idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
- ttlSeconds: submission.value.ttlSeconds,
- version: submission.value.version,
- prioritySeconds: submission.value.prioritySeconds,
- triggerSource: "dashboard",
- });
+ const replayRunService = new ReplayTaskRunService();
+ const newRun = await replayRunService.call(taskRun, {
+ environmentId: submission.value.environment,
+ payload: submission.value.payload,
+ metadata: submission.value.metadata,
+ tags: submission.value.tags,
+ queue: submission.value.queue,
+ concurrencyKey: submission.value.concurrencyKey,
+ maxAttempts: submission.value.maxAttempts,
+ maxDurationSeconds: submission.value.maxDurationSeconds,
+ machine: submission.value.machine,
+ region: submission.value.region,
+ delaySeconds: submission.value.delaySeconds,
+ idempotencyKey: submission.value.idempotencyKey,
+ idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
+ ttlSeconds: submission.value.ttlSeconds,
+ version: submission.value.version,
+ prioritySeconds: submission.value.prioritySeconds,
+ triggerSource: "dashboard",
+ });
- if (!newRun) {
- return redirectWithErrorMessage(
- submission.value.failedRedirect,
- request,
- "Failed to replay run"
+ if (!newRun) {
+ return redirectWithErrorMessage(
+ submission.value.failedRedirect,
+ request,
+ "Failed to replay run"
+ );
+ }
+
+ const runPath = v3RunSpanPath(
+ {
+ slug: taskRun.project.organization.slug,
+ },
+ { slug: taskRun.project.slug },
+ { slug: taskRun.runtimeEnvironment.slug },
+ { friendlyId: newRun.friendlyId },
+ { spanId: newRun.spanId }
);
- }
- const runPath = v3RunSpanPath(
- {
- slug: taskRun.project.organization.slug,
- },
- { slug: taskRun.project.slug },
- { slug: taskRun.runtimeEnvironment.slug },
- { friendlyId: newRun.friendlyId },
- { spanId: newRun.spanId }
- );
+ logger.debug("Replayed run", {
+ taskRunId: taskRun.id,
+ taskRunFriendlyId: taskRun.friendlyId,
+ newRunId: newRun.id,
+ newRunFriendlyId: newRun.friendlyId,
+ runPath,
+ });
- logger.debug("Replayed run", {
- taskRunId: taskRun.id,
- taskRunFriendlyId: taskRun.friendlyId,
- newRunId: newRun.id,
- newRunFriendlyId: newRun.friendlyId,
- runPath,
- });
+ return redirectWithSuccessMessage(runPath, request, `Replaying run`);
+ } catch (error) {
+ if (error instanceof Error) {
+ logger.error("Failed to replay run", {
+ error: {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ },
+ });
+ return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
+ }
- return redirectWithSuccessMessage(runPath, request, `Replaying run`);
- } catch (error) {
- if (error instanceof Error) {
- logger.error("Failed to replay run", {
- error: {
- name: error.name,
- message: error.message,
- stack: error.stack,
- },
- });
- return redirectWithErrorMessage(submission.value.failedRedirect, request, error.message);
+ logger.error("Failed to replay run", { error });
+ return redirectWithErrorMessage(
+ submission.value.failedRedirect,
+ request,
+ JSON.stringify(error)
+ );
}
-
- logger.error("Failed to replay run", { error });
- return redirectWithErrorMessage(
- submission.value.failedRedirect,
- request,
- JSON.stringify(error)
- );
}
-};
+);
async function findTask(
environment: { type: EnvironmentType; id: string },
diff --git a/apps/webapp/app/routes/vercel.install.tsx b/apps/webapp/app/routes/vercel.install.tsx
index 6a1ca4d7a64..b3d66cdf5f2 100644
--- a/apps/webapp/app/routes/vercel.install.tsx
+++ b/apps/webapp/app/routes/vercel.install.tsx
@@ -1,8 +1,7 @@
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
-import { requireUser } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { logger } from "~/services/logger.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server";
@@ -13,61 +12,76 @@ const QuerySchema = z.object({
project_slug: z.string(),
});
-export const loader = async ({ request }: LoaderFunctionArgs) => {
- const searchParams = new URL(request.url).searchParams;
- const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
+async function resolveOrgIdFromSlug(slug: string): Promise {
+ const org = await $replica.organization.findFirst({ where: { slug }, select: { id: true } });
+ return org?.id ?? null;
+}
- if (!parsed.success) {
- logger.warn("Vercel App installation redirect with invalid params", {
- searchParams,
- error: parsed.error,
- });
- throw redirect("/");
- }
-
- const { org_slug, project_slug } = parsed.data;
- const user = await requireUser(request);
-
- // Find the organization
- const org = await $replica.organization.findFirst({
- where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
- orderBy: { createdAt: "desc" },
- select: {
- id: true,
+export const loader = dashboardLoader(
+ {
+ // The org for the auth scope comes from the `org_slug` query param.
+ context: async (_params, request) => {
+ const orgSlug = new URL(request.url).searchParams.get("org_slug");
+ if (!orgSlug) return {};
+ const organizationId = await resolveOrgIdFromSlug(orgSlug);
+ return organizationId ? { organizationId } : {};
},
- });
+ authorization: { action: "write", resource: { type: "vercel" } },
+ },
+ async ({ request, user }) => {
+ const searchParams = new URL(request.url).searchParams;
+ const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
- if (!org) {
- throw redirect("/");
- }
+ if (!parsed.success) {
+ logger.warn("Vercel App installation redirect with invalid params", {
+ searchParams,
+ error: parsed.error,
+ });
+ throw redirect("/");
+ }
- // Find the project
- const project = await findProjectBySlug(org_slug, project_slug, user.id);
- if (!project) {
- logger.warn("Vercel App installation attempt for non-existent project", {
- org_slug,
- project_slug,
- userId: user.id,
+ const { org_slug, project_slug } = parsed.data;
+
+ // Find the organization
+ const org = await $replica.organization.findFirst({
+ where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ },
});
- throw redirect("/");
- }
- // Use "prod" as the default environment slug for the redirect
- // The callback will redirect to the settings page for this environment
- const environmentSlug = "prod";
+ if (!org) {
+ throw redirect("/");
+ }
- // Generate JWT state token
- const stateToken = await generateVercelOAuthState({
- organizationId: org.id,
- projectId: project.id,
- environmentSlug,
- organizationSlug: org_slug,
- projectSlug: project_slug,
- });
+ // Find the project
+ const project = await findProjectBySlug(org_slug, project_slug, user.id);
+ if (!project) {
+ logger.warn("Vercel App installation attempt for non-existent project", {
+ org_slug,
+ project_slug,
+ userId: user.id,
+ });
+ throw redirect("/");
+ }
- // Generate Vercel install URL
- const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
+ // Use "prod" as the default environment slug for the redirect
+ // The callback will redirect to the settings page for this environment
+ const environmentSlug = "prod";
- return redirect(vercelInstallUrl);
-};
+ // Generate JWT state token
+ const stateToken = await generateVercelOAuthState({
+ organizationId: org.id,
+ projectId: project.id,
+ environmentSlug,
+ organizationSlug: org_slug,
+ projectSlug: project_slug,
+ });
+ // Generate Vercel install URL
+ const vercelInstallUrl = OrgIntegrationRepository.vercelInstallUrl(stateToken);
+
+ return redirect(vercelInstallUrl);
+ }
+);
diff --git a/apps/webapp/app/services/environmentVariableApiAccess.server.ts b/apps/webapp/app/services/environmentVariableApiAccess.server.ts
new file mode 100644
index 00000000000..2ba7b2b91f2
--- /dev/null
+++ b/apps/webapp/app/services/environmentVariableApiAccess.server.ts
@@ -0,0 +1,75 @@
+import { json } from "@remix-run/server-runtime";
+import type { RuntimeEnvironmentType } from "@trigger.dev/database";
+import { rbac } from "~/services/rbac.server";
+
+type EnvironmentScopedResource = "envvars" | "apiKeys";
+
+const RESOURCE_LABELS: Record = {
+ envvars: "environment variables",
+ apiKeys: "API keys",
+};
+
+/**
+ * Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints
+ * that hand out an environment's secret credentials).
+ *
+ * Machine credentials (an environment's secret/public API key) are already
+ * scoped to a single environment, so they pass through unchanged. A personal
+ * access token carries a user, so enforce that user's role for the targeted
+ * environment tier — e.g. a Developer can't read deployed env vars or API keys
+ * via the API, matching the dashboard restriction. Blocking the credential read
+ * for deployed tiers is also what stops a restricted role deploying via the CLI
+ * (deploy needs the environment's secret key).
+ *
+ * Returns a `Response` to short-circuit with when access is denied, or
+ * `undefined` when the request may proceed.
+ */
+export async function authorizePatEnvironmentAccess({
+ request,
+ authType,
+ organizationId,
+ projectId,
+ envType,
+ resource,
+ action,
+}: {
+ request: Request;
+ authType: "personalAccessToken" | "organizationAccessToken" | "apiKey";
+ organizationId: string;
+ projectId: string;
+ envType: RuntimeEnvironmentType;
+ resource: EnvironmentScopedResource;
+ action: "read" | "write";
+}): Promise {
+ if (authType !== "personalAccessToken") {
+ return undefined;
+ }
+
+ const patAuth = await rbac.authenticatePat(request, { organizationId, projectId });
+ if (!patAuth.ok) {
+ return json({ error: patAuth.error }, { status: patAuth.status });
+ }
+
+ if (!patAuth.ability.can(action, { type: resource, envType })) {
+ return json(
+ {
+ error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,
+ },
+ { status: 403 }
+ );
+ }
+
+ return undefined;
+}
+
+/** Env-tier env var access for the env var API routes. */
+export function authorizeEnvVarApiRequest(opts: {
+ request: Request;
+ authType: "personalAccessToken" | "organizationAccessToken" | "apiKey";
+ organizationId: string;
+ projectId: string;
+ envType: RuntimeEnvironmentType;
+ action: "read" | "write";
+}): Promise {
+ return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" });
+}
diff --git a/apps/webapp/app/services/routeBuilders/permissions.server.ts b/apps/webapp/app/services/routeBuilders/permissions.server.ts
new file mode 100644
index 00000000000..8d574abcea9
--- /dev/null
+++ b/apps/webapp/app/services/routeBuilders/permissions.server.ts
@@ -0,0 +1,33 @@
+import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
+
+/**
+ * A single permission check, mirroring the `authorization` option the
+ * dashboard/api route builders accept: either a super-user check or an
+ * action + resource(s) pair.
+ */
+export type PermissionCheck =
+ | { requireSuper: true }
+ | { action: string; resource: RbacResource | RbacResource[] };
+
+/**
+ * Evaluate a set of permission checks against an already-resolved `ability`
+ * and return a plain boolean map for the client to gate UI on.
+ *
+ * The matching lives entirely in the injected ability — permissive by
+ * default, and fully enforced when an RBAC plugin is installed — so this only
+ * calls `can`/`canSuper` and no permission-model logic lives here. The
+ * returned booleans are display-only: the route builder's `authorization`
+ * block is the real security boundary.
+ */
+export function checkPermissions(
+ ability: RbacAbility,
+ checks: Record
+): Record {
+ const result = {} as Record;
+ for (const key in checks) {
+ const check = checks[key];
+ result[key] =
+ "requireSuper" in check ? ability.canSuper() : ability.can(check.action, check.resource);
+ }
+ return result;
+}
diff --git a/apps/webapp/test/checkPermissions.test.ts b/apps/webapp/test/checkPermissions.test.ts
new file mode 100644
index 00000000000..84a18d63bef
--- /dev/null
+++ b/apps/webapp/test/checkPermissions.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect } from "vitest";
+import type { RbacAbility } from "@trigger.dev/rbac";
+import { checkPermissions } from "~/services/routeBuilders/permissions.server";
+
+const permissive: RbacAbility = { can: () => true, canSuper: () => false };
+const denyAll: RbacAbility = { can: () => false, canSuper: () => false };
+
+describe("checkPermissions", () => {
+ it("returns true for every check under a permissive ability (OSS path)", () => {
+ const result = checkPermissions(permissive, {
+ canCancelRun: { action: "write", resource: { type: "runs" } },
+ canManageMembers: { action: "manage", resource: { type: "members" } },
+ });
+
+ expect(result).toEqual({ canCancelRun: true, canManageMembers: true });
+ });
+
+ it("returns false for every check under a deny-all ability", () => {
+ const result = checkPermissions(denyAll, {
+ canCancelRun: { action: "write", resource: { type: "runs" } },
+ });
+
+ expect(result).toEqual({ canCancelRun: false });
+ });
+
+ it("evaluates each check independently against can()", () => {
+ const ability: RbacAbility = {
+ can: (action, resource) => {
+ const r = Array.isArray(resource) ? resource[0] : resource;
+ return action === "read" || r.type === "tasks";
+ },
+ canSuper: () => false,
+ };
+
+ const result = checkPermissions(ability, {
+ readRuns: { action: "read", resource: { type: "runs" } },
+ writeRuns: { action: "write", resource: { type: "runs" } },
+ writeTasks: { action: "write", resource: { type: "tasks" } },
+ });
+
+ expect(result).toEqual({ readRuns: true, writeRuns: false, writeTasks: true });
+ });
+
+ it("supports requireSuper checks via canSuper()", () => {
+ const admin: RbacAbility = { can: () => false, canSuper: () => true };
+
+ expect(checkPermissions(admin, { adminOnly: { requireSuper: true } })).toEqual({
+ adminOnly: true,
+ });
+ expect(checkPermissions(denyAll, { adminOnly: { requireSuper: true } })).toEqual({
+ adminOnly: false,
+ });
+ });
+
+ it("passes resource arrays straight through to can()", () => {
+ const seen: unknown[] = [];
+ const ability: RbacAbility = {
+ can: (_action, resource) => {
+ seen.push(resource);
+ return true;
+ },
+ canSuper: () => false,
+ };
+
+ checkPermissions(ability, {
+ x: { action: "read", resource: [{ type: "runs" }, { type: "tasks" }] },
+ });
+
+ expect(seen[0]).toEqual([{ type: "runs" }, { type: "tasks" }]);
+ });
+});