{token ? (
@@ -248,6 +358,7 @@ function CreatePersonalAccessToken() {
) : (
+ {showRolePicker && }
Name
@@ -265,6 +376,37 @@ function CreatePersonalAccessToken() {
{tokenName.error}
+ {showRolePicker && (
+
+ Maximum role
+
+ value={selectedRoleId}
+ setValue={(v) => setSelectedRoleId(v)}
+ items={roles}
+ variant="tertiary/small"
+ dropdownIcon
+ text={(v) => roles.find((r) => r.id === v)?.name ?? "Select a role"}
+ >
+ {(items) =>
+ items.map((role) => (
+
+
+ {role.name}
+ {role.description ? (
+ {role.description}
+ ) : null}
+
+
+ ))
+ }
+
+
+ The token can act with up to this role. Your current role in each org is the
+ actual ceiling β the token never grants more than you have.
+
+
+ )}
+
diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx
index aafb8180026..9c2c012f6e8 100644
--- a/apps/webapp/app/routes/admin._index.tsx
+++ b/apps/webapp/app/routes/admin._index.tsx
@@ -1,7 +1,5 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -22,7 +20,7 @@ import {
import { useUser } from "~/hooks/useUser";
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
export const SearchParams = z.object({
@@ -32,30 +30,34 @@ export const SearchParams = z.object({
export type SearchParams = z.infer;
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ user, request }) => {
+ const searchParams = createSearchParams(request.url, SearchParams);
+ if (!searchParams.success) {
+ throw new Error(searchParams.error);
+ }
+ const result = await adminGetUsers(user.id, searchParams.params.getAll());
- const searchParams = createSearchParams(request.url, SearchParams);
- if (!searchParams.success) {
- throw new Error(searchParams.error);
+ return typedjson(result);
}
- const result = await adminGetUsers(userId, searchParams.params.getAll());
-
- return typedjson(result);
-};
+);
const FormSchema = z.object({ id: z.string() });
-export async function action({ request }: ActionFunctionArgs) {
- if (request.method.toLowerCase() !== "post") {
- return new Response("Method not allowed", { status: 405 });
- }
+export const action = dashboardAction(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
+ if (request.method.toLowerCase() !== "post") {
+ return new Response("Method not allowed", { status: 405 });
+ }
- const payload = Object.fromEntries(await request.formData());
- const { id } = FormSchema.parse(payload);
+ const payload = Object.fromEntries(await request.formData());
+ const { id } = FormSchema.parse(payload);
- return redirectWithImpersonation(request, id, "/");
-}
+ return redirectWithImpersonation(request, id, "/");
+ }
+);
export default function AdminDashboardRoute() {
const user = useUser();
diff --git a/apps/webapp/app/routes/admin.back-office._index.tsx b/apps/webapp/app/routes/admin.back-office._index.tsx
index 15e6f699b9a..e2226aebb4a 100644
--- a/apps/webapp/app/routes/admin.back-office._index.tsx
+++ b/apps/webapp/app/routes/admin.back-office._index.tsx
@@ -1,17 +1,15 @@
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect, typedjson } from "remix-typedjson";
+import { typedjson } from "remix-typedjson";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
-import { requireUser } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
-export async function loader({ request }: LoaderFunctionArgs) {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async () => {
+ return typedjson({});
}
- return typedjson({});
-}
+);
export default function BackOfficeIndex() {
return (
diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx
index 211a5a4fd2e..1fe3e872168 100644
--- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx
+++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx
@@ -1,5 +1,4 @@
import { Form, useNavigation, useSearchParams } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -19,7 +18,7 @@ import {
} from "~/services/authorizationRateLimitMiddleware.server";
import { logger } from "~/services/logger.server";
import { type Duration } from "~/services/rateLimiter.server";
-import { requireUser } from "~/services/session.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
const SAVED_QUERY_KEY = "saved";
const SAVED_QUERY_VALUE = "1";
@@ -98,39 +97,38 @@ function describeRateLimit(
};
}
-export async function loader({ request, params }: LoaderFunctionArgs) {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
- }
-
- const orgId = params.orgId;
- if (!orgId) {
- throw new Response(null, { status: 404 });
- }
+const ParamsSchema = z.object({
+ orgId: z.string(),
+});
- const org = await prisma.organization.findFirst({
- where: { id: orgId },
- select: {
- id: true,
- slug: true,
- title: true,
- createdAt: true,
- apiRateLimiterConfig: true,
- },
- });
-
- if (!org) {
- throw new Response(null, { status: 404 });
- }
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true }, params: ParamsSchema },
+ async ({ params }) => {
+ const { orgId } = params;
+
+ const org = await prisma.organization.findFirst({
+ where: { id: orgId },
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ createdAt: true,
+ apiRateLimiterConfig: true,
+ },
+ });
+
+ if (!org) {
+ throw new Response(null, { status: 404 });
+ }
- const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig);
+ const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig);
- return typedjson({
- org,
- effective,
- });
-}
+ return typedjson({
+ org,
+ effective,
+ });
+ }
+);
const SetRateLimitSchema = z.object({
intent: z.literal("set-rate-limit"),
@@ -144,64 +142,59 @@ const SetRateLimitSchema = z.object({
maxTokens: z.coerce.number().int().min(1),
});
-export async function action({ request, params }: ActionFunctionArgs) {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
- }
-
- const orgId = params.orgId;
- if (!orgId) {
- throw new Response(null, { status: 404 });
- }
-
- const formData = await request.formData();
- const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData));
- if (!submission.success) {
- return typedjson(
- { errors: submission.error.flatten().fieldErrors },
- { status: 400 }
- );
- }
+export const action = dashboardAction(
+ { authorization: { requireSuper: true }, params: ParamsSchema },
+ async ({ user, params, request }) => {
+ const { orgId } = params;
+
+ const formData = await request.formData();
+ const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData));
+ if (!submission.success) {
+ return typedjson(
+ { errors: submission.error.flatten().fieldErrors },
+ { status: 400 }
+ );
+ }
- const existing = await prisma.organization.findFirst({
- where: { id: orgId },
- select: { apiRateLimiterConfig: true },
- });
- if (!existing) {
- throw new Response(null, { status: 404 });
- }
+ const existing = await prisma.organization.findFirst({
+ where: { id: orgId },
+ select: { apiRateLimiterConfig: true },
+ });
+ if (!existing) {
+ throw new Response(null, { status: 404 });
+ }
- const built = RateLimitTokenBucketConfig.safeParse({
- type: "tokenBucket",
- refillRate: submission.data.refillRate,
- interval: submission.data.interval,
- maxTokens: submission.data.maxTokens,
- });
- if (!built.success) {
- return typedjson(
- { errors: built.error.flatten().fieldErrors },
- { status: 400 }
+ const built = RateLimitTokenBucketConfig.safeParse({
+ type: "tokenBucket",
+ refillRate: submission.data.refillRate,
+ interval: submission.data.interval,
+ maxTokens: submission.data.maxTokens,
+ });
+ if (!built.success) {
+ return typedjson(
+ { errors: built.error.flatten().fieldErrors },
+ { status: 400 }
+ );
+ }
+ const next = built.data;
+
+ await prisma.organization.update({
+ where: { id: orgId },
+ data: { apiRateLimiterConfig: next as any },
+ });
+
+ logger.info("admin.backOffice.rateLimit", {
+ adminUserId: user.id,
+ orgId,
+ previous: existing.apiRateLimiterConfig,
+ next,
+ });
+
+ return redirect(
+ `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}`
);
}
- const next = built.data;
-
- await prisma.organization.update({
- where: { id: orgId },
- data: { apiRateLimiterConfig: next as any },
- });
-
- logger.info("admin.backOffice.rateLimit", {
- adminUserId: user.id,
- orgId,
- previous: existing.apiRateLimiterConfig,
- next,
- });
-
- return redirect(
- `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}`
- );
-}
+);
export default function BackOfficeOrgPage() {
const { org, effective } = useTypedLoaderData();
diff --git a/apps/webapp/app/routes/admin.back-office.tsx b/apps/webapp/app/routes/admin.back-office.tsx
index 026fc13fdc5..3ec9e99b2ca 100644
--- a/apps/webapp/app/routes/admin.back-office.tsx
+++ b/apps/webapp/app/routes/admin.back-office.tsx
@@ -1,15 +1,13 @@
import { Outlet } from "@remix-run/react";
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect, typedjson } from "remix-typedjson";
-import { requireUser } from "~/services/session.server";
+import { typedjson } from "remix-typedjson";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
-export async function loader({ request }: LoaderFunctionArgs) {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async () => {
+ return typedjson({});
}
- return typedjson({});
-}
+);
export default function BackOfficeLayout() {
return (
diff --git a/apps/webapp/app/routes/admin.concurrency.tsx b/apps/webapp/app/routes/admin.concurrency.tsx
index a24f7debb9d..630bc100b0b 100644
--- a/apps/webapp/app/routes/admin.concurrency.tsx
+++ b/apps/webapp/app/routes/admin.concurrency.tsx
@@ -1,23 +1,19 @@
import { InformationCircleIcon } from "@heroicons/react/20/solid";
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Header1 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Paragraph } from "~/components/primitives/Paragraph";
-import { requireUser } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async () => {
+ const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
+ const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
+ return typedjson({ deployedConcurrency, devConcurrency });
}
-
- const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true);
- const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false);
-
- return typedjson({ deployedConcurrency, devConcurrency });
-};
+);
export default function AdminDashboardRoute() {
const { deployedConcurrency, devConcurrency } = useTypedLoaderData();
diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx
index 4066e6a4d9b..02faa7add91 100644
--- a/apps/webapp/app/routes/admin.feature-flags.tsx
+++ b/apps/webapp/app/routes/admin.feature-flags.tsx
@@ -1,14 +1,16 @@
import { useFetcher } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import stableStringify from "json-stable-stringify";
import { json } from "@remix-run/server-runtime";
-import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LockClosedIcon } from "@heroicons/react/20/solid";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
-import { requireUser } from "~/services/session.server";
+import {
+ dashboardAction,
+ dashboardLoader,
+} from "~/services/routeBuilders/dashboardBuilder";
import {
FEATURE_FLAG,
GLOBAL_LOCKED_FLAGS,
@@ -38,53 +40,48 @@ import {
type WorkerGroup,
} from "~/components/admin/FlagControls";
-export const loader = async ({ request }: LoaderFunctionArgs) => {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
- }
-
- const [globalFlags, workerGroups] = await Promise.all([
- getGlobalFlags(),
- prisma.workerInstanceGroup.findMany({
- select: { id: true, name: true },
- orderBy: { name: "asc" },
- }),
- ]);
- const controlTypes = getAllFlagControlTypes();
-
- // Resolve env-based defaults for locked flags
- const resolvedDefaults: Record = {
- [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
- };
-
- // Look up worker group name if the flag is set
- const workerGroupId = (globalFlags as Record)?.[
- FEATURE_FLAG.defaultWorkerInstanceGroupId
- ];
- const workerGroupName =
- typeof workerGroupId === "string"
- ? workerGroups.find((wg) => wg.id === workerGroupId)?.name
- : undefined;
-
- const { isManagedCloud } = featuresForRequest(request);
-
- return typedjson({
- globalFlags,
- controlTypes,
- resolvedDefaults,
- workerGroupName,
- workerGroups,
- isManagedCloud,
- });
-};
-
-export const action = async ({ request }: ActionFunctionArgs) => {
- const user = await requireUser(request);
- if (!user.admin) {
- throw new Response("Unauthorized", { status: 403 });
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
+ const [globalFlags, workerGroups] = await Promise.all([
+ getGlobalFlags(),
+ prisma.workerInstanceGroup.findMany({
+ select: { id: true, name: true },
+ orderBy: { name: "asc" },
+ }),
+ ]);
+ const controlTypes = getAllFlagControlTypes();
+
+ // Resolve env-based defaults for locked flags
+ const resolvedDefaults: Record = {
+ [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
+ };
+
+ // Look up worker group name if the flag is set
+ const workerGroupId = (globalFlags as Record)?.[
+ FEATURE_FLAG.defaultWorkerInstanceGroupId
+ ];
+ const workerGroupName =
+ typeof workerGroupId === "string"
+ ? workerGroups.find((wg) => wg.id === workerGroupId)?.name
+ : undefined;
+
+ const { isManagedCloud } = featuresForRequest(request);
+
+ return typedjson({
+ globalFlags,
+ controlTypes,
+ resolvedDefaults,
+ workerGroupName,
+ workerGroups,
+ isManagedCloud,
+ });
}
+);
+export const action = dashboardAction(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
let body: unknown;
try {
body = await request.json();
@@ -156,7 +153,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
]);
return json({ success: true });
-};
+ }
+);
export default function AdminFeatureFlagsRoute() {
const {
diff --git a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx
index 7b51067dd0c..e90752fb28d 100644
--- a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx
+++ b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx
@@ -1,5 +1,4 @@
import { Form, useActionData, useNavigate } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
@@ -8,34 +7,37 @@ import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Input } from "~/components/primitives/Input";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
-
- const model = await prisma.llmModel.findUnique({
- where: { friendlyId: params.modelId },
- include: {
- pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
- },
- });
-
- if (!model) throw new Response("Model not found", { status: 404 });
-
- // Convert Prisma Decimal to plain numbers for serialization
- const serialized = {
- ...model,
- pricingTiers: model.pricingTiers.map((t) => ({
- ...t,
- prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
- })),
- };
-
- return typedjson({ model: serialized });
-};
+const ParamsSchema = z.object({
+ modelId: z.string(),
+});
+
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true }, params: ParamsSchema },
+ async ({ params }) => {
+ const model = await prisma.llmModel.findUnique({
+ where: { friendlyId: params.modelId },
+ include: {
+ pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
+ },
+ });
+
+ if (!model) throw new Response("Model not found", { status: 404 });
+
+ // Convert Prisma Decimal to plain numbers for serialization
+ const serialized = {
+ ...model,
+ pricingTiers: model.pricingTiers.map((t) => ({
+ ...t,
+ prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
+ })),
+ };
+
+ return typedjson({ model: serialized });
+ }
+);
const SaveSchema = z.object({
modelName: z.string().min(1),
@@ -49,100 +51,99 @@ const SaveSchema = z.object({
isHidden: z.string().optional(),
});
-export async function action({ request, params }: ActionFunctionArgs) {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
-
- const friendlyId = params.modelId!;
- const existing = await prisma.llmModel.findUnique({ where: { friendlyId } });
- if (!existing) throw new Response("Model not found", { status: 404 });
- const modelId = existing.id;
-
- const formData = await request.formData();
- const _action = formData.get("_action");
-
- if (_action === "delete") {
- await prisma.llmModel.delete({ where: { id: modelId } });
- await llmPricingRegistry?.reload();
- return redirect("/admin/llm-models");
- }
-
- if (_action === "save") {
- const raw = Object.fromEntries(formData);
- const parsed = SaveSchema.safeParse(raw);
-
- if (!parsed.success) {
- return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
+export const action = dashboardAction(
+ { authorization: { requireSuper: true }, params: ParamsSchema },
+ async ({ params, request }) => {
+ const friendlyId = params.modelId;
+ const existing = await prisma.llmModel.findUnique({ where: { friendlyId } });
+ if (!existing) throw new Response("Model not found", { status: 404 });
+ const modelId = existing.id;
+
+ const formData = await request.formData();
+ const _action = formData.get("_action");
+
+ if (_action === "delete") {
+ await prisma.llmModel.delete({ where: { id: modelId } });
+ await llmPricingRegistry?.reload();
+ return redirect("/admin/llm-models");
}
- const { modelName, matchPattern, pricingTiersJson } = parsed.data;
-
- // Validate regex β strip (?i) POSIX flag since our registry handles it
- try {
- const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
- new RegExp(testPattern);
- } catch {
- return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
- }
-
- // Parse tiers
- let pricingTiers: Array<{
- name: string;
- isDefault: boolean;
- priority: number;
- conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
- prices: Record;
- }>;
- try {
- pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
- } catch {
- return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
- }
-
- // Update model
- const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
- await prisma.llmModel.update({
- where: { id: modelId },
- data: {
- modelName,
- matchPattern,
- provider: provider || null,
- description: description || null,
- contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
- maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
- capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
- isHidden: isHidden === "on",
- },
- });
-
- // Replace tiers
- await prisma.llmPricingTier.deleteMany({ where: { modelId } });
- for (const tier of pricingTiers) {
- await prisma.llmPricingTier.create({
+ if (_action === "save") {
+ const raw = Object.fromEntries(formData);
+ const parsed = SaveSchema.safeParse(raw);
+
+ if (!parsed.success) {
+ return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
+ }
+
+ const { modelName, matchPattern, pricingTiersJson } = parsed.data;
+
+ // Validate regex β strip (?i) POSIX flag since our registry handles it
+ try {
+ const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
+ new RegExp(testPattern);
+ } catch {
+ return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
+ }
+
+ // Parse tiers
+ let pricingTiers: Array<{
+ name: string;
+ isDefault: boolean;
+ priority: number;
+ conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
+ prices: Record;
+ }>;
+ try {
+ pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
+ } catch {
+ return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
+ }
+
+ // Update model
+ const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
+ await prisma.llmModel.update({
+ where: { id: modelId },
data: {
- modelId,
- name: tier.name,
- isDefault: tier.isDefault,
- priority: tier.priority,
- conditions: tier.conditions,
- prices: {
- create: Object.entries(tier.prices).map(([usageType, price]) => ({
- modelId,
- usageType,
- price,
- })),
- },
+ modelName,
+ matchPattern,
+ provider: provider || null,
+ description: description || null,
+ contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
+ maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
+ capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
+ isHidden: isHidden === "on",
},
});
+
+ // Replace tiers
+ await prisma.llmPricingTier.deleteMany({ where: { modelId } });
+ for (const tier of pricingTiers) {
+ await prisma.llmPricingTier.create({
+ data: {
+ modelId,
+ name: tier.name,
+ isDefault: tier.isDefault,
+ priority: tier.priority,
+ conditions: tier.conditions,
+ prices: {
+ create: Object.entries(tier.prices).map(([usageType, price]) => ({
+ modelId,
+ usageType,
+ price,
+ })),
+ },
+ },
+ });
+ }
+
+ await llmPricingRegistry?.reload();
+ return typedjson({ success: true });
}
- await llmPricingRegistry?.reload();
- return typedjson({ success: true });
+ return typedjson({ error: "Unknown action" }, { status: 400 });
}
-
- return typedjson({ error: "Unknown action" }, { status: 400 });
-}
+);
export default function AdminLlmModelDetailRoute() {
const { model } = useTypedLoaderData();
diff --git a/apps/webapp/app/routes/admin.llm-models._index.tsx b/apps/webapp/app/routes/admin.llm-models._index.tsx
index ea2eff72541..585cbb4637b 100644
--- a/apps/webapp/app/routes/admin.llm-models._index.tsx
+++ b/apps/webapp/app/routes/admin.llm-models._index.tsx
@@ -1,7 +1,5 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, Link } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -18,7 +16,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { seedLlmPricing, syncLlmCatalog } from "@internal/llm-model-catalog";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
@@ -30,121 +28,119 @@ const SearchParams = z.object({
search: z.string().optional(),
});
-export const loader = async ({ request }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
+ const searchParams = createSearchParams(request.url, SearchParams);
+ if (!searchParams.success) throw new Error(searchParams.error);
+ const { page: rawPage, search } = searchParams.params.getAll();
+ const page = rawPage ?? 1;
+
+ const where = {
+ projectId: null as string | null,
+ ...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}),
+ };
+
+ const [rawModels, total] = await Promise.all([
+ prisma.llmModel.findMany({
+ where,
+ include: {
+ pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
+ },
+ orderBy: { modelName: "asc" },
+ skip: (page - 1) * PAGE_SIZE,
+ take: PAGE_SIZE,
+ }),
+ prisma.llmModel.count({ where }),
+ ]);
+
+ // Convert Prisma Decimal to plain numbers for serialization
+ const models = rawModels.map((m) => ({
+ ...m,
+ pricingTiers: m.pricingTiers.map((t) => ({
+ ...t,
+ prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
+ })),
+ }));
- const searchParams = createSearchParams(request.url, SearchParams);
- if (!searchParams.success) throw new Error(searchParams.error);
- const { page: rawPage, search } = searchParams.params.getAll();
- const page = rawPage ?? 1;
-
- const where = {
- projectId: null as string | null,
- ...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}),
- };
-
- const [rawModels, total] = await Promise.all([
- prisma.llmModel.findMany({
- where,
- include: {
- pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
- },
- orderBy: { modelName: "asc" },
- skip: (page - 1) * PAGE_SIZE,
- take: PAGE_SIZE,
- }),
- prisma.llmModel.count({ where }),
- ]);
-
- // Convert Prisma Decimal to plain numbers for serialization
- const models = rawModels.map((m) => ({
- ...m,
- pricingTiers: m.pricingTiers.map((t) => ({
- ...t,
- prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })),
- })),
- }));
-
- return typedjson({
- models,
- total,
- page,
- pageCount: Math.ceil(total / PAGE_SIZE),
- filters: { search },
- });
-};
-
-export async function action({ request }: ActionFunctionArgs) {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
-
- const formData = await request.formData();
- const _action = formData.get("_action");
-
- if (_action === "seed") {
- console.log("[admin] seed action started");
- const result = await seedLlmPricing(prisma);
- console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`);
- await llmPricingRegistry?.reload();
- console.log("[admin] registry reloaded after seed");
return typedjson({
- success: true,
- message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`,
+ models,
+ total,
+ page,
+ pageCount: Math.ceil(total / PAGE_SIZE),
+ filters: { search },
});
}
+);
+
+export const action = dashboardAction(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
+ const formData = await request.formData();
+ const _action = formData.get("_action");
+
+ if (_action === "seed") {
+ console.log("[admin] seed action started");
+ const result = await seedLlmPricing(prisma);
+ console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`);
+ await llmPricingRegistry?.reload();
+ console.log("[admin] registry reloaded after seed");
+ return typedjson({
+ success: true,
+ message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`,
+ });
+ }
- if (_action === "sync") {
- console.log("[admin] sync catalog action started");
- const result = await syncLlmCatalog(prisma);
- console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`);
- await llmPricingRegistry?.reload();
- console.log("[admin] registry reloaded after sync");
- return typedjson({
- success: true,
- message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`,
- });
- }
-
- if (_action === "reload") {
- console.log("[admin] reload action started");
- await llmPricingRegistry?.reload();
- console.log("[admin] registry reloaded");
- return typedjson({ success: true, message: "Registry reloaded" });
- }
-
- if (_action === "test") {
- const modelString = formData.get("modelString");
- if (typeof modelString !== "string" || !modelString) {
- return typedjson({ testResult: null });
+ if (_action === "sync") {
+ console.log("[admin] sync catalog action started");
+ const result = await syncLlmCatalog(prisma);
+ console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`);
+ await llmPricingRegistry?.reload();
+ console.log("[admin] registry reloaded after sync");
+ return typedjson({
+ success: true,
+ message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`,
+ });
}
- // Use the registry's match() which handles prefix stripping automatically
- const matched = llmPricingRegistry?.match(modelString) ?? null;
+ if (_action === "reload") {
+ console.log("[admin] reload action started");
+ await llmPricingRegistry?.reload();
+ console.log("[admin] registry reloaded");
+ return typedjson({ success: true, message: "Registry reloaded" });
+ }
- return typedjson({
- testResult: {
- modelString,
- match: matched
- ? { friendlyId: matched.friendlyId, modelName: matched.modelName }
- : null,
- },
- });
- }
+ if (_action === "test") {
+ const modelString = formData.get("modelString");
+ if (typeof modelString !== "string" || !modelString) {
+ return typedjson({ testResult: null });
+ }
+
+ // Use the registry's match() which handles prefix stripping automatically
+ const matched = llmPricingRegistry?.match(modelString) ?? null;
+
+ return typedjson({
+ testResult: {
+ modelString,
+ match: matched
+ ? { friendlyId: matched.friendlyId, modelName: matched.modelName }
+ : null,
+ },
+ });
+ }
- if (_action === "delete") {
- const modelId = formData.get("modelId");
- if (typeof modelId === "string") {
- await prisma.llmModel.delete({ where: { id: modelId } });
- await llmPricingRegistry?.reload();
+ if (_action === "delete") {
+ const modelId = formData.get("modelId");
+ if (typeof modelId === "string") {
+ await prisma.llmModel.delete({ where: { id: modelId } });
+ await llmPricingRegistry?.reload();
+ }
+ return typedjson({ success: true });
}
- return typedjson({ success: true });
- }
- return typedjson({ error: "Unknown action" }, { status: 400 });
-}
+ return typedjson({ error: "Unknown action" }, { status: 400 });
+ }
+);
export default function AdminLlmModelsRoute() {
const { models, filters, page, pageCount, total } =
diff --git a/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx b/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx
index 78cb1c4fc91..3c63ce09fc4 100644
--- a/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx
+++ b/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx
@@ -1,39 +1,40 @@
import { useState } from "react";
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
-import { prisma } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import {
getMissingModelSamples,
type MissingModelSample,
} from "~/services/admin/missingLlmModels.server";
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
+const ParamsSchema = z.object({
+ model: z.string(),
+});
- // Model name is URL-encoded in the URL param
- const modelName = decodeURIComponent(params.model ?? "");
- if (!modelName) throw new Response("Missing model param", { status: 400 });
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true }, params: ParamsSchema },
+ async ({ params, request }) => {
+ // Model name is URL-encoded in the URL param
+ const modelName = decodeURIComponent(params.model);
+ if (!modelName) throw new Response("Missing model param", { status: 400 });
- const url = new URL(request.url);
- const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
+ const url = new URL(request.url);
+ const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
- let samples: MissingModelSample[] = [];
- let error: string | undefined;
+ let samples: MissingModelSample[] = [];
+ let error: string | undefined;
- try {
- samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 });
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to query ClickHouse";
- }
+ try {
+ samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 });
+ } catch (e) {
+ error = e instanceof Error ? e.message : "Failed to query ClickHouse";
+ }
- return typedjson({ modelName, samples, lookbackHours, error });
-};
+ return typedjson({ modelName, samples, lookbackHours, error });
+ }
+);
export default function AdminMissingModelDetailRoute() {
const { modelName, samples, lookbackHours, error } = useTypedLoaderData();
diff --git a/apps/webapp/app/routes/admin.llm-models.missing._index.tsx b/apps/webapp/app/routes/admin.llm-models.missing._index.tsx
index fd933cd22e9..7cacb727f9c 100644
--- a/apps/webapp/app/routes/admin.llm-models.missing._index.tsx
+++ b/apps/webapp/app/routes/admin.llm-models.missing._index.tsx
@@ -1,6 +1,4 @@
import { useSearchParams } from "@remix-run/react";
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LinkButton } from "~/components/primitives/Buttons";
@@ -14,8 +12,7 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
-import { prisma } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { getMissingLlmModels } from "~/services/admin/missingLlmModels.server";
const LOOKBACK_OPTIONS = [
@@ -30,25 +27,24 @@ const SearchParams = z.object({
lookbackHours: z.coerce.number().optional(),
});
-export const loader = async ({ request }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
+ const url = new URL(request.url);
+ const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
- const url = new URL(request.url);
- const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10);
+ let models: Awaited> = [];
+ let error: string | undefined;
- let models: Awaited> = [];
- let error: string | undefined;
+ try {
+ models = await getMissingLlmModels({ lookbackHours });
+ } catch (e) {
+ error = e instanceof Error ? e.message : "Failed to query ClickHouse";
+ }
- try {
- models = await getMissingLlmModels({ lookbackHours });
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to query ClickHouse";
+ return typedjson({ models, lookbackHours, error });
}
-
- return typedjson({ models, lookbackHours, error });
-};
+);
export default function AdminLlmModelsMissingRoute() {
const { models, lookbackHours, error } = useTypedLoaderData();
diff --git a/apps/webapp/app/routes/admin.llm-models.new.tsx b/apps/webapp/app/routes/admin.llm-models.new.tsx
index 7f18bf5826a..ab9c7881e2c 100644
--- a/apps/webapp/app/routes/admin.llm-models.new.tsx
+++ b/apps/webapp/app/routes/admin.llm-models.new.tsx
@@ -1,5 +1,4 @@
import { Form, useActionData, useSearchParams } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
@@ -7,16 +6,16 @@ import { useState } from "react";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Input } from "~/components/primitives/Input";
import { prisma } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server";
-export const loader = async ({ request }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
- return typedjson({});
-};
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async () => {
+ return typedjson({});
+ }
+);
const CreateSchema = z.object({
modelName: z.string().min(1),
@@ -30,83 +29,82 @@ const CreateSchema = z.object({
isHidden: z.string().optional(),
});
-export async function action({ request }: ActionFunctionArgs) {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
-
- const formData = await request.formData();
- const raw = Object.fromEntries(formData);
- console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500));
- const parsed = CreateSchema.safeParse(raw);
+export const action = dashboardAction(
+ { authorization: { requireSuper: true } },
+ async ({ request }) => {
+ const formData = await request.formData();
+ const raw = Object.fromEntries(formData);
+ console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500));
+ const parsed = CreateSchema.safeParse(raw);
+
+ if (!parsed.success) {
+ console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues));
+ return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
+ }
- if (!parsed.success) {
- console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues));
- return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
- }
+ const { modelName, matchPattern, pricingTiersJson } = parsed.data;
- const { modelName, matchPattern, pricingTiersJson } = parsed.data;
+ // Validate regex β strip (?i) POSIX flag since our registry handles it
+ try {
+ const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
+ new RegExp(testPattern);
+ } catch {
+ return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
+ }
- // Validate regex β strip (?i) POSIX flag since our registry handles it
- try {
- const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern;
- new RegExp(testPattern);
- } catch {
- return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 });
- }
+ let pricingTiers: Array<{
+ name: string;
+ isDefault: boolean;
+ priority: number;
+ conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
+ prices: Record;
+ }>;
+ try {
+ pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
+ } catch {
+ return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
+ }
- let pricingTiers: Array<{
- name: string;
- isDefault: boolean;
- priority: number;
- conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
- prices: Record;
- }>;
- try {
- pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
- } catch {
- return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
- }
+ const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
- const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data;
-
- const model = await prisma.llmModel.create({
- data: {
- friendlyId: generateFriendlyId("llm_model"),
- modelName,
- matchPattern,
- source: "admin",
- provider: provider || null,
- description: description || null,
- contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
- maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
- capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
- isHidden: isHidden === "on",
- },
- });
-
- for (const tier of pricingTiers) {
- await prisma.llmPricingTier.create({
+ const model = await prisma.llmModel.create({
data: {
- modelId: model.id,
- name: tier.name,
- isDefault: tier.isDefault,
- priority: tier.priority,
- conditions: tier.conditions,
- prices: {
- create: Object.entries(tier.prices).map(([usageType, price]) => ({
- modelId: model.id,
- usageType,
- price,
- })),
- },
+ friendlyId: generateFriendlyId("llm_model"),
+ modelName,
+ matchPattern,
+ source: "admin",
+ provider: provider || null,
+ description: description || null,
+ contextWindow: contextWindow ? parseInt(contextWindow) || null : null,
+ maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null,
+ capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [],
+ isHidden: isHidden === "on",
},
});
- }
- await llmPricingRegistry?.reload();
- return redirect(`/admin/llm-models/${model.friendlyId}`);
-}
+ for (const tier of pricingTiers) {
+ await prisma.llmPricingTier.create({
+ data: {
+ modelId: model.id,
+ name: tier.name,
+ isDefault: tier.isDefault,
+ priority: tier.priority,
+ conditions: tier.conditions,
+ prices: {
+ create: Object.entries(tier.prices).map(([usageType, price]) => ({
+ modelId: model.id,
+ usageType,
+ price,
+ })),
+ },
+ },
+ });
+ }
+
+ await llmPricingRegistry?.reload();
+ return redirect(`/admin/llm-models/${model.friendlyId}`);
+ }
+);
export default function AdminLlmModelNewRoute() {
const actionData = useActionData<{ error?: string; details?: unknown[] }>();
diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx
index 179ab23c3ee..543367d5571 100644
--- a/apps/webapp/app/routes/admin.notifications.tsx
+++ b/apps/webapp/app/routes/admin.notifications.tsx
@@ -1,7 +1,5 @@
import { ChevronRightIcon, TrashIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useFetcher, useSearchParams } from "@remix-run/react";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect } from "@remix-run/server-runtime";
import { useEffect, useRef, useState, useLayoutEffect } from "react";
import ReactMarkdown from "react-markdown";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -36,8 +34,6 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
-import { prisma } from "~/db.server";
-import { requireUserId } from "~/services/session.server";
import {
archivePlatformNotification,
createPlatformNotification,
@@ -46,6 +42,7 @@ import {
publishNowPlatformNotification,
updatePlatformNotification,
} from "~/services/platformNotifications.server";
+import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { cn } from "~/utils/cn";
@@ -59,51 +56,49 @@ const SearchParams = z.object({
hideInactive: z.coerce.boolean().optional(),
});
-export const loader = async ({ request }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ user, request }) => {
+ const searchParams = createSearchParams(request.url, SearchParams);
+ if (!searchParams.success) throw new Error(searchParams.error);
+ const { page: rawPage, hideInactive } = searchParams.params.getAll();
+ const page = rawPage ?? 1;
- const searchParams = createSearchParams(request.url, SearchParams);
- if (!searchParams.success) throw new Error(searchParams.error);
- const { page: rawPage, hideInactive } = searchParams.params.getAll();
- const page = rawPage ?? 1;
+ const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false });
- const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false });
-
- return typedjson({ ...data, userId });
-};
+ return typedjson({ ...data, userId: user.id });
+ }
+);
-export async function action({ request }: ActionFunctionArgs) {
- const userId = await requireUserId(request);
- const user = await prisma.user.findUnique({ where: { id: userId } });
- if (!user?.admin) throw redirect("/");
+export const action = dashboardAction(
+ { authorization: { requireSuper: true } },
+ async ({ user, request }) => {
+ const formData = await request.formData();
+ const _action = formData.get("_action");
- const formData = await request.formData();
- const _action = formData.get("_action");
+ if (_action === "create" || _action === "create-preview") {
+ return handleCreateAction(formData, user.id, _action === "create-preview");
+ }
- if (_action === "create" || _action === "create-preview") {
- return handleCreateAction(formData, userId, _action === "create-preview");
- }
+ if (_action === "archive") {
+ return handleArchiveAction(formData);
+ }
- if (_action === "archive") {
- return handleArchiveAction(formData);
- }
+ if (_action === "delete") {
+ return handleDeleteAction(formData);
+ }
- if (_action === "delete") {
- return handleDeleteAction(formData);
- }
+ if (_action === "publish-now") {
+ return handlePublishNowAction(formData);
+ }
- if (_action === "publish-now") {
- return handlePublishNowAction(formData);
- }
+ if (_action === "edit") {
+ return handleEditAction(formData);
+ }
- if (_action === "edit") {
- return handleEditAction(formData);
+ return typedjson({ error: "Unknown action" }, { status: 400 });
}
-
- return typedjson({ error: "Unknown action" }, { status: 400 });
-}
+);
function parseNotificationFormData(formData: FormData) {
const surface = formData.get("surface") as string;
diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx
index 6d16ab99c9d..8441d4d19da 100644
--- a/apps/webapp/app/routes/admin.orgs.tsx
+++ b/apps/webapp/app/routes/admin.orgs.tsx
@@ -1,7 +1,6 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useState } from "react";
import { z } from "zod";
import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog";
@@ -20,7 +19,7 @@ import {
TableRow,
} from "~/components/primitives/Table";
import { adminGetOrganizations } from "~/models/admin.server";
-import { requireUser, requireUserId } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { createSearchParams } from "~/utils/searchParams";
export const SearchParams = z.object({
@@ -30,20 +29,18 @@ export const SearchParams = z.object({
export type SearchParams = z.infer;
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
- }
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ user, request }) => {
+ const searchParams = createSearchParams(request.url, SearchParams);
+ if (!searchParams.success) {
+ throw new Error(searchParams.error);
+ }
+ const result = await adminGetOrganizations(user.id, searchParams.params.getAll());
- const searchParams = createSearchParams(request.url, SearchParams);
- if (!searchParams.success) {
- throw new Error(searchParams.error);
+ return typedjson(result);
}
- const result = await adminGetOrganizations(user.id, searchParams.params.getAll());
-
- return typedjson(result);
-};
+);
export default function AdminDashboardRoute() {
const { organizations, filters, page, pageCount } = useTypedLoaderData();
diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx
index 61431398220..236c7f0580c 100644
--- a/apps/webapp/app/routes/admin.tsx
+++ b/apps/webapp/app/routes/admin.tsx
@@ -1,18 +1,13 @@
import { Outlet } from "@remix-run/react";
-import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { redirect, typedjson } from "remix-typedjson";
+import { typedjson } from "remix-typedjson";
import { LinkButton } from "~/components/primitives/Buttons";
import { Tabs } from "~/components/primitives/Tabs";
-import { requireUser } from "~/services/session.server";
+import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
-export async function loader({ request }: LoaderFunctionArgs) {
- const user = await requireUser(request);
- if (!user.admin) {
- return redirect("/");
- }
-
- return typedjson({ user });
-}
+export const loader = dashboardLoader(
+ { authorization: { requireSuper: true } },
+ async ({ user }) => typedjson({ user })
+);
export default function Page() {
return (
diff --git a/apps/webapp/app/routes/api.v1.batches.$batchId.ts b/apps/webapp/app/routes/api.v1.batches.$batchId.ts
index d852385b4b6..a48db2ee407 100644
--- a/apps/webapp/app/routes/api.v1.batches.$batchId.ts
+++ b/apps/webapp/app/routes/api.v1.batches.$batchId.ts
@@ -25,8 +25,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (batch) => ({ batch: batch.friendlyId }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (batch) => ({ type: "batch", id: batch.friendlyId }),
},
},
async ({ resource: batch }) => {
diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts
index 0190ba123d5..369ef0191d8 100644
--- a/apps/webapp/app/routes/api.v1.deployments.ts
+++ b/apps/webapp/app/routes/api.v1.deployments.ts
@@ -72,8 +72,7 @@ export const loader = createLoaderApiRoute(
corsStrategy: "none",
authorization: {
action: "read",
- resource: () => ({ deployments: "list" }),
- superScopes: ["read:deployments", "read:all", "admin"],
+ resource: () => ({ type: "deployments", id: "list" }),
},
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
index 557a67409de..f9c5ac0b68c 100644
--- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
+++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
@@ -21,8 +21,7 @@ export const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "write",
- resource: () => ({}),
- superScopes: ["write:runs", "admin"],
+ resource: () => ({ type: "runs" }),
},
},
async ({ params, body, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts
index 295bcb5caee..5e952aa7ce0 100644
--- a/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts
+++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts
@@ -1,5 +1,6 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
+import { $replica } from "~/db.server";
import { findProjectByRef } from "~/models/project.server";
import {
ApiRunListPresenter,
@@ -16,6 +17,20 @@ export const loader = createLoaderPATApiRoute(
params: ParamsSchema,
searchParams: ApiRunListSearchParams,
corsStrategy: "all",
+ // Resolve projectRef β org so the PAT plugin can ground its
+ // role-floor calculation. We deliberately don't filter by user
+ // membership here β that's the plugin's job (`authenticatePat`
+ // checks OrgMember in the target org and rejects if the user
+ // isn't a member). Keeps the contract clean: context is "what
+ // org does this URL target?" and auth is "is this user allowed?"
+ context: async (params) => {
+ const project = await $replica.project.findFirst({
+ where: { externalRef: params.projectRef },
+ select: { organizationId: true },
+ });
+ return project ? { organizationId: project.organizationId } : {};
+ },
+ authorization: { action: "read", resource: () => ({ type: "runs" }) },
},
async ({ searchParams, params, authentication, apiVersion }) => {
const project = await findProjectByRef(params.projectRef, authentication.userId);
diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
index 1203682793a..99601b5d668 100644
--- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
+++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts
@@ -22,8 +22,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "update",
- resource: (params) => ({ prompts: params.slug }),
- superScopes: ["admin"],
+ resource: (params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ body, params, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
index 3ddf7b78416..2a00ceac15c 100644
--- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
+++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts
@@ -40,8 +40,7 @@ const { action, loader } = createMultiMethodApiRoute({
corsStrategy: "all",
authorization: {
action: "update",
- resource: (params) => ({ prompts: params.slug }),
- superScopes: ["admin"],
+ resource: (params) => ({ type: "prompts", id: params.slug }),
},
methods: {
POST: {
diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
index 6040fdb46e6..795e4a6c68f 100644
--- a/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
+++ b/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts
@@ -22,8 +22,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "update",
- resource: (params) => ({ prompts: params.slug }),
- superScopes: ["admin"],
+ resource: (params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ body, params, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.ts
index 32ea1525c14..0d101ae6122 100644
--- a/apps/webapp/app/routes/api.v1.prompts.$slug.ts
+++ b/apps/webapp/app/routes/api.v1.prompts.$slug.ts
@@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (_resource, params) => ({ prompts: params.slug }),
- superScopes: ["read:prompts", "admin"],
+ resource: (_resource, params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ searchParams, resource: prompt }) => {
@@ -98,8 +97,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
- resource: (params) => ({ prompts: params.slug }),
- superScopes: ["read:prompts", "admin"],
+ resource: (params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ body, params, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
index c40b3e62dbf..49f90a98c84 100644
--- a/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
+++ b/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts
@@ -27,8 +27,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (_resource, params) => ({ prompts: params.slug }),
- superScopes: ["read:prompts", "admin"],
+ resource: (_resource, params) => ({ type: "prompts", id: params.slug }),
},
},
async ({ resource: prompt }) => {
diff --git a/apps/webapp/app/routes/api.v1.prompts._index.ts b/apps/webapp/app/routes/api.v1.prompts._index.ts
index ccbc0ec38d0..e4ef5f9702e 100644
--- a/apps/webapp/app/routes/api.v1.prompts._index.ts
+++ b/apps/webapp/app/routes/api.v1.prompts._index.ts
@@ -10,8 +10,7 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
- resource: () => ({ prompts: "all" }),
- superScopes: ["read:prompts", "admin"],
+ resource: () => ({ type: "prompts", id: "all" }),
},
},
async ({ authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.query.dashboards._index.ts b/apps/webapp/app/routes/api.v1.query.dashboards._index.ts
index fdc4dbc3852..2bc9e3b3016 100644
--- a/apps/webapp/app/routes/api.v1.query.dashboards._index.ts
+++ b/apps/webapp/app/routes/api.v1.query.dashboards._index.ts
@@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
- resource: () => ({ query: "dashboards" }),
- superScopes: ["read:query", "read:all", "admin"],
+ resource: () => ({ type: "query", id: "dashboards" }),
},
},
async () => {
diff --git a/apps/webapp/app/routes/api.v1.query.schema.ts b/apps/webapp/app/routes/api.v1.query.schema.ts
index aa4762af6f8..3e95d16818d 100644
--- a/apps/webapp/app/routes/api.v1.query.schema.ts
+++ b/apps/webapp/app/routes/api.v1.query.schema.ts
@@ -47,8 +47,7 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
- resource: () => ({ query: "schema" }),
- superScopes: ["read:query", "read:all", "admin"],
+ resource: () => ({ type: "query", id: "schema" }),
},
},
async () => {
diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts
index 22500011671..37ea2fafbb5 100644
--- a/apps/webapp/app/routes/api.v1.query.ts
+++ b/apps/webapp/app/routes/api.v1.query.ts
@@ -1,7 +1,10 @@
import { json } from "@remix-run/server-runtime";
import { QueryError } from "@internal/clickhouse";
import { z } from "zod";
-import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ createActionApiRoute,
+ everyResource,
+} from "~/services/routeBuilders/apiBuilder.server";
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { logger } from "~/services/logger.server";
import { rowsToCSV } from "~/utils/dataExport";
@@ -34,11 +37,16 @@ const { action, loader } = createActionApiRoute(
findResource: async () => 1,
authorization: {
action: "read",
+ // A multi-table query reads from every detected table. Wrap with
+ // everyResource so a JWT scoped to one table can't pass auth for
+ // a query that also reads tables it isn't scoped to (would be the
+ // same OR-loophole the batch trigger route had pre-fix).
resource: (_, __, ___, body) => {
const tables = detectTables(body.query);
- return { query: tables.length > 0 ? tables : "all" };
+ return tables.length > 0
+ ? everyResource(tables.map((id) => ({ type: "query", id })))
+ : { type: "query", id: "all" };
},
- superScopes: ["read:query", "read:all", "admin"],
},
},
async ({ body, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts
index ac96c9ddb81..6e48288e958 100644
--- a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts
+++ b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts
@@ -1,7 +1,10 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
@@ -21,13 +24,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batch?.friendlyId,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batch?.friendlyId) {
+ resources.push({ type: "batch", id: run.batch.friendlyId });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ resource: run, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts
index 7c093efd960..a123b1522b7 100644
--- a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts
+++ b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts
@@ -3,7 +3,10 @@ import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
import { extractAISpanData } from "~/components/runs/v3/ai";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
@@ -28,13 +31,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batchId) {
+ resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ params, resource: run, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
index cc35836bfe6..aba85259fbc 100644
--- a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
+++ b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts
@@ -2,7 +2,10 @@ import { json } from "@remix-run/server-runtime";
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
import { z } from "zod";
import { $replica } from "~/db.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
@@ -26,13 +29,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batchId) {
+ resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ resource: run, authentication }) => {
diff --git a/apps/webapp/app/routes/api.v1.runs.ts b/apps/webapp/app/routes/api.v1.runs.ts
index b5191ee2591..59bad5bf741 100644
--- a/apps/webapp/app/routes/api.v1.runs.ts
+++ b/apps/webapp/app/routes/api.v1.runs.ts
@@ -4,7 +4,10 @@ import {
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { logger } from "~/services/logger.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
export const loader = createLoaderApiRoute(
{
@@ -13,8 +16,13 @@ export const loader = createLoaderApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
- resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (_, __, searchParams) => {
+ const taskFilter = searchParams["filter[taskIdentifier]"] ?? [];
+ return anyResource([
+ { type: "runs" },
+ ...taskFilter.map((id) => ({ type: "tasks", id })),
+ ]);
+ },
},
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
},
diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.close.ts b/apps/webapp/app/routes/api.v1.sessions.$session.close.ts
index 16d8a6d93d1..15c2e8dc6bd 100644
--- a/apps/webapp/app/routes/api.v1.sessions.$session.close.ts
+++ b/apps/webapp/app/routes/api.v1.sessions.$session.close.ts
@@ -25,8 +25,7 @@ const { action, loader } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "admin",
- resource: (params) => ({ sessions: params.session }),
- superScopes: ["admin:sessions", "admin:all", "admin"],
+ resource: (params) => ({ type: "sessions", id: params.session }),
},
},
async ({ authentication, params, body }) => {
diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts b/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts
index cdc9c9e8dc7..7c5718aeae3 100644
--- a/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts
+++ b/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts
@@ -8,7 +8,10 @@ import { $replica, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { swapSessionRun } from "~/services/realtime/sessionRunManager.server";
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
-import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createActionApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
session: z.string(),
@@ -42,15 +45,18 @@ const { action, loader } = createActionApiRoute(
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session),
authorization: {
action: "write",
+ // Multi-key: the session is addressable by URL param, friendlyId,
+ // and externalId β a JWT scoped to any of them grants access.
+ // Type-level `write:sessions` (no id) also matches; `write:all` /
+ // `admin` bypass via the JWT ability's wildcard branches.
resource: (params, _, __, ___, session) => {
const ids = new Set([params.session]);
if (session) {
ids.add(session.friendlyId);
if (session.externalId) ids.add(session.externalId);
}
- return { sessions: [...ids] };
+ return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
- superScopes: ["write:sessions", "write:all", "admin"],
},
},
async ({ authentication, params, body, resource: session }) => {
diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.ts b/apps/webapp/app/routes/api.v1.sessions.$session.ts
index 800ee32b99b..9b6fb339989 100644
--- a/apps/webapp/app/routes/api.v1.sessions.$session.ts
+++ b/apps/webapp/app/routes/api.v1.sessions.$session.ts
@@ -11,6 +11,7 @@ import {
serializeSessionWithFriendlyRunId,
} from "~/services/realtime/sessions.server";
import {
+ anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -29,8 +30,17 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (session) => ({ sessions: [session.friendlyId, session.externalId ?? ""] }),
- superScopes: ["read:sessions", "read:all", "admin"],
+ // Multi-key: a session is addressable by both friendlyId and (when
+ // set) externalId. A JWT scoped to either id grants access; type-
+ // level `read:sessions` (no id) matches both elements; `read:all`
+ // / `admin` bypass via the JWT ability's wildcard branches.
+ resource: (session) =>
+ session.externalId
+ ? anyResource([
+ { type: "sessions", id: session.friendlyId },
+ { type: "sessions", id: session.externalId },
+ ])
+ : { type: "sessions", id: session.friendlyId },
},
},
async ({ resource: session }) => {
@@ -50,8 +60,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "admin",
- resource: (params) => ({ sessions: params.session }),
- superScopes: ["admin:sessions", "admin:all", "admin"],
+ resource: (params) => ({ type: "sessions", id: params.session }),
},
},
async ({ authentication, params, body }) => {
diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts
index 38270fdfc77..65ed8df269b 100644
--- a/apps/webapp/app/routes/api.v1.sessions.ts
+++ b/apps/webapp/app/routes/api.v1.sessions.ts
@@ -20,6 +20,7 @@ import {
import { serializeSession } from "~/services/realtime/sessions.server";
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
import {
+ anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -37,8 +38,21 @@ export const loader = createLoaderApiRoute(
corsStrategy: "all",
authorization: {
action: "read",
- resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
- superScopes: ["read:sessions", "read:all", "admin"],
+ // Multi-key resource preserves the pre-RBAC superScope semantics:
+ // - Per-task scoping via `read:tasks:` matches a task element
+ // - Type-level `read:sessions` (the old superScope) matches the
+ // sessions element (collection-level β no id)
+ // - `read:all` / `admin` bypass via the JWT ability's wildcard branches
+ // The taskIdentifier filter accepts a string or an array; expand to
+ // one resource per task id so any per-task-scoped JWT among them
+ // grants access (the array gets OR semantics).
+ resource: (_, __, searchParams) => {
+ const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? [];
+ return anyResource([
+ ...taskFilter.map((id) => ({ type: "tasks" as const, id })),
+ { type: "sessions" as const },
+ ]);
+ },
},
findResource: async () => 1,
},
@@ -113,21 +127,20 @@ const { action } = createActionApiRoute(
// Per-task scoping via `body.taskIdentifier` (action-route resource
// callbacks receive the parsed body as the 4th arg β see
// `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo`
- // can only create sessions whose `taskIdentifier` is `"foo"`. Broad
- // callers (cli-v3 MCP, customer servers wrapping their own auth)
- // hold the `write:sessions` super-scope and bypass the per-task
- // check entirely.
+ // can only create sessions whose `taskIdentifier` is `"foo"`.
//
- // Note: the auth check is OR across resource types, so listing both
- // `sessions` and `tasks` here would let a `write:sessions`-scoped
- // JWT pass for *any* task β defeating the per-task narrowing. Keep
- // it task-only and let the super-scope path handle session-level
- // wildcard access.
+ // Multi-key resource: pre-RBAC this route had a `superScopes:
+ // ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent
+ // is the `{ type: "sessions" }` element below β a `write:sessions`
+ // JWT (no id) matches it directly, deliberately bypassing the
+ // per-task check exactly as before. `admin` / `write:all` bypass
+ // via the JWT ability's wildcard branches.
action: "write",
- resource: (_params, _searchParams, _headers, body) => ({
- tasks: body.taskIdentifier,
- }),
- superScopes: ["write:sessions", "admin"],
+ resource: (_params, _searchParams, _headers, body) =>
+ anyResource([
+ { type: "tasks", id: body.taskIdentifier },
+ { type: "sessions" },
+ ]),
},
corsStrategy: "all",
},
diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
index 5811fc67709..c069103d368 100644
--- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
+++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
@@ -51,8 +51,7 @@ const { action, loader } = createActionApiRoute(
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "trigger",
- resource: (params) => ({ tasks: params.taskId }),
- superScopes: ["write:tasks", "admin"],
+ resource: (params) => ({ type: "tasks", id: params.taskId }),
},
corsStrategy: "all",
},
diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts
index e6ada1a739c..c1e415404ca 100644
--- a/apps/webapp/app/routes/api.v1.tasks.batch.ts
+++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts
@@ -7,7 +7,10 @@ import {
import { env } from "~/env.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
-import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ createActionApiRoute,
+ everyResource,
+} from "~/services/routeBuilders/apiBuilder.server";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import {
@@ -30,10 +33,17 @@ const { action, loader } = createActionApiRoute(
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "batchTrigger",
- resource: (_, __, ___, body) => ({
- tasks: Array.from(new Set(body.items.map((i) => i.task))),
- }),
- superScopes: ["write:tasks", "admin"],
+ // Each item in the batch is a distinct task β every one must be
+ // authorized, not just any one of them. `everyResource` flips
+ // the auth check to AND semantics so a JWT scoped to taskA can't
+ // submit a batch that also includes taskB / taskC.
+ resource: (_, __, ___, body) =>
+ everyResource(
+ Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({
+ type: "tasks",
+ id,
+ }))
+ ),
},
corsStrategy: "all",
},
diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts
index 133b6bc55fb..4a3e5f960c6 100644
--- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts
+++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts
@@ -23,8 +23,7 @@ const { action, loader } = createActionApiRoute(
allowJWT: true,
authorization: {
action: "write",
- resource: (params) => ({ waitpoints: params.waitpointFriendlyId }),
- superScopes: ["write:waitpoints", "admin"],
+ resource: (params) => ({ type: "waitpoints", id: params.waitpointFriendlyId }),
},
corsStrategy: "all",
},
diff --git a/apps/webapp/app/routes/api.v2.batches.$batchId.ts b/apps/webapp/app/routes/api.v2.batches.$batchId.ts
index c89dbbaf312..218eb433559 100644
--- a/apps/webapp/app/routes/api.v2.batches.$batchId.ts
+++ b/apps/webapp/app/routes/api.v2.batches.$batchId.ts
@@ -25,8 +25,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (batch) => ({ batch: batch.friendlyId }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (batch) => ({ type: "batch", id: batch.friendlyId }),
},
},
async ({ resource: batch }) => {
diff --git a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
index a05af273d8d..a636ca0cc1d 100644
--- a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
+++ b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts
@@ -15,8 +15,7 @@ const { action } = createActionApiRoute(
corsStrategy: "none",
authorization: {
action: "write",
- resource: (params) => ({ runs: params.runParam }),
- superScopes: ["write:runs", "admin"],
+ resource: (params) => ({ type: "runs", id: params.runParam }),
},
findResource: async (params, auth) => {
return $replica.taskRun.findFirst({
diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts
index 8db98b4d343..b7758b1e97f 100644
--- a/apps/webapp/app/routes/api.v2.tasks.batch.ts
+++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts
@@ -9,7 +9,10 @@ import { env } from "~/env.server";
import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.server";
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
-import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ createActionApiRoute,
+ everyResource,
+} from "~/services/routeBuilders/apiBuilder.server";
import {
handleRequestIdempotency,
saveRequestIdempotency,
@@ -32,10 +35,17 @@ const { action, loader } = createActionApiRoute(
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "batchTrigger",
- resource: (_, __, ___, body) => ({
- tasks: Array.from(new Set(body.items.map((i) => i.task))),
- }),
- superScopes: ["write:tasks", "admin"],
+ // Each item in the batch is a distinct task β every one must be
+ // authorized, not just any one of them. `everyResource` flips
+ // the auth check to AND semantics so a JWT scoped to taskA can't
+ // submit a batch that also includes taskB / taskC.
+ resource: (_, __, ___, body) =>
+ everyResource(
+ Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({
+ type: "tasks",
+ id,
+ }))
+ ),
},
corsStrategy: "all",
},
diff --git a/apps/webapp/app/routes/api.v3.batches.ts b/apps/webapp/app/routes/api.v3.batches.ts
index 5067eaef06e..a5bb2047bde 100644
--- a/apps/webapp/app/routes/api.v3.batches.ts
+++ b/apps/webapp/app/routes/api.v3.batches.ts
@@ -35,12 +35,9 @@ const { action, loader } = createActionApiRoute(
maxContentLength: 131_072, // 128KB is plenty for the batch metadata
authorization: {
action: "batchTrigger",
- resource: () => ({
- // No specific tasks to authorize at batch creation time
- // Tasks are validated when items are streamed
- tasks: [],
- }),
- superScopes: ["write:tasks", "admin"],
+ // No specific tasks to authorize at batch creation time β tasks are
+ // validated when items are streamed. Collection-level check.
+ resource: () => ({ type: "tasks" }),
},
corsStrategy: "all",
},
diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts
index de40a9a9120..00ea7102580 100644
--- a/apps/webapp/app/routes/api.v3.runs.$runId.ts
+++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts
@@ -1,7 +1,10 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -18,13 +21,17 @@ export const loader = createLoaderApiRoute(
shouldRetryNotFound: true,
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batch?.friendlyId,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batch?.friendlyId) {
+ resources.push({ type: "batch", id: run.batch.friendlyId });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ authentication, resource, apiVersion }) => {
diff --git a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts
index 33449deebca..96376b8850c 100644
--- a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts
+++ b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts
@@ -23,8 +23,7 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (batch) => ({ batch: batch.friendlyId }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (batch) => ({ type: "batch", id: batch.friendlyId }),
},
},
async ({ authentication, request, resource: batchRun, apiVersion }) => {
diff --git a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts
index 060f937b0eb..e03787c6200 100644
--- a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts
+++ b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts
@@ -3,7 +3,10 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -31,13 +34,17 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batch?.friendlyId,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batch?.friendlyId) {
+ resources.push({ type: "batch", id: run.batch.friendlyId });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ authentication, request, resource: run, apiVersion }) => {
diff --git a/apps/webapp/app/routes/realtime.v1.runs.ts b/apps/webapp/app/routes/realtime.v1.runs.ts
index 18eeeb0a075..c829ca89d6d 100644
--- a/apps/webapp/app/routes/realtime.v1.runs.ts
+++ b/apps/webapp/app/routes/realtime.v1.runs.ts
@@ -1,7 +1,10 @@
import { z } from "zod";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
const SearchParamsSchema = z.object({
tags: z
@@ -21,8 +24,11 @@ export const loader = createLoaderApiRoute(
findResource: async () => 1, // This is a dummy value, it's not used
authorization: {
action: "read",
- resource: (_, __, searchParams) => searchParams,
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (_, __, searchParams) =>
+ anyResource([
+ { type: "runs" },
+ ...(searchParams.tags ?? []).map((tag) => ({ type: "tags", id: tag })),
+ ]),
},
},
async ({ searchParams, authentication, request, apiVersion }) => {
diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts
index 4251baae91e..18813c0561f 100644
--- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts
+++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts
@@ -12,7 +12,10 @@ import {
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
-import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createActionApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
import { engine } from "~/v3/runEngine.server";
import { ServiceValidationError } from "~/v3/services/common.server";
@@ -49,15 +52,16 @@ const { action, loader } = createActionApiRoute(
action: "write",
// Authorize against the union of the URL form, friendlyId, and
// externalId so a JWT scoped to any form authorizes any URL.
+ // Type-level `write:sessions` (no id) also matches; `write:all` /
+ // `admin` bypass via the JWT ability's wildcard branches.
resource: (params, _, __, ___, session) => {
const ids = new Set([params.session]);
if (session) {
ids.add(session.friendlyId);
if (session.externalId) ids.add(session.externalId);
}
- return { sessions: [...ids] };
+ return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
- superScopes: ["write:sessions", "write:all", "admin"],
},
},
async ({ request, params, authentication, resource: session }) => {
diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts
index c04992f7f14..73b10a5c89d 100644
--- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts
+++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts
@@ -10,6 +10,7 @@ import {
} from "~/services/realtime/sessions.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
import {
+ anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -30,8 +31,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "write",
- resource: (params) => ({ sessions: params.session }),
- superScopes: ["write:sessions", "write:all", "admin"],
+ resource: (params) => ({ type: "sessions", id: params.session }),
},
},
async ({ params, authentication }) => {
@@ -110,15 +110,18 @@ const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
+ // Multi-key: the channel is addressable by the URL key, the row's
+ // friendlyId, and (if set) externalId. Type-level `read:sessions`
+ // matches any of them; `read:all` / `admin` bypass via the JWT
+ // ability's wildcard branches.
resource: ({ row, addressingKey }) => {
const ids = new Set([addressingKey]);
if (row) {
ids.add(row.friendlyId);
if (row.externalId) ids.add(row.externalId);
}
- return { sessions: [...ids] };
+ return anyResource([...ids].map((id) => ({ type: "sessions", id })));
},
- superScopes: ["read:sessions", "read:all", "admin"],
},
},
async ({ params, request, authentication, resource }) => {
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 aabd83bc9bb..a57afcef56e 100644
--- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
+++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts
@@ -3,7 +3,10 @@ import { z } from "zod";
import { $replica } from "~/db.server";
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
-import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
+import {
+ anyResource,
+ createLoaderApiRoute,
+} from "~/services/routeBuilders/apiBuilder.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
const ParamsSchema = z.object({
@@ -86,7 +89,12 @@ export const loader = createLoaderApiRoute(
friendlyId: params.runId,
runtimeEnvironmentId: auth.environment.id,
},
- include: {
+ select: {
+ id: true,
+ friendlyId: true,
+ taskIdentifier: true,
+ runTags: true,
+ realtimeStreamsVersion: true,
batch: {
select: {
friendlyId: true,
@@ -97,13 +105,17 @@ export const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batch?.friendlyId,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batch?.friendlyId) {
+ resources.push({ type: "batch", id: run.batch.friendlyId });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ params, request, resource: run, authentication }) => {
diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts
index b16b1ca7922..6577c2b8686 100644
--- a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts
+++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts
@@ -7,6 +7,7 @@ import {
deleteInputStreamWaitpoint,
} from "~/services/inputStreamWaitpointCache.server";
import {
+ anyResource,
createActionApiRoute,
createLoaderApiRoute,
} from "~/services/routeBuilders/apiBuilder.server";
@@ -31,8 +32,7 @@ const { action } = createActionApiRoute(
corsStrategy: "all",
authorization: {
action: "write",
- resource: (params) => ({ inputStreams: params.runId }),
- superScopes: ["write:inputStreams", "write:all", "admin"],
+ resource: (params) => ({ type: "inputStreams", id: params.runId }),
},
},
async ({ request, params, authentication }) => {
@@ -125,13 +125,17 @@ const loader = createLoaderApiRoute(
},
authorization: {
action: "read",
- resource: (run) => ({
- runs: run.friendlyId,
- tags: run.runTags,
- batch: run.batch?.friendlyId,
- tasks: run.taskIdentifier,
- }),
- superScopes: ["read:runs", "read:all", "admin"],
+ resource: (run) => {
+ const resources = [
+ { type: "runs", id: run.friendlyId },
+ { type: "tasks", id: run.taskIdentifier },
+ ...run.runTags.map((tag) => ({ type: "tags", id: tag })),
+ ];
+ if (run.batch?.friendlyId) {
+ resources.push({ type: "batch", id: run.batch.friendlyId });
+ }
+ return anyResource(resources);
+ },
},
},
async ({ params, request, resource: run, authentication }) => {
diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts
index e781cdfeb7a..f985ab6f61e 100644
--- a/apps/webapp/app/services/personalAccessToken.server.ts
+++ b/apps/webapp/app/services/personalAccessToken.server.ts
@@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";
+import { rbac } from "./rbac.server";
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
import { env } from "~/env.server";
@@ -16,9 +17,26 @@ const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", toke
// staleness is fine.
export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
+// The OSS fallback's setTokenRole returns this exact string when no
+// enterprise plugin is loaded. We treat that as "no role attached" β
+// the PAT is still valid; auth just falls through to legacy permissive
+// behaviour. Any other error is treated as a real failure and triggers
+// the compensating delete below.
+// Must match the OSS fallback's exact error string (see
+// `internal-packages/rbac/src/fallback.ts`'s `setTokenRole`). The
+// match is how we detect "no plugin installed" and skip the
+// compensating delete.
+const FALLBACK_NOT_INSTALLED_ERROR = "RBAC plugin not installed";
+
type CreatePersonalAccessTokenOptions = {
name: string;
userId: string;
+ // Optional: when provided, persist a TokenRole row alongside the PAT
+ // so PAT-authenticated requests pick up that role's permissions
+ // (TRI-8749). The dashboard tokens page passes a chosen system role;
+ // the CLI auth-code path doesn't pass one (legacy behaviour
+ // preserved β those PATs run with no explicit role).
+ roleId?: string;
};
/** Returns obfuscated access tokens that aren't revoked */
@@ -338,6 +356,7 @@ export async function createPersonalAccessTokenFromAuthorizationCode(
export async function createPersonalAccessToken({
name,
userId,
+ roleId,
}: CreatePersonalAccessTokenOptions) {
const token = createToken();
const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY);
@@ -352,6 +371,45 @@ export async function createPersonalAccessToken({
},
});
+ // Persist the role choice via the RBAC plugin's setTokenRole. The
+ // plugin may store this in a separate datastore from Prisma (e.g.
+ // Drizzle on a different schema), so co-transactional inserts are
+ // awkward β we use a compensating-delete pattern instead: if
+ // setTokenRole fails, roll back the PAT row by deleting it. The auth
+ // path treats "no role" as permissive (matches the default fallback)
+ // so a brief orphan window between the two writes is harmless. The
+ // compensating delete narrows that window from "until manual cleanup"
+ // to "until the request returns".
+ if (roleId) {
+ const roleResult = await rbac.setTokenRole({
+ tokenId: personalAccessToken.id,
+ roleId,
+ });
+ if (!roleResult.ok) {
+ // The default fallback always returns ok=false with this exact
+ // message. That isn't a failure β there's no plugin to write to,
+ // so the PAT just runs without an explicit role (matches the
+ // pre-RBAC behaviour). Don't compensating-delete in that case.
+ if (roleResult.error === FALLBACK_NOT_INSTALLED_ERROR) {
+ logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", {
+ patId: personalAccessToken.id,
+ userId,
+ });
+ } else {
+ await prisma.personalAccessToken
+ .delete({ where: { id: personalAccessToken.id } })
+ .catch((err) => {
+ logger.error("Failed to compensating-delete PAT after TokenRole insert failed", {
+ patId: personalAccessToken.id,
+ roleResultError: roleResult.error,
+ deleteError: err instanceof Error ? err.message : String(err),
+ });
+ });
+ throw new Error(`Failed to assign role to access token: ${roleResult.error}`);
+ }
+ }
+ }
+
return {
id: personalAccessToken.id,
name,
diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts
index 51075c1b87d..3b3e7210451 100644
--- a/apps/webapp/app/services/platform.v3.server.ts
+++ b/apps/webapp/app/services/platform.v3.server.ts
@@ -1,5 +1,5 @@
import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3";
-import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database";
+import type { RuntimeEnvironmentType } from "@trigger.dev/database";
import {
BillingClient,
defaultMachine as defaultMachineFromPlatform,
@@ -25,7 +25,6 @@ import { redirect } from "remix-typedjson";
import { z } from "zod";
import { env } from "~/env.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
-import { createEnvironment } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
import { singleton } from "~/utils/singleton";
@@ -587,33 +586,6 @@ export async function getEntitlement(
return result.val;
}
-export async function projectCreated(
- organization: Pick,
- project: Project
-) {
- if (!isCloud()) {
- await createEnvironment({ organization, project, type: "STAGING" });
- await createEnvironment({
- organization,
- project,
- type: "PREVIEW",
- isBranchableEnvironment: true,
- });
- } else {
- //staging is only available on certain plans
- const plan = await getCurrentPlan(organization.id);
- if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) {
- await createEnvironment({ organization, project, type: "STAGING" });
- await createEnvironment({
- organization,
- project,
- type: "PREVIEW",
- isBranchableEnvironment: true,
- });
- }
- }
-}
-
export async function getBillingAlerts(
organizationId: string
): Promise {
@@ -778,7 +750,7 @@ export async function triggerInitialDeployment(
}
}
-function isCloud(): boolean {
+export function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
"https://test-cloud.trigger.dev",
diff --git a/apps/webapp/app/services/projectCreated.server.ts b/apps/webapp/app/services/projectCreated.server.ts
new file mode 100644
index 00000000000..f845af52033
--- /dev/null
+++ b/apps/webapp/app/services/projectCreated.server.ts
@@ -0,0 +1,35 @@
+import type { Organization, Project } from "@trigger.dev/database";
+import { createEnvironment } from "~/models/organization.server";
+import { getCurrentPlan, isCloud } from "~/services/platform.v3.server";
+
+// Extracted from platform.v3.server.ts to break a circular import:
+// platform.v3.server β models/organization.server (via createEnvironment).
+// The cycle caused the bundled __esm wrappers to re-enter and short-circuit
+// the platform.v3.server init, leaving `defaultMachine` and `machines`
+// undefined in `singleton("machinePresets", ...)` β the boot crash at
+// `allMachines()` traced to TRI-8731.
+export async function projectCreated(
+ organization: Pick,
+ project: Project
+) {
+ if (!isCloud()) {
+ await createEnvironment({ organization, project, type: "STAGING" });
+ await createEnvironment({
+ organization,
+ project,
+ type: "PREVIEW",
+ isBranchableEnvironment: true,
+ });
+ } else {
+ const plan = await getCurrentPlan(organization.id);
+ if (plan?.v3Subscription?.plan?.limits?.hasStagingEnvironment) {
+ await createEnvironment({ organization, project, type: "STAGING" });
+ await createEnvironment({
+ organization,
+ project,
+ type: "PREVIEW",
+ isBranchableEnvironment: true,
+ });
+ }
+ }
+}
diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts
new file mode 100644
index 00000000000..6004a03eeb5
--- /dev/null
+++ b/apps/webapp/app/services/rbac.server.ts
@@ -0,0 +1,18 @@
+import { prisma } from "~/db.server";
+import plugin from "@trigger.dev/rbac";
+import { env } from "~/env.server";
+import { getUserId } from "./session.server";
+
+async function getSessionUserId(request: Request): Promise {
+ const id = await getUserId(request);
+ return id ?? null;
+}
+
+// plugin.create() is synchronous β returns a lazy controller that resolves
+// any installed RBAC plugin on first call. Top-level await is not used
+// because CJS output format does not support it.
+export const rbac = plugin.create(
+ prisma,
+ { getSessionUserId },
+ { forceFallback: env.RBAC_FORCE_FALLBACK }
+);
diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
index 9e439938d0d..d51540cdf36 100644
--- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
+++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
@@ -1,17 +1,12 @@
import { z } from "zod";
-import {
- ApiAuthenticationResultSuccess,
- authenticateApiRequestWithFailure,
-} from "../apiAuth.server";
+import { ApiAuthenticationResultSuccess } from "../apiAuth.server";
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
-import {
- AuthorizationAction,
- AuthorizationResources,
- checkAuthorization,
-} from "../authorization.server";
import { logger } from "../logger.server";
+import { rbac } from "../rbac.server";
+import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
+import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
@@ -50,8 +45,127 @@ function logBoundaryError(
}
}
+// Bridges the RBAC plugin (source of truth for auth + abilities) to the legacy
+// ApiAuthenticationResultSuccess shape route handlers still expect. All three
+// apiBuilder call sites funnel through this helper β no handler-level changes
+// needed.
+async function authenticateRequestForApiBuilder(
+ request: Request,
+ { allowJWT }: { allowJWT: boolean }
+): Promise<
+ | { ok: false; status: 401; error: string }
+ | { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility }
+> {
+ const result = await rbac.authenticateBearer(request, { allowJWT });
+ if (!result.ok) {
+ return { ok: false, status: 401, error: result.error };
+ }
+
+ // The fallback already filters deleted projects; this is belt-and-braces for
+ // any race between auth and the follow-up lookup, and fills in the full
+ // Prisma-shaped AuthenticatedEnvironment that handlers read from.
+ const environment = await findEnvironmentById(result.environment.id);
+ if (!environment) {
+ return { ok: false, status: 401, error: "Invalid API key" };
+ }
+
+ const authentication: ApiAuthenticationResultSuccess = {
+ ok: true,
+ apiKey: result.environment.apiKey,
+ type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE",
+ environment,
+ realtime: result.jwt?.realtime,
+ oneTimeUse: result.jwt?.oneTimeUse,
+ };
+
+ return { ok: true, authentication, ability: result.ability };
+}
+
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion;
+// Sentinel ability for routes that don't opt into the cap-and-floor PAT
+// model β preserves pre-RBAC behaviour where PATs were pure user-identity
+// tokens. New routes that want gated PAT auth declare a `context` and
+// `authorization` block; the actual ability comes from `rbac.authenticatePat`.
+const PERMISSIVE_ABILITY: RbacAbility = {
+ can: () => true,
+ canSuper: () => false,
+};
+
+// A multi-resource auth check has two possible directions, and route authors
+// have to pick one explicitly:
+//
+// - `anyResource(...)` β succeed if *any* element passes. Used when a single
+// record carries multiple identifiers (a run is addressable by friendlyId /
+// batch / tags / task) so a JWT scoped to *any* of them grants access.
+//
+// - `everyResource(...)` β succeed only if *every* element passes. Used for
+// batch operations where each element is a *distinct* resource and a JWT
+// scoped to one element must not authorize the others.
+//
+// Bare `RbacResource[]` is intentionally *not* part of `AuthResource` β the
+// type system forces every multi-resource site to disambiguate. The original
+// pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for
+// "broader-than-this-resource" access; post-RBAC that's expressed via the JWT
+// ability's wildcard branches (`*:all` and `admin*` β see
+// `internal-packages/rbac/src/ability.ts`) plus a collection-level shape
+// `{ type: "" }` (no id) in the `anyResource` array so a
+// `:` JWT matches it. No code knob needed.
+//
+// Markers are Symbols so they can't collide with arbitrary RbacResource fields.
+const ANY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.anyResource");
+const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource");
+
+type AnyResourceAuth = {
+ readonly [ANY_RESOURCE_MARKER]: true;
+ readonly resources: readonly RbacResource[];
+};
+
+type EveryResourceAuth = {
+ readonly [EVERY_RESOURCE_MARKER]: true;
+ readonly resources: readonly RbacResource[];
+};
+
+export function anyResource(resources: RbacResource[]): AnyResourceAuth {
+ return { [ANY_RESOURCE_MARKER]: true, resources };
+}
+
+export function everyResource(resources: RbacResource[]): EveryResourceAuth {
+ return { [EVERY_RESOURCE_MARKER]: true, resources };
+}
+
+function isAnyResource(value: unknown): value is AnyResourceAuth {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ (value as Record)[ANY_RESOURCE_MARKER] === true
+ );
+}
+
+function isEveryResource(value: unknown): value is EveryResourceAuth {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ (value as Record)[EVERY_RESOURCE_MARKER] === true
+ );
+}
+
+type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth;
+
+function checkAuth(
+ ability: RbacAbility,
+ action: string,
+ resource: AuthResource
+): boolean {
+ if (isEveryResource(resource)) {
+ return resource.resources.every((r) => ability.can(action, r));
+ }
+ if (isAnyResource(resource)) {
+ return ability.can(action, [...resource.resources]);
+ }
+ return ability.can(action, resource);
+}
+
type ApiKeyRouteBuilderOptions<
TParamsSchema extends AnyZodSchema | undefined = undefined,
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
@@ -76,7 +190,7 @@ type ApiKeyRouteBuilderOptions<
) => Promise;
shouldRetryNotFound?: boolean;
authorization?: {
- action: AuthorizationAction;
+ action: string;
resource: (
resource: NonNullable,
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
@@ -90,8 +204,7 @@ type ApiKeyRouteBuilderOptions<
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
? z.infer
: undefined
- ) => AuthorizationResources;
- superScopes?: string[];
+ ) => AuthResource;
};
};
@@ -144,23 +257,15 @@ export function createLoaderApiRoute<
}
try {
- const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
-
- if (!authenticationResult) {
+ const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
+ if (!authResult.ok) {
return await wrapResponse(
request,
- json({ error: "Invalid or Missing API key" }, { status: 401 }),
- corsStrategy !== "none"
- );
- }
-
- if (!authenticationResult.ok) {
- return await wrapResponse(
- request,
- json({ error: authenticationResult.error }, { status: 401 }),
+ json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
+ const { authentication: authenticationResult, ability } = authResult;
let parsedParams: any = undefined;
if (paramsSchema) {
@@ -227,7 +332,7 @@ export function createLoaderApiRoute<
}
if (authorization) {
- const { action, resource: authResource, superScopes } = authorization;
+ const { action, resource: authResource } = authorization;
const $authResource = authResource(
resource,
parsedParams,
@@ -235,26 +340,12 @@ export function createLoaderApiRoute<
parsedHeaders
);
- logger.debug("Checking authorization", {
- action,
- resource: $authResource,
- superScopes,
- scopes: authenticationResult.scopes,
- });
-
- const authorizationResult = checkAuthorization(
- authenticationResult,
- action,
- $authResource,
- superScopes
- );
-
- if (!authorizationResult.authorized) {
+ if (!checkAuth(ability, action, $authResource)) {
return await wrapResponse(
request,
json(
{
- error: `Unauthorized: ${authorizationResult.reason}`,
+ error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
@@ -309,6 +400,37 @@ type PATRouteBuilderOptions<
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
corsStrategy?: "all" | "none";
+ // Resolves the target org/project for the request. Fed to
+ // `rbac.authenticatePat` so the plugin can compute the user's role
+ // floor (their authority in that org) for the cap intersection.
+ // When omitted, the PAT runs in identity-only mode β no role floor,
+ // no per-route ability gating beyond what authorization (if any)
+ // declares against a permissive baseline. Routes added before TRI-9087
+ // run in this mode by default.
+ context?: (
+ params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ request: Request
+ ) =>
+ | { organizationId?: string; projectId?: string }
+ | Promise<{ organizationId?: string; projectId?: string }>;
+ authorization?: {
+ action: string;
+ resource: (
+ params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ searchParams: TSearchParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined,
+ headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined
+ ) => AuthResource;
+ };
};
type PATHandlerFunction<
@@ -328,6 +450,7 @@ type PATHandlerFunction<
? z.infer
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
+ ability: RbacAbility;
request: Request;
apiVersion: API_VERSIONS;
}) => Promise;
@@ -346,6 +469,8 @@ export function createLoaderPATApiRoute<
searchParams: searchParamsSchema,
headers: headersSchema,
corsStrategy = "none",
+ context: contextFn,
+ authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
@@ -415,11 +540,53 @@ export function createLoaderPATApiRoute<
const apiVersion = getApiVersion(request);
+ // Resolve ability via the rbac plugin. When neither `context` nor
+ // `authorization` is declared, the legacy permissive ability stands
+ // in β preserves the pre-RBAC PAT behaviour for routes that
+ // haven't opted into the cap-and-floor model yet.
+ let ability: RbacAbility = PERMISSIVE_ABILITY;
+ if (contextFn || authorization) {
+ const ctx = contextFn ? await contextFn(parsedParams, request) : {};
+ const patAuth = await rbac.authenticatePat(request, ctx);
+ if (!patAuth.ok) {
+ return await wrapResponse(
+ request,
+ json({ error: patAuth.error }, { status: patAuth.status }),
+ corsStrategy !== "none"
+ );
+ }
+ ability = patAuth.ability;
+
+ if (authorization) {
+ const $resource = authorization.resource(
+ parsedParams,
+ parsedSearchParams,
+ parsedHeaders
+ );
+ if (!checkAuth(ability, authorization.action, $resource)) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Unauthorized",
+ code: "unauthorized",
+ param: "access_token",
+ type: "authorization",
+ },
+ { status: 403 }
+ ),
+ corsStrategy !== "none"
+ );
+ }
+ }
+ }
+
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
+ ability,
request,
apiVersion,
});
@@ -468,7 +635,7 @@ type ApiKeyActionRouteBuilderOptions<
: undefined
) => Promise;
authorization?: {
- action: AuthorizationAction;
+ action: string;
resource: (
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
? z.infer
@@ -490,8 +657,7 @@ type ApiKeyActionRouteBuilderOptions<
// externalId for sessions) read it here so a JWT minted for either form
// authorizes both URL forms.
resource: TResource | undefined
- ) => AuthorizationResources;
- superScopes?: string[];
+ ) => AuthResource;
};
maxContentLength?: number;
body?: TBodySchema;
@@ -579,23 +745,15 @@ export function createActionApiRoute<
}
try {
- const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
-
- if (!authenticationResult) {
- return await wrapResponse(
- request,
- json({ error: "Invalid or Missing API key" }, { status: 401 }),
- corsStrategy !== "none"
- );
- }
-
- if (!authenticationResult.ok) {
+ const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
+ if (!authResult.ok) {
return await wrapResponse(
request,
- json({ error: authenticationResult.error }, { status: 401 }),
+ json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
+ const { authentication: authenticationResult, ability } = authResult;
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
@@ -706,7 +864,7 @@ export function createActionApiRoute<
// - PRIVATE key + missing resource β auth passes β 404 (correct)
// - PRIVATE key + existing resource β auth passes β handler runs
if (authorization) {
- const { action, resource: authResource, superScopes } = authorization;
+ const { action, resource: authResource } = authorization;
const $resource = authResource(
parsedParams,
parsedSearchParams,
@@ -715,26 +873,12 @@ export function createActionApiRoute<
resource
);
- logger.debug("Checking authorization", {
- action,
- resource: $resource,
- superScopes,
- scopes: authenticationResult.scopes,
- });
-
- const authorizationResult = checkAuthorization(
- authenticationResult,
- action,
- $resource,
- superScopes
- );
-
- if (!authorizationResult.authorized) {
+ if (!checkAuth(ability, action, $resource)) {
return await wrapResponse(
request,
json(
{
- error: `Unauthorized: ${authorizationResult.reason}`,
+ error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
@@ -825,9 +969,8 @@ type MultiMethodApiRouteOptions<
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
- action: AuthorizationAction;
- resource: (params: InferZod) => AuthorizationResources;
- superScopes?: string[];
+ action: string;
+ resource: (params: InferZod) => AuthResource;
};
maxContentLength?: number;
methods: Partial<
@@ -872,33 +1015,22 @@ export function createMultiMethodApiRoute<
if (!methodConfig) {
return await wrapResponse(
request,
- json(
- { error: "Method not allowed" },
- { status: 405, headers: { Allow: allowedMethods } }
- ),
+ json({ error: "Method not allowed" }, { status: 405, headers: { Allow: allowedMethods } }),
corsStrategy !== "none"
);
}
try {
// Authenticate
- const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
-
- if (!authenticationResult) {
+ const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
+ if (!authResult.ok) {
return await wrapResponse(
request,
- json({ error: "Invalid or Missing API key" }, { status: 401 }),
- corsStrategy !== "none"
- );
- }
-
- if (!authenticationResult.ok) {
- return await wrapResponse(
- request,
- json({ error: authenticationResult.error }, { status: 401 }),
+ json({ error: authResult.error }, { status: authResult.status }),
corsStrategy !== "none"
);
}
+ const { authentication: authenticationResult, ability } = authResult;
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
@@ -966,29 +1098,15 @@ export function createMultiMethodApiRoute<
// Authorize
if (authorization) {
- const { action, resource, superScopes } = authorization;
+ const { action, resource } = authorization;
const $resource = resource(parsedParams);
- logger.debug("Checking authorization", {
- action,
- resource: $resource,
- superScopes,
- scopes: authenticationResult.scopes,
- });
-
- const authorizationResult = checkAuthorization(
- authenticationResult,
- action,
- $resource,
- superScopes
- );
-
- if (!authorizationResult.authorized) {
+ if (!checkAuth(ability, action, $resource)) {
return await wrapResponse(
request,
json(
{
- error: `Unauthorized: ${authorizationResult.reason}`,
+ error: "Unauthorized",
code: "unauthorized",
param: "access_token",
type: "authorization",
diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts
new file mode 100644
index 00000000000..394d0c54177
--- /dev/null
+++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts
@@ -0,0 +1,108 @@
+// Server-only impl backing dashboardBuilder.ts. Imports rbac.server and
+// runs the actual auth/authorization. The wrappers in dashboardBuilder.ts
+// dynamic-import this module from inside the loader/action body, so it
+// never reaches the client bundle.
+
+import { json, redirect } from "@remix-run/server-runtime";
+import type { RbacAbility } from "@trigger.dev/rbac";
+import { rbac } from "~/services/rbac.server";
+import type {
+ AuthorizationOption,
+ DashboardLoaderOptions,
+ SessionUser,
+} from "./dashboardBuilder";
+import { fromZodError } from "zod-validation-error";
+import type { z } from "zod";
+
+type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion;
+
+function loginRedirectFor(request: Request, override?: string): Response {
+ if (override) return redirect(override);
+ const url = new URL(request.url);
+ const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
+ return redirect(`/login?redirectTo=${redirectTo}`);
+}
+
+function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean {
+ if ("requireSuper" in authorization) {
+ return ability.canSuper();
+ }
+ return ability.can(authorization.action, authorization.resource);
+}
+
+type AuthScope = { organizationId?: string; projectId?: string };
+
+export async function authenticateAndAuthorize<
+ TParams,
+ TSearchParams,
+ TContext extends AuthScope
+>(
+ request: Request,
+ rawParams: unknown,
+ options: DashboardLoaderOptions
+): Promise<
+ | { ok: false; response: Response }
+ | {
+ ok: true;
+ user: SessionUser;
+ ability: RbacAbility;
+ params: unknown;
+ searchParams: unknown;
+ context: TContext;
+ }
+> {
+ let parsedParams: any = undefined;
+ if (options.params) {
+ const parsed = (options.params as unknown as AnyZodSchema).safeParse(rawParams);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ response: json(
+ { error: "Params Error", details: fromZodError(parsed.error).details },
+ { status: 400 }
+ ),
+ };
+ }
+ parsedParams = parsed.data;
+ }
+
+ let parsedSearchParams: any = undefined;
+ if (options.searchParams) {
+ const fromUrl = Object.fromEntries(new URL(request.url).searchParams);
+ const parsed = (options.searchParams as unknown as AnyZodSchema).safeParse(fromUrl);
+ if (!parsed.success) {
+ return {
+ ok: false,
+ response: json(
+ { error: "Query Error", details: fromZodError(parsed.error).details },
+ { status: 400 }
+ ),
+ };
+ }
+ parsedSearchParams = parsed.data;
+ }
+
+ const ctx = (options.context
+ ? await options.context(parsedParams, request)
+ : ({} as TContext)) as TContext;
+ const auth = await rbac.authenticateSession(request, ctx);
+ if (!auth.ok) {
+ if (auth.reason === "unauthenticated") {
+ return { ok: false, response: loginRedirectFor(request, options.loginRedirect) };
+ }
+ return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
+ }
+
+ if (options.authorization && !isAuthorized(auth.ability, options.authorization)) {
+ return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
+ }
+
+ return {
+ ok: true,
+ user: auth.user,
+ ability: auth.ability,
+ params: parsedParams,
+ searchParams: parsedSearchParams,
+ context: ctx,
+ };
+}
diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts
new file mode 100644
index 00000000000..673f4f78deb
--- /dev/null
+++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts
@@ -0,0 +1,141 @@
+// Client-safe shim for the dashboard route builder. The actual server
+// implementation lives in dashboardBuilder.server.ts; the wrappers here
+// just return closures that lazily import that impl on first invocation.
+//
+// Why split: routes use `export const loader = dashboardLoader(...)` at
+// module top-level. Remix's dev build preserves the top-level call when
+// resolving the loader export, so the import target needs to exist on
+// the client even though the closure body never executes there. A
+// `.server.ts` file is excluded from the client bundle, which would
+// resolve `dashboardLoader` to undefined and crash with
+// "dashboardLoader is not a function" on first navigation. Keeping this
+// file non-`.server` puts the wrappers in the client bundle as
+// effectively no-op closures (they're never called there), and the
+// closure body's dynamic import only resolves at server runtime.
+
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
+import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
+import type { z } from "zod";
+
+type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion;
+
+type InferZod = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+
+export type SessionUser = {
+ id: string;
+ email: string;
+ name: string | null;
+ displayName: string | null;
+ avatarUrl: string | null;
+ admin: boolean;
+ confirmedBasicDetails: boolean;
+ isImpersonating: boolean;
+};
+
+// `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit
+// action + resource pair is checked via ability.can(...).
+export type AuthorizationOption =
+ | { requireSuper: true }
+ | {
+ action: string;
+ resource: RbacResource | RbacResource[];
+ };
+
+// Plugin-side scope: whatever the route's `context` returns must include
+// these (or just be `{}` when the route doesn't scope by org/project).
+// rbac.authenticateSession reads them off the value to filter UserRole.
+type AuthScope = { organizationId?: string; projectId?: string };
+
+export type DashboardLoaderOptions = {
+ params?: TParams;
+ searchParams?: TSearchParams;
+ // Resolves any per-request data the handler + auth check both need
+ // (typically org/project lookups from URL params). The returned object
+ // is fed to `rbac.authenticateSession` as the auth scope AND passed
+ // through to the handler in `args.context`, so the route does each
+ // lookup once.
+ context?: (
+ params: InferZod,
+ request: Request
+ ) => TContext | Promise;
+ authorization?: AuthorizationOption;
+ // Where to send unauthenticated requests. Defaults to /login with a
+ // redirectTo back to the original path.
+ loginRedirect?: string;
+ // Where to send users who pass auth but fail the ability check. Defaults
+ // to "/" (the home page).
+ unauthorizedRedirect?: string;
+};
+
+export type DashboardLoaderHandlerArgs = {
+ params: InferZod;
+ searchParams: InferZod;
+ user: SessionUser;
+ ability: RbacAbility;
+ context: TContext;
+ request: Request;
+};
+
+export function dashboardLoader<
+ TParams extends AnyZodSchema | undefined = undefined,
+ TSearchParams extends AnyZodSchema | undefined = undefined,
+ TContext extends AuthScope = AuthScope,
+ TReturn extends Response = Response
+>(
+ options: DashboardLoaderOptions,
+ handler: (
+ args: DashboardLoaderHandlerArgs
+ ) => Promise
+) {
+ return async function loader({ request, params }: LoaderFunctionArgs): Promise {
+ // Server-only β see comment at top. Node caches the module after the
+ // first call, so the dynamic import is effectively free past warmup.
+ const { authenticateAndAuthorize } = await import("./dashboardBuilder.server");
+ const result = await authenticateAndAuthorize(request, params, options);
+ if (!result.ok) throw result.response;
+
+ return handler({
+ params: result.params as InferZod,
+ searchParams: result.searchParams as InferZod,
+ user: result.user,
+ ability: result.ability,
+ context: result.context as TContext,
+ request,
+ });
+ };
+}
+
+export type DashboardActionOptions =
+ DashboardLoaderOptions;
+
+export type DashboardActionHandlerArgs =
+ DashboardLoaderHandlerArgs;
+
+export function dashboardAction<
+ TParams extends AnyZodSchema | undefined = undefined,
+ TSearchParams extends AnyZodSchema | undefined = undefined,
+ TContext extends AuthScope = AuthScope,
+ TReturn extends Response = Response
+>(
+ options: DashboardActionOptions,
+ handler: (
+ args: DashboardActionHandlerArgs
+ ) => Promise
+) {
+ return async function action({ request, params }: ActionFunctionArgs): Promise {
+ const { authenticateAndAuthorize } = await import("./dashboardBuilder.server");
+ const result = await authenticateAndAuthorize(request, params, options);
+ if (!result.ok) throw result.response;
+
+ return handler({
+ params: result.params as InferZod,
+ searchParams: result.searchParams as InferZod,
+ user: result.user,
+ ability: result.ability,
+ context: result.context as TContext,
+ request,
+ });
+ };
+}
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index 7a151053f5a..8f94b302ef7 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -114,6 +114,10 @@ export function organizationTeamPath(organization: OrgForPath) {
return `${organizationPath(organization)}/settings/team`;
}
+export function organizationRolesPath(organization: OrgForPath) {
+ return `${organizationPath(organization)}/settings/roles`;
+}
+
export function inviteTeamMemberPath(organization: OrgForPath) {
return `${organizationPath(organization)}/invite`;
}
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 0880eb71037..94cd8beef64 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -124,6 +124,7 @@
"@trigger.dev/companyicons": "^1.5.35",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
+ "@trigger.dev/rbac": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.27",
"@trigger.dev/redis-worker": "workspace:*",
diff --git a/apps/webapp/test/README.md b/apps/webapp/test/README.md
new file mode 100644
index 00000000000..d1c2a418b39
--- /dev/null
+++ b/apps/webapp/test/README.md
@@ -0,0 +1,65 @@
+# Webapp tests
+
+Three suites live in this directory.
+
+## Unit tests β `*.test.ts`
+
+Run with `pnpm test` from `apps/webapp`. Default vitest pickup. No
+container setup. Run on every PR via `unit-tests-webapp.yml`.
+
+## Smoke e2e β `*.e2e.test.ts`
+
+End-to-end auth baseline that proves the route auth plumbing is wired up.
+Each file spins up its own webapp + Postgres + Redis container in
+`beforeAll` (~30s startup). Vitest config: `vitest.e2e.config.ts`. Run on
+every PR via `e2e-webapp.yml`.
+
+```bash
+cd apps/webapp
+pnpm exec vitest --config vitest.e2e.config.ts
+```
+
+## Comprehensive auth e2e β `*.e2e.full.test.ts`
+
+The full RBAC auth matrix β every route family with explicit pass/fail
+scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for
+each family's coverage spec.
+
+**Architecture**: one container reused across the whole suite via
+`vitest.e2e.full.config.ts`'s `globalSetup`. Test files share the server
+through `getTestServer()` from `helpers/sharedTestServer.ts`. Each test
+seeds its own resources so order doesn't matter.
+
+**Layout**:
+
+| File | Top-level describe | Family subtasks |
+|---|---|---|
+| `auth-api.e2e.full.test.ts` | `API` | TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT |
+| `auth-dashboard.e2e.full.test.ts` | `Dashboard` | TRI-8742 admin pages |
+| `auth-cross-cutting.e2e.full.test.ts` | `Cross-cutting` | TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle |
+
+**Adding a new family**: pick the relevant file, add a nested `describe`
+block. Inside, seed your own fixtures via the helpers and hit the shared
+server.
+
+```ts
+describe("Trigger task", () => {
+ const server = getTestServer();
+
+ it("missing Authorization β 401", async () => {
+ const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" });
+ expect(res.status).toBe(401);
+ });
+});
+```
+
+**CI**: `e2e-webapp-auth-full.yml`. Triggers on `workflow_dispatch`,
+nightly schedule, and PRs touching auth-relevant paths (route builders,
+rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself).
+
+**Run locally**:
+
+```bash
+cd apps/webapp
+pnpm exec vitest --config vitest.e2e.full.config.ts
+```
diff --git a/apps/webapp/test/api-auth.e2e.test.ts b/apps/webapp/test/api-auth.e2e.test.ts
index c425ca7449c..31e365d6d40 100644
--- a/apps/webapp/test/api-auth.e2e.test.ts
+++ b/apps/webapp/test/api-auth.e2e.test.ts
@@ -11,6 +11,9 @@ import type { TestServer } from "@internal/testcontainers/webapp";
import { startTestServer } from "@internal/testcontainers/webapp";
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
+import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
+import { seedTestRun } from "./helpers/seedTestRun";
+import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
vi.setConfig({ testTimeout: 180_000 });
@@ -119,3 +122,306 @@ describe("JWT bearer auth β baseline behavior", () => {
expect(res.status).toBe(401);
});
});
+
+// Exercises the RBAC plugin loader end-to-end. The test server boots
+// with RBAC_FORCE_FALLBACK=1 (see internal-packages/testcontainers/src/webapp.ts),
+// which makes rbac.server.ts use the default fallback regardless of
+// whether a plugin is installed in node_modules. /admin/concurrency
+// uses rbac.authenticateSession internally; an unauthenticated request
+// must flow through LazyController β RoleBaseAccessFallback β
+// redirect("/login").
+describe("RBAC plugin β fallback wiring", () => {
+ it("unauthenticated dashboard route redirects to /login via the fallback", async () => {
+ const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
+ expect(res.status).toBe(302);
+ const location = res.headers.get("location") ?? "";
+ expect(new URL(location, "http://placeholder").pathname).toBe("/login");
+ });
+});
+
+// Covers createActionApiRoute's bearer auth path. The target route is
+// POST /api/v1/idempotencyKeys/:key/reset β allowJWT: true, superScopes: ["write:runs", "admin"].
+// Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps
+// authenticateApiRequestWithFailure for rbac.authenticateBearer.
+describe("API bearer auth β action requests", () => {
+ const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
+
+ it("valid API key: auth passes (body validation fails, not 401/403)", async () => {
+ const { apiKey } = await seedTestEnvironment(server.prisma);
+ const res = await server.webapp.fetch(targetPath, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
+ body: JSON.stringify({}), // missing taskIdentifier β zod validation error
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("missing Authorization header: 401", async () => {
+ const res = await server.webapp.fetch(targetPath, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ taskIdentifier: "noop" }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("invalid API key: 401", async () => {
+ const res = await server.webapp.fetch(targetPath, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real",
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ taskIdentifier: "noop" }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+});
+
+describe("JWT bearer auth β action requests", () => {
+ const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset";
+
+ it("JWT with matching scope: auth passes", async () => {
+ const { environment } = await seedTestEnvironment(server.prisma);
+ const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] });
+ const res = await server.webapp.fetch(targetPath, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with wrong scope (read-only) on write route: 403", async () => {
+ const { environment } = await seedTestEnvironment(server.prisma);
+ const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
+ const res = await server.webapp.fetch(targetPath, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
+ body: JSON.stringify({ taskIdentifier: "noop" }),
+ });
+ expect(res.status).toBe(403);
+ });
+});
+
+// Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs.
+// authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed
+// or doesn't match a non-revoked PersonalAccessToken row.
+describe("Personal access token auth", () => {
+ const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
+
+ it("missing Authorization header: 401", async () => {
+ const res = await server.webapp.fetch(pathFor("nonexistent"));
+ expect(res.status).toBe(401);
+ });
+
+ it("API key (tr_dev_*) on PAT-only route: 401", async () => {
+ const { apiKey } = await seedTestEnvironment(server.prisma);
+ const res = await server.webapp.fetch(pathFor("nonexistent"), {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("malformed PAT (wrong prefix): 401", async () => {
+ const res = await server.webapp.fetch(pathFor("nonexistent"), {
+ headers: { Authorization: "Bearer not_a_pat_at_all_random_string" },
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("well-formed but unknown PAT: 401", async () => {
+ const res = await server.webapp.fetch(pathFor("nonexistent"), {
+ headers: {
+ Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000",
+ },
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("revoked PAT: 401", async () => {
+ const user = await seedTestUser(server.prisma);
+ const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true });
+ const res = await server.webapp.fetch(pathFor("nonexistent"), {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("valid PAT on nonexistent project: 404 (auth passes)", async () => {
+ const user = await seedTestUser(server.prisma);
+ const { token } = await seedTestPAT(server.prisma, user.id);
+ const res = await server.webapp.fetch(pathFor("nonexistent"), {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ expect(res.status).toBe(404);
+ });
+});
+
+// Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource.
+// Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete β allowJWT: true,
+// authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }),
+// superScopes: ["write:waitpoints", "admin"] }.
+//
+// The Waitpoint is seeded with status COMPLETED so the handler short-circuits with
+// { success: true } once auth passes β no run-engine worker needed. "Auth passes" is
+// observable as a 200 response; "auth fails" is observable as a 403.
+describe("JWT bearer auth β resource-scoped scopes", () => {
+ const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`;
+
+ async function seedEnvAndWaitpoint() {
+ const seed = await seedTestEnvironment(server.prisma);
+ const waitpoint = await seedTestWaitpoint(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ return { ...seed, waitpoint };
+ }
+
+ async function completeRequest(friendlyId: string, jwt: string) {
+ return server.webapp.fetch(pathFor(friendlyId), {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ }
+
+ it("scope matches exact resource id: 200", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, {
+ scopes: [`write:waitpoints:${waitpoint.friendlyId}`],
+ });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("scope targets a different resource id: 403", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, {
+ scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"],
+ });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+
+ it("type-level scope (no id) grants all resources of that type: 200", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, {
+ scopes: [`read:waitpoints:${waitpoint.friendlyId}`],
+ });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+
+ it("scope targets a different resource type: 403", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, {
+ scopes: ["write:runs:run_abc000000000000000000000"],
+ });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+
+ it("admin super-scope grants access (legacy behaviour): 200", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, { scopes: ["admin"] });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("unrelated type scope with no super-scope match: 403", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
+ const res = await completeRequest(waitpoint.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+});
+
+// Pre-migration coverage for the three behavioural constraints captured in TRI-8719.
+// Each test locks in an observable current behaviour that the migration must preserve:
+// - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes
+// - multi-key resource callbacks (runs/tags/batch/tasks) β any key match grants access
+// - empty resource callbacks relying on superScopes
+describe("JWT bearer auth β behaviours to preserve through TRI-8719", () => {
+ it("custom action: type-level write:tasks scope satisfies action=\"trigger\" (auth passes)", async () => {
+ const { environment } = await seedTestEnvironment(server.prisma);
+ // Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks.
+ // Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"].
+ // After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is
+ // satisfied by a scope whose action is write.
+ const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] });
+ const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("multi-key resource: read:tags: scope grants access to a run carrying that tag (auth passes)", async () => {
+ const { environment, project } = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: environment.id,
+ projectId: project.id,
+ runTags: ["my-resource-scoped-tag"],
+ });
+ const jwt = await generateTestJWT(environment, {
+ scopes: ["read:tags:my-resource-scoped-tag"],
+ });
+ const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("multi-key resource: read:batch: scope grants access to a run in that batch (auth passes)", async () => {
+ const { environment, project } = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: environment.id,
+ projectId: project.id,
+ withBatch: true,
+ });
+ const jwt = await generateTestJWT(environment, {
+ scopes: [`read:batch:${batchFriendlyId}`],
+ });
+ const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ // Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts)
+ // currently DENY all JWTs because legacy checkAuthorization's empty-resource check
+ // fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" }
+ // changes this to "JWTs with read:runs or write:runs now work on these routes" β an
+ // intentional improvement, not a preserved behaviour. See TRI-8719 description for
+ // the note; there's nothing to lock in with a test here.
+});
+
+// Edge cases where auth-path DB state should cause 401 even with a valid-looking token.
+describe("API bearer auth β environment/project edge cases", () => {
+ it("valid API key whose project is soft-deleted: 401", async () => {
+ const { apiKey, project } = await seedTestEnvironment(server.prisma);
+ await server.prisma.project.update({
+ where: { id: project.id },
+ data: { deletedAt: new Date() },
+ });
+ const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts
new file mode 100644
index 00000000000..31f2ced7eca
--- /dev/null
+++ b/apps/webapp/test/auth-api.e2e.full.test.ts
@@ -0,0 +1,2974 @@
+// Comprehensive API auth tests β uses the shared TestServer started by
+// vitest.e2e.full.config.ts's globalSetup. Family subtasks under TRI-8731
+// add nested describe blocks here:
+//
+// describe("API", () => {
+// describe("Trigger task", () => { ... }) // TRI-8733
+// describe("Runs β resource routes", () => { ... }) // TRI-8734
+// ...
+// })
+//
+// See test/helpers/sharedTestServer.ts for `getTestServer()`.
+
+import { generateJWT } from "@trigger.dev/core/v3/jwt";
+import { describe, expect, it } from "vitest";
+import { getTestServer } from "./helpers/sharedTestServer";
+import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
+import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
+import { seedTestRun } from "./helpers/seedTestRun";
+import { seedTestApiSession } from "./helpers/seedTestApiSession";
+import { seedTestUserProject } from "./helpers/seedTestUserProject";
+import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
+
+describe("API", () => {
+ // Placeholder until family subtasks add their describes (TRI-8733+).
+ // Verifies the shared container is reachable from this worker.
+ it("shared webapp container responds to /healthcheck", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch("/healthcheck");
+ expect(res.ok).toBe(true);
+ });
+
+ // PAT-authenticated routes (TRI-8741). The smoke matrix in
+ // test/api-auth.e2e.test.ts covers basic 401 cases (missing auth,
+ // wrong-prefix, unknown PAT, revoked PAT, valid-PAT-on-nonexistent-
+ // project). This describe extends the matrix to the cases that
+ // require seeding the full user β org β project β env graph:
+ // valid-PAT-on-real-project, cross-org isolation, soft-deleted
+ // project, and the global-admin-flag-doesn't-grant-cross-org carve-
+ // out.
+ //
+ // Target route: GET /api/v1/projects/:projectRef/runs (the only
+ // createLoaderPATApiRoute consumer at time of writing β re-grep
+ // before extending if more PAT-only routes appear).
+ describe("PAT-authenticated routes β comprehensive", () => {
+ const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
+
+ it("JWT on PAT-only route: 401", async () => {
+ const server = getTestServer();
+ const { environment } = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(pathFor("nonexistent"), {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ // PAT route doesn't accept JWTs β auth rejects before resource lookup.
+ expect(res.status).toBe(401);
+ });
+
+ it("valid PAT, project exists in user's org: auth passes", async () => {
+ const server = getTestServer();
+ const { project, pat } = await seedTestUserProject(server.prisma);
+ const res = await server.webapp.fetch(pathFor(project.externalRef), {
+ headers: { Authorization: `Bearer ${pat.token}` },
+ });
+ // Auth + scoping pass. The route's run-list presenter hits
+ // ClickHouse which isn't reachable in tests β accept any status
+ // that isn't an auth failure.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("valid PAT, project belongs to a different user's org: 404", async () => {
+ const server = getTestServer();
+ // Two completely isolated graphs. Both projects exist; the PAT
+ // belongs to userA, the project to userB's org. findProjectByRef
+ // scopes by `members: { some: { userId } }`, so userA's PAT
+ // sees userB's project as nonexistent β 404 (not 403).
+ const a = await seedTestUserProject(server.prisma);
+ const b = await seedTestUserProject(server.prisma);
+ const res = await server.webapp.fetch(pathFor(b.project.externalRef), {
+ headers: { Authorization: `Bearer ${a.pat.token}` },
+ });
+ // Lock in the 404 β the access check inside findProjectByRef
+ // returns null for cross-org and the route maps null to 404.
+ expect(res.status).toBe(404);
+ });
+
+ it("valid PAT, project soft-deleted (deletedAt != null): 200 (route does not filter)", async () => {
+ const server = getTestServer();
+ // findProjectByRef (apps/webapp/app/models/project.server.ts)
+ // does NOT filter on deletedAt β it scopes only by externalRef
+ // and the user's org membership. So a soft-deleted project is
+ // still findable here; the run-list presenter just returns
+ // data:[] (or whatever survived). The ticket lists this as a
+ // 404 case but that's not the route's actual contract; lock in
+ // observed behaviour and call out the gap so a future change
+ // (either tightening findProjectByRef or filtering at the route)
+ // is conscious.
+ const { project, pat } = await seedTestUserProject(server.prisma, {
+ projectDeleted: true,
+ });
+ const res = await server.webapp.fetch(pathFor(project.externalRef), {
+ headers: { Authorization: `Bearer ${pat.token}` },
+ });
+ // ClickHouse-dependent run-list β auth-passed assertion.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("valid PAT for a global-admin user: still per-user (no cross-org access)", async () => {
+ const server = getTestServer();
+ // user.admin = true is the legacy super-admin flag. The PAT
+ // route's access check is per-user (members: { some: { userId } }),
+ // not admin-aware β so admin doesn't unlock cross-org visibility.
+ // Lock in that behaviour: an admin's PAT can't read another
+ // org's project either.
+ const admin = await seedTestUser(server.prisma, { admin: true });
+ const adminPat = await seedTestPAT(server.prisma, admin.id);
+ const otherOrg = await seedTestUserProject(server.prisma);
+
+ const res = await server.webapp.fetch(pathFor(otherOrg.project.externalRef), {
+ headers: { Authorization: `Bearer ${adminPat.token}` },
+ });
+ expect(res.status).toBe(404);
+ });
+
+ it("valid PAT, admin user accessing their OWN project: auth passes", async () => {
+ const server = getTestServer();
+ // Companion to the above β confirm admin=true users can still
+ // access their own org's projects (the admin flag isn't
+ // accidentally subtracting permission).
+ const { project, pat } = await seedTestUserProject(server.prisma, {
+ userAdmin: true,
+ });
+ const res = await server.webapp.fetch(pathFor(project.externalRef), {
+ headers: { Authorization: `Bearer ${pat.token}` },
+ });
+ // ClickHouse-dependent run-list β auth-passed assertion.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // Resource-scoped writes (TRI-8740). Two routes:
+ // - POST /api/v1/waitpoints/tokens/:friendlyId/complete
+ // resource: { type: "waitpoints", id: friendlyId }
+ // - POST /realtime/v1/streams/:runId/input/:streamId
+ // resource: { type: "inputStreams", id: runId }
+ //
+ // The smoke matrix (api-auth.e2e.test.ts "JWT bearer auth β resource-
+ // scoped scopes") already covers waitpoints comprehensively for JWT
+ // resource-id matching, type-level scopes, action mismatches, admin
+ // super-scope, etc. This block fills the gaps:
+ // - Private API key (not JWT) on the route.
+ // - JWT with `write:all` super-scope.
+ // - Cross-env (env A's JWT trying env B's resource).
+ // Plus the equivalent full matrix for input-streams which the smoke
+ // matrix doesn't touch.
+ describe("Resource-scoped writes β waitpoints (gap-fill)", () => {
+ const pathFor = (friendlyId: string) =>
+ `/api/v1/waitpoints/tokens/${friendlyId}/complete`;
+ const completeRequest = (path: string, headers: Record) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify({}),
+ });
+
+ async function seedEnvAndWaitpoint() {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const waitpoint = await seedTestWaitpoint(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ return { ...seed, waitpoint };
+ }
+
+ it("private API key (tr_dev_*): auth passes (200)", async () => {
+ const { apiKey, waitpoint } = await seedEnvAndWaitpoint();
+ const res = await completeRequest(pathFor(waitpoint.friendlyId), {
+ Authorization: `Bearer ${apiKey}`,
+ });
+ // Waitpoint is COMPLETED, so the handler short-circuits with 200
+ // once auth passes. Auth-passed assertion: NOT 401 / 403.
+ expect(res.status).toBe(200);
+ });
+
+ it("JWT with write:all super-scope: auth passes (200)", async () => {
+ const { environment, waitpoint } = await seedEnvAndWaitpoint();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["write:all"] },
+ expirationTime: "15m",
+ });
+ const res = await completeRequest(pathFor(waitpoint.friendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).toBe(200);
+ });
+
+ it("cross-env: env A's JWT cannot complete env B's waitpoint: not 200", async () => {
+ const server = getTestServer();
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedEnvAndWaitpoint();
+ const jwt = await generateJWT({
+ secretKey: a.apiKey,
+ payload: {
+ pub: true,
+ sub: a.environment.id,
+ scopes: [`write:waitpoints:${b.waitpoint.friendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ // The JWT is signed by env A and its sub claim says env A. The
+ // route resolves env from the sub claim and the waitpoint is
+ // env B's, so the lookup misses. The exact code depends on
+ // whether auth or the resource lookup fires first β both
+ // outcomes are correct, just NOT 200.
+ const res = await completeRequest(pathFor(b.waitpoint.friendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(200);
+ });
+ });
+
+ describe("Resource-scoped writes β input streams (full matrix)", () => {
+ const pathFor = (runId: string, streamId: string) =>
+ `/realtime/v1/streams/${runId}/input/${streamId}`;
+ const postRequest = (path: string, headers: Record) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify({ data: { hello: "world" } }),
+ });
+
+ async function seedEnvAndRun() {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ return { ...seed, runFriendlyId, streamId: "test-stream" };
+ }
+
+ it("missing auth: 401", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(pathFor("run_doesnotexist", "stream-x"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ data: {} }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes (not 401/403)", async () => {
+ const { apiKey, runFriendlyId, streamId } = await seedEnvAndRun();
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${apiKey}`,
+ });
+ // Route may return any 2xx/4xx based on stream state β we only
+ // care that auth passed (NOT 401/403).
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with exact-id scope: auth passes", async () => {
+ const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: [`write:inputStreams:${runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with type-level scope: auth passes", async () => {
+ const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["write:inputStreams"] },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with wrong resource id: 403", async () => {
+ const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: ["write:inputStreams:run_someoneelse00000000000000"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with read action on write route: 403", async () => {
+ const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: [`read:inputStreams:${runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with write:all super-scope: auth passes", async () => {
+ const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["write:all"] },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with admin super-scope: auth passes", async () => {
+ const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(runFriendlyId, streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("cross-env: env A's JWT cannot write to env B's run: not 200", async () => {
+ const server = getTestServer();
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedEnvAndRun();
+ const jwt = await generateJWT({
+ secretKey: a.apiKey,
+ payload: {
+ pub: true,
+ sub: a.environment.id,
+ scopes: [`write:inputStreams:${b.runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await postRequest(pathFor(b.runFriendlyId, b.streamId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ // Either auth fails outright or the run lookup misses (env A's
+ // view of the run doesn't include env B's data). Critical
+ // security property: NOT 200.
+ expect(res.status).not.toBe(200);
+ });
+ });
+
+ // Trigger task routes (TRI-8733). The single-task route uses
+ // action: "trigger" with a single resource { type: "tasks", id };
+ // batch v1/v2 use action: "batchTrigger" with a body-derived array
+ // [{type:"tasks", id}, ...] under AND semantics β every task in the
+ // batch must be authorized, not just any one (otherwise a JWT scoped
+ // to one task could submit a batch with arbitrary other tasks).
+ // v3 batches use a collection-level resource { type: "tasks" }
+ // (no id β items are validated per-row when streamed).
+ //
+ // ACTION_ALIASES (from packages/core/src/v3/jwt.ts) maps writeβtrigger
+ // and writeβbatchTrigger so write:tasks scopes also satisfy these
+ // routes. The smoke matrix already verifies write:tasks β trigger
+ // alias works; we re-test it here per-route so scope misconfig in
+ // one route doesn't slip past.
+ describe("Trigger task β single (api.v1.tasks.$taskId.trigger)", () => {
+ const TASK_ID = "test-task";
+ const path = `/api/v1/tasks/${TASK_ID}/trigger`;
+
+ async function seedAndRequest(
+ headers: Record,
+ body: unknown = { payload: {} }
+ ) {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify(body),
+ });
+ return { res, seed };
+ }
+
+ it("missing auth: 401", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes (handler may 4xx β not 401/403)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${seed.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ payload: {} }),
+ });
+ // Auth passed; the handler may 404 because the task doesn't
+ // actually exist in the BackgroundWorker. Anything not 401/403
+ // is "auth passed" for this test.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:tasks (type-level, ACTION_ALIASES writeβtrigger): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with trigger:tasks:: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`trigger:tasks:${TASK_ID}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with trigger:tasks:: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["trigger:tasks:some-other-task"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with read:tasks: 403 (read NOT aliased to trigger)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with empty scopes: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: [] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT signed with wrong key: 401", async () => {
+ const server = getTestServer();
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: b.apiKey, // wrong key for env A's sub
+ payload: {
+ pub: true,
+ sub: a.environment.id,
+ scopes: [`trigger:tasks:${TASK_ID}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT with admin super-scope: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ payload: {} }),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ describe("Trigger task β batch v1 (api.v1.tasks.batch)", () => {
+ const path = "/api/v1/tasks/batch";
+ const buildBody = (taskIds: string[]) => ({
+ items: taskIds.map((task) => ({ task, payload: {} })),
+ });
+
+ it("missing auth: 401", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA"])),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${seed.apiKey}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(buildBody(["taskA"])),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:tasks (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA", "taskB"])),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with batchTrigger:tasks:taskA + body has [taskA, taskB]: 403 (every-task semantics)", async () => {
+ // Batch trigger uses AND semantics β every task in the body must
+ // be authorized, not just any one of them. A JWT scoped to only
+ // taskA cannot submit a batch that also includes taskB, otherwise
+ // the caller would be triggering tasks they have no scope for.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["batchTrigger:tasks:taskA"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA", "taskB"])),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with batchTrigger:tasks:taskA + body has [taskA] only: auth passes", async () => {
+ // Per-task scope grants per-task access β a batch containing
+ // only the authorized task is allowed.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["batchTrigger:tasks:taskA"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA"])),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with batchTrigger:tasks: + body has only taskA: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["batchTrigger:tasks:not-in-body"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA"])),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with read:tasks: 403 (action mismatch)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA"])),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody(["taskA"])),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // v2 batch shares the exact same authorization config as v1 β same
+ // body-derived array resource, same batchTrigger action. We don't
+ // duplicate the full matrix here; the v1 tests cover the wrapper
+ // behaviour. If v2's authorization config ever diverges from v1's,
+ // add a targeted test here. For now just sanity-check that the v2
+ // route's wiring is alive.
+ describe("Trigger task β batch v2 (api.v2.tasks.batch) sanity", () => {
+ const path = "/api/v2/tasks/batch";
+
+ it("missing auth: 401", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ items: [{ task: "t", payload: {} }] }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT with write:tasks: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ items: [{ task: "t", payload: {} }] }),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // v3 batches use a collection-level resource { type: "tasks" } with
+ // no id β items are validated per-row when streamed. So id-specific
+ // scopes (write:tasks:foo) shouldn't grant blanket access; only
+ // type-level write:tasks (or admin/write:all) should.
+ describe("Trigger task β batch v3 (api.v3.batches) collection-level", () => {
+ const path = "/api/v3/batches";
+ const buildBody = () => ({ runCount: 1 });
+
+ it("missing auth: 401", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody()),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT with write:tasks (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody()),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with read:tasks: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody()),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
+ body: JSON.stringify(buildBody()),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // Run lists (TRI-8736). Two routes share the same multi-key
+ // resource pattern β collection-level `{ type: "runs" }` always
+ // present, plus an array of secondary keys derived from search
+ // params:
+ // - GET /api/v1/runs: filter[taskIdentifier]=A,B β +{ type: "tasks", id: A }, { type: "tasks", id: B }
+ // - GET /realtime/v1/runs: ?tags=foo,bar β +{ type: "tags", id: "foo" }, { type: "tags", id: "bar" }
+ //
+ // Multi-key any-match contract from TRI-8719: a JWT with a scope
+ // matching ANY element of the resource array grants access. So:
+ // - read:runs β matches the collection key β passes
+ // - read:tasks:A (with A in filter) β matches an array element β passes
+ // - read:tasks:Z (with A in filter) β no match β 403
+ describe("Run list β api.v1.runs (multi-key tasks)", () => {
+ const path = "/api/v1/runs";
+
+ async function get(query: string, headers: Record) {
+ return getTestServer().webapp.fetch(`${path}${query}`, { headers });
+ }
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ // Pass cases on api.v1.runs assert "auth passed" (not 401/403)
+ // rather than strict 200. The handler hits ClickHouse which isn't
+ // reachable from the test container β the endpoint can 500 in
+ // tests even when auth is fine. The auth layer is what we're
+ // verifying here.
+ it("private API key: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await get("", { Authorization: `Bearer ${seed.apiKey}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with read:runs (collection-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with read:all super-scope: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with empty scopes: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: [] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with write:runs (action mismatch β read route): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a β passes (array match)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:tasks:task_a"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(
+ "?filter%5BtaskIdentifier%5D=task_a%2Ctask_b",
+ { Authorization: `Bearer ${jwt}` }
+ );
+ // Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}].
+ // The scope read:tasks:task_a matches the second element β access granted.
+ // Handler may 500 (ClickHouse unreachable in tests) but auth passed.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z β 403 (no array match)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:tasks:task_z"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(
+ "?filter%5BtaskIdentifier%5D=task_a",
+ { Authorization: `Bearer ${jwt}` }
+ );
+ // Resource is [{runs}, {tasks:task_a}]. JWT scope says
+ // read:tasks:task_z which doesn't match the runs collection
+ // (wrong type) or the task_a element (wrong id). 403.
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe("Run list β realtime.v1.runs (multi-key tags)", () => {
+ const path = "/realtime/v1/runs";
+
+ async function get(query: string, headers: Record) {
+ return getTestServer().webapp.fetch(`${path}${query}`, { headers });
+ }
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT with read:runs (collection-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ // Realtime endpoints stream β the route may return 200 (streaming
+ // OK) or other status codes depending on streams setup. We only
+ // care that auth passed: NOT 401/403.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with read:tags:foo + ?tags=foo,bar β passes (array match)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:tags:foo"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get("?tags=foo,bar", { Authorization: `Bearer ${jwt}` });
+ // Resource array is [{type:"runs"}, {type:"tags",id:"foo"}, {type:"tags",id:"bar"}].
+ // Scope matches the foo element β access granted.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with read:tags:baz + ?tags=foo β 403 (no array match)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:tags:baz"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get("?tags=foo", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:runs (action mismatch): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get("", { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // Run mutations (TRI-8735). Two routes:
+ // - POST /api/v2/runs/:runParam/cancel
+ // action: write, resource: { type: "runs", id: params.runParam }
+ // β single id-keyed resource, supports id-specific scopes.
+ // - POST /api/v1/idempotencyKeys/:key/reset
+ // action: write, resource: { type: "runs" } (collection-level)
+ // β id-specific scopes don't grant blanket access; only
+ // type-level write:runs (or super-scopes) work.
+ //
+ // The legacy idempotencyKeys/:key/reset rejected ALL JWTs due to an
+ // empty-resource bug. Post TRI-8719 the empty-resource resolution
+ // lets write:runs JWTs through. Tests here lock in the new behaviour.
+ describe("Run mutations β cancel (api.v2.runs.$runParam.cancel)", () => {
+ const pathFor = (runId: string) => `/api/v2/runs/${runId}/cancel`;
+ const post = (path: string, headers: Record) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify({}),
+ });
+
+ it("missing auth: 401", async () => {
+ const res = await post(pathFor("run_anything"), {});
+ expect(res.status).toBe(401);
+ });
+
+ it("invalid API key: 401", async () => {
+ const res = await post(pathFor("run_anything"), {
+ Authorization: "Bearer tr_dev_definitely_not_real_key",
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key on real run: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${seed.apiKey}`,
+ });
+ // Auth + findResource passed; handler may return any 2xx/4xx
+ // depending on run state. We only care: not 401/403.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:runs (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:runs:: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`write:runs:${runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:runs:: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["write:runs:run_someoneelse00000000000"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with read:runs (action mismatch): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`read:runs:${runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with write:all super-scope: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] },
+ expirationTime: "15m",
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await post(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ describe("Run mutations β idempotencyKeys.reset (api.v1.idempotencyKeys.$key.reset)", () => {
+ // Collection-level resource { type: "runs" } β id-specific
+ // write:runs: scopes don't help here (no id to match).
+ // The legacy version of this route rejected ALL JWTs due to an
+ // empty-resource bug; the post-TRI-8719 path lets write:runs
+ // through. Tests below pin that down.
+ const path = "/api/v1/idempotencyKeys/some-key/reset";
+ const validBody = JSON.stringify({ taskIdentifier: "test-task" });
+
+ const post = (headers: Record, body = validBody) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body,
+ });
+
+ it("missing auth: 401", async () => {
+ const res = await post({});
+ expect(res.status).toBe(401);
+ });
+
+ it("invalid API key: 401", async () => {
+ const res = await post({ Authorization: "Bearer tr_dev_invalid" });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await post({ Authorization: `Bearer ${seed.apiKey}` });
+ // Handler may 404/204 depending on whether the idempotency key
+ // exists. Auth-passed assertion only.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with write:runs (type-level): auth passes β locks in TRI-8719 fix", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ // PRE-TRI-8719: this returned 403 (legacy empty-resource bug
+ // rejected all JWTs). POST-TRI-8719: write:runs grants access.
+ // Locking in the new behaviour.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with read:runs (action mismatch): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT with write:all: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT with admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // Run resource routes (TRI-8734). Every read-side `$runId` route
+ // computes its authorization resource from the loaded TaskRun:
+ // [
+ // { type: "runs", id: run.friendlyId },
+ // { type: "tasks", id: run.taskIdentifier },
+ // ...run.runTags.map(tag => ({ type: "tags", id: tag })),
+ // run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId },
+ // ]
+ //
+ // A JWT scope matching ANY array element grants access. We test the
+ // full matrix against the canonical route (api.v3.runs.$runId), and
+ // a sanity check on one of the others to confirm the wiring isn't
+ // route-local. If a future route's resource shape diverges, add a
+ // targeted describe.
+ describe("Run resource β GET /api/v3/runs/:runId (multi-key array)", () => {
+ const pathFor = (runId: string) => `/api/v3/runs/${runId}`;
+
+ async function seedRunWithBatchAndTags() {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const seeded = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ runTags: ["alpha", "beta"],
+ withBatch: true,
+ });
+ return { ...seed, ...seeded };
+ }
+
+ const get = (path: string, headers: Record) =>
+ getTestServer().webapp.fetch(path, { headers });
+
+ it("missing auth: 401", async () => {
+ const res = await get(pathFor("run_anything"), {});
+ expect(res.status).toBe(401);
+ });
+
+ it("invalid API key: 401", async () => {
+ const res = await get(pathFor("run_anything"), {
+ Authorization: "Bearer tr_dev_invalid",
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key on real run: auth passes", async () => {
+ const { runFriendlyId, apiKey } = await seedRunWithBatchAndTags();
+ const res = await get(pathFor(runFriendlyId), {
+ Authorization: `Bearer ${apiKey}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:runs (type-level): auth passes", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:runs:: auth passes (id match)", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: [`read:runs:${runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:runs:: 403", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: ["read:runs:run_someoneelse00000000000"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT read:tags:: auth passes (array element match)", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ // run was seeded with runTags=["alpha","beta"]; scope matches "alpha".
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:tags:alpha"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:tags:: 403", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:tags:gamma"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT read:batch:: auth passes", async () => {
+ const { runFriendlyId, batchFriendlyId, apiKey, environment } =
+ await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: [`read:batch:${batchFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:batch:: 403", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: ["read:batch:batch_someoneelse00000000"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT read:tasks:: auth passes", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ // seedTestRun uses taskIdentifier "test-task" by default.
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:tasks:test-task"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:all: auth passes", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:all"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT admin: auth passes", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT write:runs:: 403 (action mismatch β read route)", async () => {
+ const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: [`write:runs:${runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("cross-env: env A's JWT cannot read env B's run: not 200", async () => {
+ const server = getTestServer();
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedRunWithBatchAndTags();
+ const jwt = await generateJWT({
+ secretKey: a.apiKey,
+ payload: {
+ pub: true,
+ sub: a.environment.id,
+ scopes: [`read:runs:${b.runFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(b.runFriendlyId), { Authorization: `Bearer ${jwt}` });
+ // Either auth fails or the run lookup misses (env A's view of
+ // the run doesn't include env B's data). Critical: NOT 200.
+ expect(res.status).not.toBe(200);
+ });
+ });
+
+ // Sanity check: same multi-key pattern wired the same way on the
+ // events sub-route. If this drifts in the future the divergence
+ // gets a dedicated describe.
+ describe("Run resource β GET /api/v1/runs/:runId/events (sanity)", () => {
+ const pathFor = (runId: string) => `/api/v1/runs/${runId}/events`;
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(pathFor("run_anything"));
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT read:runs (type-level): auth passes on a real run", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const { runFriendlyId } = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await getTestServer().webapp.fetch(pathFor(runFriendlyId), {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // Batch resources (TRI-8737). Per-batch retrieve + realtime
+ // endpoints β single-id resource `{ type: "batch", id: batch.friendlyId }`.
+ // The list endpoint (`GET /api/v1/batches`) is currently absent
+ // from this branch (deleted in s3-switchover), so the list-
+ // section of the matrix is N/A here. If/when the list endpoint
+ // returns, add a list-side describe.
+ //
+ // Notable behaviour: the route's resource is `{ type: "batch" }`,
+ // NOT `{ type: "runs" }`. The legacy literal-match escape that
+ // let `read:runs` JWTs hit batch endpoints no longer applies.
+ // Tests pin this down (a `read:runs` scope on a `{ type: "batch" }`
+ // resource is a type mismatch β 403).
+ describe("Batch retrieve β GET /api/v1/batches/:batchId", () => {
+ const pathFor = (batchId: string) => `/api/v1/batches/${batchId}`;
+
+ async function seedRunWithBatch() {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const seeded = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ withBatch: true,
+ });
+ // batchFriendlyId is guaranteed when withBatch is set.
+ if (!seeded.batchFriendlyId) {
+ throw new Error("seedTestRun({ withBatch: true }) didn't return a batchFriendlyId");
+ }
+ return { ...seed, batchFriendlyId: seeded.batchFriendlyId };
+ }
+
+ const get = (path: string, headers: Record) =>
+ getTestServer().webapp.fetch(path, { headers });
+
+ it("missing auth: 401", async () => {
+ const res = await get(pathFor("batch_anything"), {});
+ expect(res.status).toBe(401);
+ });
+
+ it("invalid API key: 401", async () => {
+ const res = await get(pathFor("batch_anything"), {
+ Authorization: "Bearer tr_dev_invalid",
+ });
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key on real batch: auth passes", async () => {
+ const { batchFriendlyId, apiKey } = await seedRunWithBatch();
+ const res = await get(pathFor(batchFriendlyId), {
+ Authorization: `Bearer ${apiKey}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:batch: matching: auth passes", async () => {
+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: [`read:batch:${batchFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:batch:: 403", async () => {
+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: {
+ pub: true,
+ sub: environment.id,
+ scopes: ["read:batch:batch_someoneelse00000000"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT read:batch (type-level): auth passes", async () => {
+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:batch"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:runs: 403 (resource type is 'batch', not 'runs')", async () => {
+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ // Pre-TRI-8719 the legacy literal-match escape granted
+ // read:runs access to batch endpoints. Post-migration the
+ // resource type is strictly { type: "batch" } and read:runs
+ // doesn't match. Lock this in β if SDKs were issuing
+ // read:runs:* JWTs for batch lookups, that's a regression to
+ // catch.
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT read:all super-scope: auth passes", async () => {
+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["read:all"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT admin: auth passes", async () => {
+ const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("cross-env: env A's JWT cannot read env B's batch: not 200", async () => {
+ const server = getTestServer();
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedRunWithBatch();
+ const jwt = await generateJWT({
+ secretKey: a.apiKey,
+ payload: {
+ pub: true,
+ sub: a.environment.id,
+ scopes: [`read:batch:${b.batchFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get(pathFor(b.batchFriendlyId), { Authorization: `Bearer ${jwt}` });
+ // Critical: env A's JWT can't see env B's batch (env-scoped
+ // findResource returns null). NOT 200.
+ expect(res.status).not.toBe(200);
+ });
+ });
+
+ // Sanity: api.v2 and realtime.v1 share the exact same authorization
+ // config as v1. Don't duplicate the full matrix; just verify the
+ // wiring is alive on each.
+ describe("Batch retrieve β GET /api/v2/batches/:batchId (sanity)", () => {
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch("/api/v2/batches/batch_anything");
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT read:batch (type-level): auth passes on real batch", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const seeded = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ withBatch: true,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:batch"] },
+ expirationTime: "15m",
+ });
+ const res = await getTestServer().webapp.fetch(
+ `/api/v2/batches/${seeded.batchFriendlyId}`,
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // Prompts routes (TRI-8738). Resource shapes:
+ // - List resource: { type: "prompts", id: "all" } action: read
+ // - Retrieve resource: { type: "prompts", id: params.slug } action: read
+ // - Override resource: { type: "prompts", id: params.slug } action: update
+ // (multi-method: POST/PUT/PATCH/DELETE)
+ // - Promote resource: { type: "prompts", id: params.slug } action: update
+ // - Reactivate resource: { type: "prompts", id: params.slug } action: update
+ //
+ // ACTION_ALIASES: update β write, so write:prompts also satisfies
+ // the update-action routes.
+ //
+ // Auth happens before any DB lookup, so we test against
+ // non-existent slugs β handler will 404 but we assert "not 401/403"
+ // for pass cases.
+ describe("Prompts list β GET /api/v1/prompts (collection-level)", () => {
+ const path = "/api/v1/prompts";
+ const get = (headers: Record) =>
+ getTestServer().webapp.fetch(path, { headers });
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await get({ Authorization: `Bearer ${seed.apiKey}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:prompts (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:runs: 403 (type mismatch)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ describe("Prompts retrieve β GET /api/v1/prompts/:slug (id-keyed read)", () => {
+ const SLUG = "test-prompt";
+ const path = `/api/v1/prompts/${SLUG}`;
+ const get = (headers: Record) =>
+ getTestServer().webapp.fetch(path, { headers });
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await get({ Authorization: `Bearer ${seed.apiKey}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:prompts (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:prompts:: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`read:prompts:${SLUG}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:prompts:: not 200 (no access)", async () => {
+ // Note: the prompts retrieve route has a findResource callback
+ // that runs BEFORE authorization. Since we don't seed a Prompt
+ // fixture, the route 404s before reaching the auth check β
+ // assert "not 200" to capture the no-access semantic without
+ // depending on whether the guard that fires first is auth (403)
+ // or findResource (404). Both block the user.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:prompts:some-other-slug"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(200);
+ });
+
+ it("JWT read:runs: not 200 (type mismatch β no access)", async () => {
+ // Same caveat as above re: findResource ordering.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(200);
+ });
+
+ it("JWT admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ describe("Prompts override β POST /api/v1/prompts/:slug/override (update action)", () => {
+ const SLUG = "test-prompt";
+ const path = `/api/v1/prompts/${SLUG}/override`;
+ const post = (headers: Record) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify({ content: "test" }),
+ });
+
+ it("missing auth: 401", async () => {
+ const res = await post({});
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT write:prompts: matching (ACTION_ALIASES writeβupdate): passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`write:prompts:${SLUG}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT write:prompts (type-level): passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:prompts: 403 (action mismatch β read NOT aliased to update)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`read:prompts:${SLUG}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT write:prompts:: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["write:prompts:some-other-slug"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT admin: passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ describe("Prompts promote/reactivate (sanity, update action)", () => {
+ it("promote: JWT write:prompts (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch("/api/v1/prompts/some-slug/promote", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("reactivate: JWT read:prompts: 403 (action mismatch)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] },
+ expirationTime: "15m",
+ });
+ // Body must satisfy the route's schema ({ version: positive int })
+ // β otherwise body validation 400s before authorization runs.
+ const res = await server.webapp.fetch(
+ "/api/v1/prompts/some-slug/override/reactivate",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
+ body: JSON.stringify({ version: 1 }),
+ }
+ );
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // Deployments + query routes (TRI-8739). Read-only family with
+ // distinct resource types per route:
+ // - GET /api/v1/deployments { type: "deployments", id: "list" }
+ // - GET /api/v1/query/schema { type: "query", id: "schema" }
+ // - GET /api/v1/query/dashboards { type: "query", id: "dashboards" }
+ // - POST /api/v1/query body-derived: detectTables(query) β
+ // [{ type: "query", id }] or
+ // { type: "query", id: "all" } if none
+ describe("Deployments list β GET /api/v1/deployments", () => {
+ const path = "/api/v1/deployments";
+ const get = (headers: Record) =>
+ getTestServer().webapp.fetch(path, { headers });
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ it("private API key: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const res = await get({ Authorization: `Bearer ${seed.apiKey}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:deployments: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:all: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:runs (type mismatch): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT write:deployments (action mismatch β read route): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:deployments"] },
+ expirationTime: "15m",
+ });
+ const res = await get({ Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe("Query schema β GET /api/v1/query/schema (sanity)", () => {
+ const path = "/api/v1/query/schema";
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT read:query (type-level): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT read:deployments (type mismatch): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe("Query dashboards β GET /api/v1/query/dashboards (sanity)", () => {
+ const path = "/api/v1/query/dashboards";
+
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch(path);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT read:query: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] },
+ expirationTime: "15m",
+ });
+ const res = await server.webapp.fetch(path, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ describe("Query ad-hoc β POST /api/v1/query (body-derived resource)", () => {
+ const path = "/api/v1/query";
+ const post = (body: object, headers: Record) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...headers },
+ body: JSON.stringify(body),
+ });
+
+ it("missing auth: 401", async () => {
+ const res = await post({ query: "SELECT * FROM runs" }, {});
+ expect(res.status).toBe(401);
+ });
+
+ it("body with table 'runs' + JWT read:query:runs: auth passes (any-match)", async () => {
+ // detectTables pulls 'runs' from FROM-clause. Resource becomes
+ // [{ type: "query", id: "runs" }]. Scope read:query:runs matches.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ query: "SELECT * FROM runs" }, {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("body with no detectable tables (defaults id='all') + JWT read:query: auth passes", async () => {
+ // A query with no FROM clause β detectTables returns [] β
+ // resource is { type: "query", id: "all" }. Type-level read:query
+ // matches.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("multi-table body + JWT scoped to only one of them: 403 (every-table semantics)", async () => {
+ // detectTables matches `\bFROM\s+\b` per query-schema, so
+ // a query with two FROM clauses (e.g. UNION) yields a multi-
+ // entry resource list. The route wraps it in everyResource so
+ // AND semantics apply: a JWT scoped to one detected table
+ // cannot submit a query that also reads the other.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] },
+ expirationTime: "15m",
+ });
+ const res = await post(
+ {
+ query:
+ "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics",
+ },
+ { Authorization: `Bearer ${jwt}` }
+ );
+ expect(res.status).toBe(403);
+ });
+
+ it("multi-table body + JWT scoped to all detected tables: auth passes", async () => {
+ // Companion to the every-table 403 above β when the JWT covers
+ // every detected table the AND-check passes.
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:query:runs", "read:query:metrics"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post(
+ {
+ query:
+ "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics",
+ },
+ { Authorization: `Bearer ${jwt}` }
+ );
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("body with table 'runs' + JWT read:query:other_table: 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: ["read:query:other_table"],
+ },
+ expirationTime: "15m",
+ });
+ const res = await post({ query: "SELECT * FROM runs" }, {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it("JWT admin: auth passes regardless of body", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ query: "SELECT * FROM runs" }, {
+ Authorization: `Bearer ${jwt}`,
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("JWT write:query (action mismatch): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: { pub: true, sub: seed.environment.id, scopes: ["write:query"] },
+ expirationTime: "15m",
+ });
+ const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` });
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe("Batch retrieve β GET /realtime/v1/batches/:batchId (sanity)", () => {
+ it("missing auth: 401", async () => {
+ const res = await getTestServer().webapp.fetch("/realtime/v1/batches/batch_anything");
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT read:batch:: auth passes on real batch", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const seeded = await seedTestRun(server.prisma, {
+ environmentId: seed.environment.id,
+ projectId: seed.project.id,
+ withBatch: true,
+ });
+ const jwt = await generateJWT({
+ secretKey: seed.apiKey,
+ payload: {
+ pub: true,
+ sub: seed.environment.id,
+ scopes: [`read:batch:${seeded.batchFriendlyId}`],
+ },
+ expirationTime: "15m",
+ });
+ const res = await getTestServer().webapp.fetch(
+ `/realtime/v1/batches/${seeded.batchFriendlyId}`,
+ { headers: { Authorization: `Bearer ${jwt}` } }
+ );
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // Sessions β JWT scope matrix.
+ //
+ // The session routes were authored against the pre-RBAC apiBuilder
+ // and used the legacy `superScopes: [...]` field to whitelist broad
+ // access. After TRI-8719 superScopes is dead code; the equivalent
+ // bypass is expressed via:
+ // - multi-key resource arrays (one element per addressable key,
+ // plus a collection-level `{ type: "sessions" }` for type-only
+ // scopes)
+ // - the JWT ability's `*:all` and `admin*` wildcard branches
+ //
+ // These tests lock in that the migration's "no JWT regresses"
+ // promise holds for sessions. Each historical superScope becomes a
+ // positive test, and per-task narrowing gets negative coverage.
+ describe("Sessions β JWT scope matrix", () => {
+ // ---- List sessions: GET /api/v1/sessions
+ //
+ // Resource: [{ type: "tasks", id: } per filter id, { type: "sessions" }]
+ // Old superScopes: ["read:sessions", "read:all", "admin"]
+ describe("List sessions β GET /api/v1/sessions", () => {
+ const path = (taskFilter?: string) =>
+ taskFilter
+ ? `/api/v1/sessions?filter[taskIdentifier]=${taskFilter}`
+ : "/api/v1/sessions";
+
+ const fetchWithJwt = async (jwt: string, taskFilter?: string) =>
+ getTestServer().webapp.fetch(path(taskFilter), {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ // The handler reads from ClickHouse via SessionsRepository, which
+ // isn't wired up in the e2e webapp container β so successful auth
+ // surfaces as 5xx after the handler errors. Assert "not 401, not
+ // 403" rather than 200 for the auth-passes paths.
+
+ it("read:tasks:foo on filter=foo: auth passes", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:tasks:foo",
+ ]);
+ const res = await fetchWithJwt(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("read:tasks:bar on filter=foo: 403 (per-task narrowing)", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:tasks:bar",
+ ]);
+ const res = await fetchWithJwt(jwt, "foo");
+ expect(res.status).toBe(403);
+ });
+
+ it("read:sessions on filter=foo: auth passes (was a superScope)", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:sessions",
+ ]);
+ const res = await fetchWithJwt(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("read:sessions on no-filter list: auth passes", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:sessions",
+ ]);
+ const res = await fetchWithJwt(jwt);
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("read:all: auth passes (was a superScope)", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]);
+ const res = await fetchWithJwt(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("admin: auth passes (was a superScope)", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
+ const res = await fetchWithJwt(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("read:tasks (type-only) on no-filter list: 403 (filter is sessions, not tasks)", async () => {
+ // No filter β resource is `{ type: "sessions" }` only. read:tasks
+ // doesn't match the sessions type, so 403 β explicit narrowing.
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:tasks",
+ ]);
+ const res = await fetchWithJwt(jwt);
+ expect(res.status).toBe(403);
+ });
+
+ it("write:tasks:foo (wrong action) on filter=foo: 403", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:tasks:foo",
+ ]);
+ const res = await fetchWithJwt(jwt, "foo");
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ---- Create session: POST /api/v1/sessions
+ //
+ // Resource: [{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }]
+ // Old superScopes: ["write:sessions", "admin"]
+ describe("Create session β POST /api/v1/sessions", () => {
+ const path = "/api/v1/sessions";
+
+ const post = async (jwt: string, taskIdentifier: string) =>
+ getTestServer().webapp.fetch(path, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ type: "chat.agent",
+ taskIdentifier,
+ triggerConfig: { basePayload: {} },
+ }),
+ });
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("write:tasks:foo matching body: auth passes", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:tasks:foo",
+ ]);
+ const res = await post(jwt, "foo");
+ // Body validation / handler can fail later (404 if task is
+ // missing, 400 for invalid body) β we only care that auth
+ // didn't reject.
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("write:tasks:bar mismatching body: 403", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:tasks:bar",
+ ]);
+ const res = await post(jwt, "foo");
+ expect(res.status).toBe(403);
+ });
+
+ it("write:sessions: auth passes (was a superScope)", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:sessions",
+ ]);
+ const res = await post(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("write:all: auth passes", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);
+ const res = await post(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("admin: auth passes", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
+ const res = await post(jwt, "foo");
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("read:tasks:foo (wrong action): 403", async () => {
+ const seed = await seedTestEnvironment(getTestServer().prisma);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:tasks:foo",
+ ]);
+ const res = await post(jwt, "foo");
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ---- Retrieve session: GET /api/v1/sessions/:session
+ //
+ // Resource: multi-key array of `{ type: "sessions", id }` entries
+ // for friendlyId and externalId (when set).
+ // Old superScopes: ["read:sessions", "read:all", "admin"]
+ describe("Retrieve session β GET /api/v1/sessions/:session", () => {
+ const get = async (sessionParam: string, jwt: string) =>
+ getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("read:sessions:: 200", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ `read:sessions:${session.friendlyId}`,
+ ]);
+ const res = await get(session.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("read:sessions: on externalId URL form: 200 (multi-key)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ `read:sessions:${session.externalId}`,
+ ]);
+ const res = await get(session.externalId!, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("read:sessions (type-only): 200", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:sessions",
+ ]);
+ const res = await get(session.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("read:sessions:other (non-matching id): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:sessions:not-this-session",
+ ]);
+ const res = await get(session.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ---- Update session: PATCH /api/v1/sessions/:session
+ //
+ // action: "admin" β only admin-tier scopes (or wildcards) satisfy.
+ // Old superScopes: ["admin:sessions", "admin:all", "admin"]
+ describe("Update session β PATCH /api/v1/sessions/:session", () => {
+ const patch = async (sessionParam: string, jwt: string) =>
+ getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, {
+ method: "PATCH",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ tags: ["updated"] }),
+ });
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("admin:sessions:: 200", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ `admin:sessions:${session.friendlyId}`,
+ ]);
+ const res = await patch(session.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("admin:sessions (type-only): 200", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "admin:sessions",
+ ]);
+ const res = await patch(session.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("admin:all: 200", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin:all"]);
+ const res = await patch(session.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("admin (bare): 200", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
+ const res = await patch(session.friendlyId, jwt);
+ expect(res.status).toBe(200);
+ });
+
+ it("write:sessions (wrong action β admin not aliased from write): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:sessions",
+ ]);
+ const res = await patch(session.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ---- Close session: POST /api/v1/sessions/:session/close
+ //
+ // action: "admin" β same matrix as PATCH.
+ describe("Close session β POST /api/v1/sessions/:session/close", () => {
+ const close = async (sessionParam: string, jwt: string) =>
+ getTestServer().webapp.fetch(
+ `/api/v1/sessions/${sessionParam}/close`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ reason: "test" }),
+ }
+ );
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("admin:sessions: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "admin:sessions",
+ ]);
+ const res = await close(session.friendlyId, jwt);
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("admin: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
+ const res = await close(session.friendlyId, jwt);
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("write:sessions: 403 (admin action)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:sessions",
+ ]);
+ const res = await close(session.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ---- End-and-continue: POST /api/v1/sessions/:session/end-and-continue
+ //
+ // action: "write" β multi-key sessions resource.
+ describe("End-and-continue β POST /api/v1/sessions/:session/end-and-continue", () => {
+ const endAndContinue = async (sessionParam: string, jwt: string) =>
+ getTestServer().webapp.fetch(
+ `/api/v1/sessions/${sessionParam}/end-and-continue`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/json",
+ },
+ // Body shape doesn't matter for auth β handler runs after
+ // the auth check so any 4xx here means auth passed.
+ body: JSON.stringify({
+ reason: "test",
+ callingRunId: "run_does_not_exist",
+ }),
+ }
+ );
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("write:sessions: auth passes (was a superScope)", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:sessions",
+ ]);
+ const res = await endAndContinue(session.friendlyId, jwt);
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("write:all: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);
+ const res = await endAndContinue(session.friendlyId, jwt);
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("read:sessions (wrong action): 403", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:sessions",
+ ]);
+ const res = await endAndContinue(session.friendlyId, jwt);
+ expect(res.status).toBe(403);
+ });
+ });
+
+ // ---- Realtime IO: GET (subscribe) and PUT (initialize)
+ //
+ // Both go through createLoaderApiRoute / createActionApiRoute β same
+ // multi-key sessions resource. No deep matrix here; one positive
+ // test per old superScope per method is enough.
+ describe("Realtime IO β /realtime/v1/sessions/:session/:io", () => {
+ const ioPath = (sessionParam: string) =>
+ `/realtime/v1/sessions/${sessionParam}/in`;
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("GET with read:sessions (was a superScope): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "read:sessions",
+ ]);
+ const res = await server.webapp.fetch(ioPath(session.friendlyId), {
+ method: "HEAD",
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("GET with read:all: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]);
+ const res = await server.webapp.fetch(ioPath(session.friendlyId), {
+ method: "HEAD",
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("PUT with write:sessions (was a superScope): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:sessions",
+ ]);
+ const res = await server.webapp.fetch(ioPath(session.friendlyId), {
+ method: "PUT",
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+
+ // ---- Realtime append: POST /realtime/v1/sessions/:session/:io/append
+ //
+ // action: "write" β multi-key sessions resource.
+ describe("Realtime append β POST /realtime/v1/sessions/:session/:io/append", () => {
+ const appendPath = (sessionParam: string) =>
+ `/realtime/v1/sessions/${sessionParam}/in/append`;
+
+ const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
+ generateJWT({
+ secretKey: apiKey,
+ payload: { pub: true, sub: envId, scopes },
+ expirationTime: "15m",
+ });
+
+ it("write:sessions (was a superScope): auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
+ "write:sessions",
+ ]);
+ const res = await server.webapp.fetch(appendPath(session.friendlyId), {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/octet-stream",
+ },
+ body: new Uint8Array([1, 2, 3]),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+
+ it("write:all: auth passes", async () => {
+ const server = getTestServer();
+ const seed = await seedTestEnvironment(server.prisma);
+ const session = await seedTestApiSession(server.prisma, seed.environment);
+ const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);
+ const res = await server.webapp.fetch(appendPath(session.friendlyId), {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/octet-stream",
+ },
+ body: new Uint8Array([1, 2, 3]),
+ });
+ expect(res.status).not.toBe(401);
+ expect(res.status).not.toBe(403);
+ });
+ });
+ });
+});
diff --git a/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts b/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
new file mode 100644
index 00000000000..d5d462f6c32
--- /dev/null
+++ b/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
@@ -0,0 +1,216 @@
+// Cross-cutting auth-layer behaviours that aren't tied to a specific route
+// family β see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
+// cross-env mismatch, force-fallback toggle.
+//
+// Strategy: pick one representative API-key route
+// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT
+// route (POST /api/v1/waitpoints/tokens//complete) and exercise the
+// edge cases against those. The route choice doesn't matter β the
+// auth layer is shared across every API route via apiBuilder.server.ts.
+// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial
+// cases (missing/invalid key, basic JWT pass, soft-deleted project);
+// this file adds cases that need explicit fixture setup.
+
+import { generateJWT } from "@trigger.dev/core/v3/jwt";
+import { SignJWT } from "jose";
+import { describe, expect, it } from "vitest";
+import { getTestServer } from "./helpers/sharedTestServer";
+import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
+
+describe("Cross-cutting", () => {
+ it("shared prisma client can read from the postgres container", async () => {
+ const server = getTestServer();
+ const count = await server.prisma.user.count();
+ expect(count).toBeGreaterThanOrEqual(0);
+ });
+
+ // The auth path falls back to RevokedApiKey when a key isn't found
+ // in RuntimeEnvironment β letting customers continue to use a key
+ // for a configurable grace window after rotation. See
+ // models/runtimeEnvironment.server.ts. The grace lookup matches by
+ // (apiKey AND expiresAt > now) and rehydrates the env via the FK.
+ describe("Revoked API key grace window", () => {
+ const route = "/api/v1/runs/run_doesnotexist/result";
+
+ it("revoked key within grace (expiresAt > now): auth passes", async () => {
+ const server = getTestServer();
+ const { environment } = await seedTestEnvironment(server.prisma);
+ // Mint a fresh "rotated" key that doesn't exist on any env, then
+ // record it as recently revoked with a future grace expiry.
+ const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`;
+ await server.prisma.revokedApiKey.create({
+ data: {
+ apiKey: rotatedKey,
+ runtimeEnvironmentId: environment.id,
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day
+ },
+ });
+ const res = await server.webapp.fetch(route, {
+ headers: { Authorization: `Bearer ${rotatedKey}` },
+ });
+ // Auth passed β the route's resource lookup just doesn't find
+ // run_doesnotexist. The point is NOT 401.
+ expect(res.status).not.toBe(401);
+ });
+
+ it("revoked key past grace (expiresAt < now): 401", async () => {
+ const server = getTestServer();
+ const { environment } = await seedTestEnvironment(server.prisma);
+ const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`;
+ await server.prisma.revokedApiKey.create({
+ data: {
+ apiKey: expiredKey,
+ runtimeEnvironmentId: environment.id,
+ expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute
+ },
+ });
+ const res = await server.webapp.fetch(route, {
+ headers: { Authorization: `Bearer ${expiredKey}` },
+ });
+ expect(res.status).toBe(401);
+ });
+ });
+
+ // JWT edge cases beyond what the smoke matrix covers (which only
+ // checks "wrong key" and "missing scope"). All target the same
+ // representative JWT route β the JWT validator is shared across
+ // routes via apiBuilder, so coverage here generalises.
+ describe("JWT edge cases", () => {
+ const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete";
+
+ async function postWithJwt(jwt: string) {
+ const server = getTestServer();
+ return server.webapp.fetch(route, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${jwt}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+ }
+
+ it("JWT with expirationTime in the past: 401", async () => {
+ const server = getTestServer();
+ const { environment } = await seedTestEnvironment(server.prisma);
+ // generateJWT only accepts string expirationTimes (relative, like
+ // "15m"). To create a definitively-expired token use jose
+ // directly with an absolute past timestamp.
+ const secret = new TextEncoder().encode(environment.apiKey);
+ const jwt = await new SignJWT({
+ pub: true,
+ sub: environment.id,
+ scopes: ["write:waitpoints"],
+ })
+ .setIssuer("https://id.trigger.dev")
+ .setAudience("https://api.trigger.dev")
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt(0)
+ .setExpirationTime(1) // 1970-01-01 β definitively expired
+ .sign(secret);
+
+ const res = await postWithJwt(jwt);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT with pub: false: 401", async () => {
+ const server = getTestServer();
+ const { environment } = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] },
+ expirationTime: "15m",
+ });
+ // pub: false means "this token isn't meant for client-side use"
+ // β the auth layer rejects it for the same-class JWT routes.
+ const res = await postWithJwt(jwt);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT with no sub claim: 401", async () => {
+ const server = getTestServer();
+ const { environment } = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: environment.apiKey,
+ payload: { pub: true, scopes: ["write:waitpoints"] },
+ expirationTime: "15m",
+ });
+ // No sub claim β auth can't resolve which env the token belongs
+ // to, so it must reject. (sub carries the env id.)
+ const res = await postWithJwt(jwt);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT signed with another env's apiKey (cross-env): 401", async () => {
+ const server = getTestServer();
+ // env A's id but signed with env B's apiKey β sub-vs-signature
+ // mismatch the auth layer must catch.
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedTestEnvironment(server.prisma);
+ const jwt = await generateJWT({
+ secretKey: b.apiKey, // <-- WRONG key relative to the sub claim
+ payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] },
+ expirationTime: "15m",
+ });
+ const res = await postWithJwt(jwt);
+ expect(res.status).toBe(401);
+ });
+
+ it("JWT malformed (three parts but invalid base64 in payload): 401", async () => {
+ // Three "."-separated parts so the JWT shape gate sees it as a
+ // candidate, but the payload segment is non-base64 garbage.
+ // Validator must surface this as 401, not 500.
+ const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature";
+ const res = await postWithJwt(malformed);
+ expect(res.status).toBe(401);
+ });
+ });
+
+ // The auth layer resolves the JWT's env from the `sub` claim β NOT
+ // from the route path. So a JWT for env A hitting a route that
+ // fetches a resource from env B should never accidentally see env
+ // B's data. Test by minting a JWT for env A and asking for a
+ // resource that lives in env B β expect 404 (not 200).
+ describe("Cross-environment: JWT auth resolves env from sub, not URL", () => {
+ it("env A's JWT cannot read env B's resource: 404", async () => {
+ const server = getTestServer();
+ const a = await seedTestEnvironment(server.prisma);
+ const b = await seedTestEnvironment(server.prisma);
+
+ // Seed a real-ish run row in env B so the route would have
+ // something to find IF auth resolved the env from the URL.
+ const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`;
+ await server.prisma.taskRun.create({
+ data: {
+ friendlyId,
+ taskIdentifier: "test-task",
+ payload: "{}",
+ payloadType: "application/json",
+ traceId: `trace_${Math.random().toString(36).slice(2)}`,
+ spanId: `span_${Math.random().toString(36).slice(2)}`,
+ runtimeEnvironmentId: b.environment.id,
+ projectId: b.project.id,
+ organizationId: b.organization.id,
+ engine: "V2",
+ status: "COMPLETED_SUCCESSFULLY",
+ queue: "task/test-task",
+ },
+ });
+
+ const jwt = await generateJWT({
+ secretKey: a.apiKey,
+ payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] },
+ expirationTime: "15m",
+ });
+
+ const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, {
+ headers: { Authorization: `Bearer ${jwt}` },
+ });
+ // The route resolves runs scoped to the JWT's env (env A). The
+ // run lives in env B, so env A's view returns "not found" β
+ // critically, NOT 200.
+ expect(res.status).not.toBe(200);
+ expect([401, 404]).toContain(res.status);
+ });
+ });
+});
diff --git a/apps/webapp/test/auth-dashboard.e2e.full.test.ts b/apps/webapp/test/auth-dashboard.e2e.full.test.ts
new file mode 100644
index 00000000000..948b9c6c0cf
--- /dev/null
+++ b/apps/webapp/test/auth-dashboard.e2e.full.test.ts
@@ -0,0 +1,122 @@
+// Comprehensive dashboard session-auth tests β see TRI-8742.
+// Each test seeds a User + session cookie via seedTestUser / seedTestSession
+// (helpers/seedTestSession.ts) and hits the shared webapp container.
+
+import { describe, expect, it } from "vitest";
+import { getTestServer } from "./helpers/sharedTestServer";
+import { seedTestSession, seedTestUser } from "./helpers/seedTestSession";
+
+describe("Dashboard", () => {
+ it("shared webapp container redirects /admin/concurrency to /login when unauthenticated", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" });
+ expect(res.status).toBe(302);
+ });
+
+ // Admin pages migrated to dashboardLoader({ authorization: { requireSuper: true } })
+ // in TRI-8717. The dashboardLoader resolves auth in three stages:
+ // 1. No session β redirect to /login?redirectTo=.
+ // 2. Session, user.admin === false β redirect to / (no path leakage).
+ // 3. Session, user.admin === true β run the loader handler.
+ //
+ // Coverage strategy: pick three representative routes (the index, a
+ // tabbed sub-page, and the back-office tree) rather than all 14 β
+ // they all share the same dashboardLoader config so testing every
+ // file would just confirm the wrapper works, which the harness
+ // already proves. If the wrapper config drifts per-route in the
+ // future, add targeted tests for the divergent ones.
+ describe("Admin pages β requireSuper gate", () => {
+ const adminRoutes = [
+ "/admin",
+ "/admin/concurrency",
+ "/admin/back-office",
+ ];
+
+ for (const path of adminRoutes) {
+ describe(`GET ${path}`, () => {
+ it("no session: redirects to /login?redirectTo=", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(path, { redirect: "manual" });
+ expect(res.status).toBe(302);
+ const location = res.headers.get("location") ?? "";
+ expect(location).toContain("/login");
+ // Path leaks deliberately so a successful login bounces the
+ // user back to where they were headed.
+ expect(location).toContain(`redirectTo=${encodeURIComponent(path)}`);
+ });
+
+ it("session for non-admin user: redirects to / (no path leakage)", async () => {
+ const server = getTestServer();
+ const user = await seedTestUser(server.prisma, { admin: false });
+ const cookie = await seedTestSession({ userId: user.id });
+ const res = await server.webapp.fetch(path, {
+ redirect: "manual",
+ headers: { Cookie: cookie },
+ });
+ expect(res.status).toBe(302);
+ const location = res.headers.get("location") ?? "";
+ // unauthorizedRedirect default in dashboardBuilder is "/".
+ // A non-admin landing on /admin shouldn't get redirectTo
+ // back to /admin once they upgrade β they're not getting in
+ // by re-auth.
+ expect(new URL(location, "http://localhost").pathname).toBe("/");
+ });
+
+ it("session for admin user: 2xx", async () => {
+ const server = getTestServer();
+ const user = await seedTestUser(server.prisma, { admin: true });
+ const cookie = await seedTestSession({ userId: user.id });
+ const res = await server.webapp.fetch(path, {
+ redirect: "manual",
+ headers: { Cookie: cookie },
+ });
+ // Loader handler ran β could be 200 (HTML) or 204 (Remix
+ // _data fetch). Either way, NOT a redirect.
+ expect(res.status).toBeLessThan(300);
+ });
+ });
+ }
+ });
+
+ // Action handlers behind requireSuper used to return 403 Unauthorized
+ // pre-RBAC β now they redirect to / via dashboardAction's
+ // unauthorizedRedirect. The ticket flagged this as a behaviour
+ // change worth locking in (any XHR fetcher that branched on 403
+ // would have regressed silently). Use admin.feature-flags POST as
+ // the canary β it's the simplest action of the bunch.
+ describe("Admin action β requireSuper gate (admin.feature-flags POST)", () => {
+ const path = "/admin/feature-flags";
+
+ it("no session: redirects to /login (POST)", async () => {
+ const server = getTestServer();
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ body: JSON.stringify({}),
+ headers: { "Content-Type": "application/json" },
+ redirect: "manual",
+ });
+ expect(res.status).toBe(302);
+ const location = res.headers.get("location") ?? "";
+ expect(location).toContain("/login");
+ });
+
+ it("session for non-admin user: redirects to / (was 403 pre-RBAC)", async () => {
+ const server = getTestServer();
+ const user = await seedTestUser(server.prisma, { admin: false });
+ const cookie = await seedTestSession({ userId: user.id });
+ const res = await server.webapp.fetch(path, {
+ method: "POST",
+ body: JSON.stringify({}),
+ headers: { "Content-Type": "application/json", Cookie: cookie },
+ redirect: "manual",
+ });
+ // Behaviour change from the TRI-8717 migration: the legacy
+ // path returned 403 Unauthorized; dashboardAction returns a
+ // 302 to "/" instead. Any client code branching on 403 needs
+ // updating β locking this in so a silent regression is loud.
+ expect(res.status).toBe(302);
+ const location = res.headers.get("location") ?? "";
+ expect(new URL(location, "http://localhost").pathname).toBe("/");
+ });
+ });
+});
diff --git a/apps/webapp/test/helpers/seedTestApiSession.ts b/apps/webapp/test/helpers/seedTestApiSession.ts
new file mode 100644
index 00000000000..cb98c1798c9
--- /dev/null
+++ b/apps/webapp/test/helpers/seedTestApiSession.ts
@@ -0,0 +1,47 @@
+// Inserts a `Session` row directly via Prisma so route auth tests can
+// exercise routes that resolve a session by friendlyId or externalId.
+//
+// Note: not to be confused with `seedTestSession` in this directory β
+// that helper builds a *dashboard cookie session* for cookie-auth tests.
+// This helper builds an *agent-stream Session row* (the chat.agent
+// runtime concept).
+
+import type { PrismaClient, Session } from "@trigger.dev/database";
+import { randomBytes } from "node:crypto";
+
+function randomHex(len = 12): string {
+ return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
+}
+
+export async function seedTestApiSession(
+ prisma: PrismaClient,
+ env: {
+ id: string;
+ type: string;
+ organizationId: string;
+ projectId: string;
+ },
+ overrides?: { taskIdentifier?: string; externalId?: string | null }
+): Promise {
+ const suffix = randomHex(8);
+ return prisma.session.create({
+ data: {
+ id: `session_${suffix}`,
+ friendlyId: `session_${suffix}`,
+ // `null` lets a caller exercise the externalId-absent code path
+ // (single-id auth resource); omit the override to get a unique
+ // externalId for the multi-key path.
+ externalId:
+ overrides?.externalId === null
+ ? null
+ : overrides?.externalId ?? `ext_${suffix}`,
+ type: "chat.agent",
+ projectId: env.projectId,
+ runtimeEnvironmentId: env.id,
+ environmentType: env.type as Session["environmentType"],
+ organizationId: env.organizationId,
+ taskIdentifier: overrides?.taskIdentifier ?? `agent_${suffix}`,
+ triggerConfig: { basePayload: { messages: [], trigger: "preload" } },
+ },
+ });
+}
diff --git a/apps/webapp/test/helpers/seedTestPAT.ts b/apps/webapp/test/helpers/seedTestPAT.ts
new file mode 100644
index 00000000000..d977bf5882e
--- /dev/null
+++ b/apps/webapp/test/helpers/seedTestPAT.ts
@@ -0,0 +1,59 @@
+import type { PrismaClient } from "@trigger.dev/database";
+import { createCipheriv, createHash, randomBytes } from "node:crypto";
+
+// Must match ENCRYPTION_KEY in internal-packages/testcontainers/src/webapp.ts
+const ENCRYPTION_KEY = "test-encryption-key-for-e2e!!!!!";
+
+function hashToken(token: string): string {
+ return createHash("sha256").update(token).digest("hex");
+}
+
+function encryptToken(value: string, key: string) {
+ const nonce = randomBytes(12);
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
+ let encrypted = cipher.update(value, "utf8", "hex");
+ encrypted += cipher.final("hex");
+ return {
+ nonce: nonce.toString("hex"),
+ ciphertext: encrypted,
+ tag: cipher.getAuthTag().toString("hex"),
+ };
+}
+
+function obfuscate(token: string): string {
+ return `${token.slice(0, 11)}${"β’".repeat(20)}${token.slice(-4)}`;
+}
+
+export async function seedTestUser(prisma: PrismaClient, overrides?: { admin?: boolean }) {
+ const suffix = randomBytes(6).toString("hex");
+ return prisma.user.create({
+ data: {
+ email: `pat-user-${suffix}@test.local`,
+ authenticationMethod: "MAGIC_LINK",
+ admin: overrides?.admin ?? false,
+ },
+ });
+}
+
+// Seeds a PersonalAccessToken row using the same hashing/encryption scheme as
+// webapp's services/personalAccessToken.server.ts so the webapp subprocess can
+// authenticate against it.
+export async function seedTestPAT(
+ prisma: PrismaClient,
+ userId: string,
+ opts: { revoked?: boolean } = {}
+): Promise<{ token: string; id: string }> {
+ const token = `tr_pat_${randomBytes(20).toString("hex")}`;
+ const encrypted = encryptToken(token, ENCRYPTION_KEY);
+ const row = await prisma.personalAccessToken.create({
+ data: {
+ name: "e2e-test-pat",
+ userId,
+ encryptedToken: encrypted,
+ hashedToken: hashToken(token),
+ obfuscatedToken: obfuscate(token),
+ revokedAt: opts.revoked ? new Date() : null,
+ },
+ });
+ return { token, id: row.id };
+}
diff --git a/apps/webapp/test/helpers/seedTestRun.ts b/apps/webapp/test/helpers/seedTestRun.ts
new file mode 100644
index 00000000000..44137e45005
--- /dev/null
+++ b/apps/webapp/test/helpers/seedTestRun.ts
@@ -0,0 +1,61 @@
+import type { PrismaClient, TaskRun } from "@trigger.dev/database";
+import { customAlphabet, nanoid } from "nanoid";
+
+const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
+
+export interface SeededRun {
+ run: TaskRun;
+ runFriendlyId: string; // `run_...`
+ batchFriendlyId?: string; // `batch_...` when { withBatch: true }
+}
+
+// Minimum-viable TaskRun for auth-layer e2e tests β enough fields for
+// ApiRetrieveRunPresenter.findRun to return it and for the authorization.resource
+// callback to populate `runs`, `tags`, `batch`, `tasks` keys.
+export async function seedTestRun(
+ prisma: PrismaClient,
+ opts: {
+ environmentId: string;
+ projectId: string;
+ runTags?: string[];
+ withBatch?: boolean;
+ }
+): Promise {
+ const runInternalId = idGenerator();
+ const runFriendlyId = `run_${runInternalId}`;
+
+ let batchInternalId: string | undefined;
+ if (opts.withBatch) {
+ batchInternalId = idGenerator();
+ await prisma.batchTaskRun.create({
+ data: {
+ id: batchInternalId,
+ friendlyId: `batch_${batchInternalId}`,
+ runtimeEnvironmentId: opts.environmentId,
+ },
+ });
+ }
+
+ const run = await prisma.taskRun.create({
+ data: {
+ id: runInternalId,
+ friendlyId: runFriendlyId,
+ taskIdentifier: "test-task",
+ payload: "{}",
+ payloadType: "application/json",
+ traceId: nanoid(32),
+ spanId: nanoid(16),
+ queue: "task/test-task",
+ runtimeEnvironmentId: opts.environmentId,
+ projectId: opts.projectId,
+ runTags: opts.runTags ?? [],
+ batchId: batchInternalId,
+ },
+ });
+
+ return {
+ run,
+ runFriendlyId,
+ batchFriendlyId: batchInternalId ? `batch_${batchInternalId}` : undefined,
+ };
+}
diff --git a/apps/webapp/test/helpers/seedTestSession.ts b/apps/webapp/test/helpers/seedTestSession.ts
new file mode 100644
index 00000000000..3e51c5c2c63
--- /dev/null
+++ b/apps/webapp/test/helpers/seedTestSession.ts
@@ -0,0 +1,58 @@
+// Produces a `Cookie:` header value for an authenticated session that the
+// webapp under test will accept. Mirrors the webapp's
+// `services/sessionStorage.server.ts` config exactly β the SESSION_SECRET
+// must match what the webapp container was started with (see
+// `internal-packages/testcontainers/src/webapp.ts` β currently
+// "test-session-secret-for-e2e-tests").
+//
+// Used by dashboard auth tests (TRI-8742). Each test seeds its own user +
+// session so test order doesn't matter.
+
+import { createCookieSessionStorage } from "@remix-run/node";
+import type { PrismaClient } from "@trigger.dev/database";
+import { randomBytes } from "node:crypto";
+
+// Must match SESSION_SECRET in internal-packages/testcontainers/src/webapp.ts.
+const SESSION_SECRET = "test-session-secret-for-e2e-tests";
+
+// Shape of the session config in apps/webapp/app/services/sessionStorage.server.ts.
+const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: "__session",
+ sameSite: "lax",
+ path: "/",
+ httpOnly: true,
+ secrets: [SESSION_SECRET],
+ secure: false, // NODE_ENV is "test" in the spawned webapp.
+ maxAge: 60 * 60 * 24 * 365,
+ },
+});
+
+export async function seedTestUser(
+ prisma: PrismaClient,
+ overrides?: { admin?: boolean; email?: string }
+) {
+ const suffix = randomBytes(6).toString("hex");
+ return prisma.user.create({
+ data: {
+ email: overrides?.email ?? `e2e-${suffix}@test.local`,
+ authenticationMethod: "MAGIC_LINK",
+ admin: overrides?.admin ?? false,
+ },
+ });
+}
+
+// Builds the `Cookie:` header value for a given user. Set this on test
+// requests to the webapp to authenticate as that user.
+//
+// remix-auth's default sessionKey is "user" and stores AuthUser as
+// { userId } β see apps/webapp/app/services/authUser.ts.
+export async function seedTestSession(opts: { userId: string }): Promise {
+ const session = await sessionStorage.getSession();
+ session.set("user", { userId: opts.userId });
+ const setCookie = await sessionStorage.commitSession(session);
+ // commitSession returns "__session=; Path=/; ...". The Cookie
+ // header only needs the name=value pair.
+ const firstSegment = setCookie.split(";")[0];
+ return firstSegment;
+}
diff --git a/apps/webapp/test/helpers/seedTestUserProject.ts b/apps/webapp/test/helpers/seedTestUserProject.ts
new file mode 100644
index 00000000000..3512054ec1f
--- /dev/null
+++ b/apps/webapp/test/helpers/seedTestUserProject.ts
@@ -0,0 +1,67 @@
+import type { PrismaClient } from "@trigger.dev/database";
+import { randomBytes } from "node:crypto";
+import { seedTestPAT, seedTestUser } from "./seedTestPAT";
+
+function randomHex(len = 12): string {
+ return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
+}
+
+// Composite test fixture: a User, an Organization with that user as a
+// member, a Project owned by the org, a DEVELOPMENT environment, and a
+// non-revoked PAT for the user.
+//
+// Used by the PAT-comprehensive matrix (TRI-8741) to exercise routes
+// like GET /api/v1/projects/:projectRef/runs whose access check is
+// `findProjectByRef(externalRef, userId)` β i.e. the project's org
+// must have the userId in its members. seedTestEnvironment alone
+// doesn't create the OrgMember link, which is why this helper exists.
+//
+// Caller passes `projectDeleted: true` to test the soft-deleted-
+// project path; `userAdmin: true` to confirm the global admin flag
+// doesn't add cross-org visibility (the route is per-user).
+export async function seedTestUserProject(
+ prisma: PrismaClient,
+ opts: { userAdmin?: boolean; projectDeleted?: boolean } = {}
+) {
+ const suffix = randomHex(8);
+ const apiKey = `tr_dev_${randomHex(24)}`;
+ const pkApiKey = `pk_dev_${randomHex(24)}`;
+
+ const user = await seedTestUser(prisma, { admin: opts.userAdmin ?? false });
+
+ const organization = await prisma.organization.create({
+ data: {
+ title: `e2e-pat-org-${suffix}`,
+ slug: `e2e-pat-org-${suffix}`,
+ v3Enabled: true,
+ members: { create: { userId: user.id, role: "ADMIN" } },
+ },
+ });
+
+ const project = await prisma.project.create({
+ data: {
+ name: `e2e-pat-project-${suffix}`,
+ slug: `e2e-pat-proj-${suffix}`,
+ externalRef: `proj_${suffix}`,
+ organizationId: organization.id,
+ engine: "V2",
+ deletedAt: opts.projectDeleted ? new Date() : null,
+ },
+ });
+
+ const environment = await prisma.runtimeEnvironment.create({
+ data: {
+ slug: "dev",
+ type: "DEVELOPMENT",
+ apiKey,
+ pkApiKey,
+ shortcode: suffix.slice(0, 4),
+ projectId: project.id,
+ organizationId: organization.id,
+ },
+ });
+
+ const pat = await seedTestPAT(prisma, user.id);
+
+ return { user, organization, project, environment, pat };
+}
diff --git a/apps/webapp/test/helpers/seedTestWaitpoint.ts b/apps/webapp/test/helpers/seedTestWaitpoint.ts
new file mode 100644
index 00000000000..f4794b2b6c1
--- /dev/null
+++ b/apps/webapp/test/helpers/seedTestWaitpoint.ts
@@ -0,0 +1,29 @@
+import type { PrismaClient } from "@trigger.dev/database";
+import { customAlphabet } from "nanoid";
+
+// Must match friendlyId.ts IdUtil alphabet so generated IDs are valid.
+const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
+
+// Seeds a Waitpoint already in COMPLETED status so the waitpoints/:id/complete
+// handler short-circuits with { success: true }. That keeps the "auth passes"
+// assertion independent of run-engine workers (which are disabled in e2e).
+export async function seedTestWaitpoint(
+ prisma: PrismaClient,
+ opts: { environmentId: string; projectId: string }
+): Promise<{ id: string; friendlyId: string }> {
+ const internalId = idGenerator();
+ const friendlyId = `waitpoint_${internalId}`;
+ await prisma.waitpoint.create({
+ data: {
+ id: internalId,
+ friendlyId,
+ type: "MANUAL",
+ status: "COMPLETED",
+ idempotencyKey: internalId,
+ userProvidedIdempotencyKey: false,
+ environmentId: opts.environmentId,
+ projectId: opts.projectId,
+ },
+ });
+ return { id: internalId, friendlyId };
+}
diff --git a/apps/webapp/test/helpers/sharedTestServer.ts b/apps/webapp/test/helpers/sharedTestServer.ts
new file mode 100644
index 00000000000..35360fd221f
--- /dev/null
+++ b/apps/webapp/test/helpers/sharedTestServer.ts
@@ -0,0 +1,53 @@
+// Per-worker access to the shared TestServer started by globalSetup. Each
+// test file imports `getTestServer()` once at module top-level; the returned
+// value is a singleton within that worker process.
+//
+// `webapp.fetch(path)` prepends the shared baseUrl. The PrismaClient is
+// constructed lazily and disconnected on test-suite end via afterAll in the
+// importing file (or left to the worker shutting down).
+
+import { PrismaClient } from "@trigger.dev/database";
+import { afterAll, inject } from "vitest";
+
+interface SharedWebapp {
+ baseUrl: string;
+ fetch(path: string, init?: RequestInit): Promise;
+}
+
+interface SharedTestServer {
+ webapp: SharedWebapp;
+ prisma: PrismaClient;
+}
+
+let cached: SharedTestServer | undefined;
+
+export function getTestServer(): SharedTestServer {
+ if (cached) return cached;
+
+ const baseUrl = inject("baseUrl");
+ const databaseUrl = inject("databaseUrl");
+
+ if (!baseUrl || !databaseUrl) {
+ throw new Error(
+ "globalSetup didn't provide baseUrl/databaseUrl β run via vitest.e2e.full.config.ts"
+ );
+ }
+
+ const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
+
+ cached = {
+ webapp: {
+ baseUrl,
+ fetch: (path, init) => fetch(`${baseUrl}${path}`, init),
+ },
+ prisma,
+ };
+
+ // Disconnect the PrismaClient when the worker is done. globalSetup's
+ // teardown stops the container; this just releases the per-worker pool.
+ afterAll(async () => {
+ await prisma.$disconnect().catch(() => {});
+ });
+
+ return cached;
+}
diff --git a/apps/webapp/test/setup/global-e2e-full-setup.ts b/apps/webapp/test/setup/global-e2e-full-setup.ts
new file mode 100644
index 00000000000..31a9c15781f
--- /dev/null
+++ b/apps/webapp/test/setup/global-e2e-full-setup.ts
@@ -0,0 +1,28 @@
+// vitest globalSetup β runs once for the whole *.e2e.full.test.ts suite.
+// Boots one Postgres + Redis + webapp; tests connect to it via the
+// `baseUrl` / `databaseUrl` values provided to test workers below.
+//
+// Each test file recreates its own PrismaClient connected to the shared DB
+// (PrismaClient instances aren't serialisable across worker boundaries).
+
+import type { TestProject } from "vitest/node";
+import { startTestServer, type TestServer } from "@internal/testcontainers/webapp";
+
+let server: TestServer | undefined;
+
+export default async function setup(project: TestProject) {
+ server = await startTestServer();
+ project.provide("baseUrl", server.webapp.baseUrl);
+ project.provide("databaseUrl", server.databaseUrl);
+
+ return async () => {
+ await server?.stop().catch(() => {});
+ };
+}
+
+declare module "vitest" {
+ export interface ProvidedContext {
+ baseUrl: string;
+ databaseUrl: string;
+ }
+}
diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts
index 2e51eb3f17d..66f697706a5 100644
--- a/apps/webapp/vitest.config.ts
+++ b/apps/webapp/vitest.config.ts
@@ -4,7 +4,10 @@ import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
- exclude: ["test/**/*.e2e.test.ts"],
+ // *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts.
+ // *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts
+ // (needs a globalSetup-spawned webapp + Postgres container).
+ exclude: ["test/**/*.e2e.test.ts", "test/**/*.e2e.full.test.ts"],
globals: true,
pool: "forks",
},
diff --git a/apps/webapp/vitest.e2e.full.config.ts b/apps/webapp/vitest.e2e.full.config.ts
new file mode 100644
index 00000000000..47a4b0a8084
--- /dev/null
+++ b/apps/webapp/vitest.e2e.full.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from "vitest/config";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+// Comprehensive auth e2e suite β see TRI-8731. Boots a single
+// webapp + Postgres + Redis container in globalSetup and rapid-fires
+// tests against it across multiple test files. Distinct from the smoke
+// suite (vitest.e2e.config.ts) which uses per-file beforeAll setup and
+// runs in default CI on every PR.
+export default defineConfig({
+ test: {
+ include: ["test/**/*.e2e.full.test.ts"],
+ globalSetup: ["./test/setup/global-e2e-full-setup.ts"],
+ globals: true,
+ pool: "forks",
+ testTimeout: 60_000,
+ hookTimeout: 180_000,
+ },
+ // @ts-ignore
+ plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],
+});
diff --git a/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql b/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql
new file mode 100644
index 00000000000..d7cdc1a0c0b
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql
@@ -0,0 +1,5 @@
+-- TRI-8892: optional RBAC role assignment carried on the invite. When
+-- set, the accept-invite flow calls the loaded RBAC plugin's
+-- setUserRole(rbacRoleId) after the OrgMember insert; otherwise the
+-- runtime fallback derives the role from the legacy `role` column.
+ALTER TABLE "OrgMemberInvite" ADD COLUMN IF NOT EXISTS "rbacRoleId" TEXT;
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index 7ace893e4dd..989f6027a8f 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -282,6 +282,16 @@ model OrgMemberInvite {
email String
role OrgMemberRole @default(MEMBER)
+ /// Optional RBAC role to assign on invite acceptance. When set, the
+ /// accept-invite flow calls the loaded RBAC plugin's setUserRole with
+ /// this id after creating the OrgMember. Null = legacy behaviour, the
+ /// runtime fallback derives the role from `role` above.
+ ///
+ /// Plain text (not an FK) β the RBAC plugin's RbacRole table lives on
+ /// a separate schema (Drizzle, not Prisma) so we can't model the FK
+ /// here. Validation happens at write time (action) and read time
+ /// (acceptInvite).
+ rbacRoleId String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
diff --git a/internal-packages/rbac/package.json b/internal-packages/rbac/package.json
new file mode 100644
index 00000000000..d04089e4ff7
--- /dev/null
+++ b/internal-packages/rbac/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@trigger.dev/rbac",
+ "private": true,
+ "version": "0.0.1",
+ "main": "./dist/src/index.js",
+ "types": "./dist/src/index.d.ts",
+ "dependencies": {
+ "@trigger.dev/core": "workspace:*",
+ "@trigger.dev/plugins": "workspace:*"
+ },
+ "devDependencies": {
+ "@trigger.dev/database": "workspace:*",
+ "@types/node": "^20.14.14",
+ "rimraf": "6.0.1"
+ },
+ "scripts": {
+ "clean": "rimraf dist",
+ "typecheck": "tsc --noEmit",
+ "build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration",
+ "dev": "tsc --noEmit false --outDir dist --declaration --watch",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ }
+}
diff --git a/internal-packages/rbac/src/ability.test.ts b/internal-packages/rbac/src/ability.test.ts
new file mode 100644
index 00000000000..c9dc2e922f0
--- /dev/null
+++ b/internal-packages/rbac/src/ability.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect } from "vitest";
+import { permissiveAbility, superAbility, denyAbility, buildFallbackAbility, buildJwtAbility } from "./ability.js";
+
+describe("permissiveAbility", () => {
+ it("allows any action on any resource type", () => {
+ expect(permissiveAbility.can("read", { type: "run" })).toBe(true);
+ expect(permissiveAbility.can("write", { type: "deployment" })).toBe(true);
+ expect(permissiveAbility.can("delete", { type: "task" })).toBe(true);
+ });
+
+ it("allows actions on specific resource instances", () => {
+ expect(permissiveAbility.can("read", { type: "run", id: "run_abc123" })).toBe(true);
+ });
+
+ it("does not grant super-user access", () => {
+ expect(permissiveAbility.canSuper()).toBe(false);
+ });
+});
+
+describe("superAbility", () => {
+ it("allows any action on any resource", () => {
+ expect(superAbility.can("read", { type: "run" })).toBe(true);
+ expect(superAbility.can("write", { type: "deployment" })).toBe(true);
+ });
+
+ it("grants super-user access", () => {
+ expect(superAbility.canSuper()).toBe(true);
+ });
+});
+
+describe("denyAbility", () => {
+ it("denies all actions", () => {
+ expect(denyAbility.can("read", { type: "run" })).toBe(false);
+ expect(denyAbility.can("write", { type: "deployment" })).toBe(false);
+ });
+
+ it("does not grant super-user access", () => {
+ expect(denyAbility.canSuper()).toBe(false);
+ });
+});
+
+describe("buildJwtAbility", () => {
+ it("allows action matching a general scope", () => {
+ const ability = buildJwtAbility(["read:runs"]);
+ expect(ability.can("read", { type: "runs" })).toBe(true);
+ expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
+ });
+
+ it("allows only the specific ID for a scoped permission", () => {
+ const ability = buildJwtAbility(["read:runs:run_abc"]);
+ expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
+ expect(ability.can("read", { type: "runs", id: "run_xyz" })).toBe(false);
+ expect(ability.can("read", { type: "runs" })).toBe(false);
+ });
+
+ it("allows any read with read:all scope", () => {
+ const ability = buildJwtAbility(["read:all"]);
+ expect(ability.can("read", { type: "runs" })).toBe(true);
+ expect(ability.can("read", { type: "tasks" })).toBe(true);
+ expect(ability.can("write", { type: "runs" })).toBe(false);
+ });
+
+ it("allows everything with admin scope", () => {
+ const ability = buildJwtAbility(["admin"]);
+ expect(ability.can("read", { type: "runs" })).toBe(true);
+ expect(ability.can("write", { type: "deployments" })).toBe(true);
+ });
+
+ it("never grants canSuper", () => {
+ expect(buildJwtAbility(["admin"]).canSuper()).toBe(false);
+ expect(buildJwtAbility(["read:all"]).canSuper()).toBe(false);
+ expect(buildJwtAbility([]).canSuper()).toBe(false);
+ });
+
+ it("denies everything for empty scopes", () => {
+ const ability = buildJwtAbility([]);
+ expect(ability.can("read", { type: "runs" })).toBe(false);
+ });
+
+ it("denies wrong action with general resource scope", () => {
+ const ability = buildJwtAbility(["read:runs"]);
+ expect(ability.can("write", { type: "runs" })).toBe(false);
+ });
+});
+
+describe("buildJwtAbility β array resources", () => {
+ it("authorizes when any resource in the array passes a scope check", () => {
+ const ability = buildJwtAbility(["read:batch:batch_abc"]);
+ const resources = [
+ { type: "runs", id: "run_xyz" },
+ { type: "batch", id: "batch_abc" },
+ { type: "tasks", id: "task_other" },
+ ];
+ expect(ability.can("read", resources)).toBe(true);
+ });
+
+ it("rejects when no resource in the array passes a scope check", () => {
+ const ability = buildJwtAbility(["read:batch:batch_abc"]);
+ const resources = [
+ { type: "runs", id: "run_xyz" },
+ { type: "batch", id: "batch_other" },
+ { type: "tasks", id: "task_other" },
+ ];
+ expect(ability.can("read", resources)).toBe(false);
+ });
+
+ it("empty array never authorizes", () => {
+ const ability = buildJwtAbility(["read:all"]);
+ expect(ability.can("read", [])).toBe(false);
+ });
+
+ it("authorizes a single resource via the non-array form (backwards compatible)", () => {
+ const ability = buildJwtAbility(["read:runs:run_abc"]);
+ expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true);
+ });
+});
+
+describe("buildFallbackAbility", () => {
+ it("returns permissiveAbility for non-admin users", () => {
+ const ability = buildFallbackAbility(false);
+ expect(ability.can("read", { type: "run" })).toBe(true);
+ expect(ability.canSuper()).toBe(false);
+ });
+
+ it("returns superAbility for admin users", () => {
+ const ability = buildFallbackAbility(true);
+ expect(ability.can("read", { type: "run" })).toBe(true);
+ expect(ability.canSuper()).toBe(true);
+ });
+});
diff --git a/internal-packages/rbac/src/ability.ts b/internal-packages/rbac/src/ability.ts
new file mode 100644
index 00000000000..c11f4f46613
--- /dev/null
+++ b/internal-packages/rbac/src/ability.ts
@@ -0,0 +1,49 @@
+import type { RbacAbility, RbacResource } from "@trigger.dev/plugins";
+
+/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */
+export const permissiveAbility: RbacAbility = {
+ can: () => true,
+ canSuper: () => false,
+};
+
+/** Platform admin (user.admin = true): can do everything including super-user actions. */
+export const superAbility: RbacAbility = {
+ can: () => true,
+ canSuper: () => true,
+};
+
+/** Deprecated PUBLIC tokens and unauthenticated subjects: denied everything. */
+export const denyAbility: RbacAbility = {
+ can: () => false,
+ canSuper: () => false,
+};
+
+export function buildFallbackAbility(isAdmin: boolean): RbacAbility {
+ return isAdmin ? superAbility : permissiveAbility;
+}
+
+/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */
+export function buildJwtAbility(scopes: string[]): RbacAbility {
+ const matches = (action: string, r: RbacResource): boolean =>
+ scopes.some((scope) => {
+ const [scopeAction, scopeType, scopeId] = scope.split(":");
+ if (scopeAction === "admin") return true;
+ if (scopeAction !== action && scopeAction !== "*") return false;
+ if (scopeType === "all") return true;
+ if (scopeType !== r.type) return false;
+ if (!scopeId) return true;
+ return scopeId === r.id;
+ });
+ return {
+ can(action: string, resource: RbacResource | RbacResource[]): boolean {
+ // Array form means "any element passes β authorized", matching the
+ // legacy multi-key checkAuthorization semantic.
+ return Array.isArray(resource)
+ ? resource.some((r) => matches(action, r))
+ : matches(action, resource);
+ },
+ canSuper(): boolean {
+ return false;
+ },
+ };
+}
diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts
new file mode 100644
index 00000000000..3c37e007987
--- /dev/null
+++ b/internal-packages/rbac/src/fallback.ts
@@ -0,0 +1,357 @@
+import type {
+ Permission,
+ Role,
+ RbacEnvironment,
+ RbacUser,
+ RbacSubject,
+ RbacResource,
+ BearerAuthResult,
+ PatAuthResult,
+ SessionAuthResult,
+ RoleAssignmentResult,
+ RoleBaseAccessController,
+ RoleMutationResult,
+} from "@trigger.dev/plugins";
+import { createHash } from "node:crypto";
+import type { PrismaClient } from "@trigger.dev/database";
+import { validateJWT } from "@trigger.dev/core/v3/jwt";
+import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js";
+
+export class RoleBaseAccessFallback {
+ constructor(private readonly prisma: PrismaClient) {}
+
+ create(
+ helpers: { getSessionUserId: (request: Request) => Promise }
+ ): RoleBaseAccessFallbackController {
+ return new RoleBaseAccessFallbackController(this.prisma, helpers);
+ }
+}
+
+class RoleBaseAccessFallbackController implements RoleBaseAccessController {
+ constructor(
+ private readonly prisma: PrismaClient,
+ private readonly helpers: { getSessionUserId: (request: Request) => Promise }
+ ) {}
+
+ async authenticateBearer(
+ request: Request,
+ options?: { allowJWT?: boolean }
+ ): Promise {
+ const rawToken = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim();
+ if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" };
+
+ if (options?.allowJWT && isPublicJWT(rawToken)) {
+ const envId = extractJWTSub(rawToken);
+ if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" };
+
+ const env = await this.prisma.runtimeEnvironment.findFirst({
+ where: { id: envId },
+ include: {
+ project: true,
+ organization: true,
+ parentEnvironment: { select: { apiKey: true } },
+ },
+ });
+ if (!env || env.project.deletedAt !== null) {
+ return { ok: false, status: 401, error: "Invalid Public Access Token" };
+ }
+
+ const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey;
+ const result = await validateJWT(rawToken, signingKey);
+ if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" };
+
+ const scopes = Array.isArray(result.payload.scopes)
+ ? (result.payload.scopes as string[])
+ : [];
+ const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined;
+ const oneTimeUse = result.payload.otu === true;
+
+ return {
+ ok: true,
+ environment: toRbacEnvironment(env),
+ subject: {
+ type: "publicJWT",
+ environmentId: env.id,
+ organizationId: env.organizationId,
+ projectId: env.projectId,
+ },
+ ability: buildJwtAbility(scopes),
+ jwt: { realtime, oneTimeUse },
+ };
+ }
+
+ const include = {
+ project: true,
+ organization: true,
+ orgMember: { select: { userId: true } },
+ } as const;
+ let env = await this.prisma.runtimeEnvironment.findFirst({
+ where: { apiKey: rawToken },
+ include,
+ });
+
+ // Revoked API key grace window β mirrors `findEnvironmentByApiKey`
+ // in apps/webapp/app/models/runtimeEnvironment.server.ts. Recently
+ // rotated keys keep working until their `expiresAt`; without this
+ // branch a customer who rotates an env API key gets immediate 401s
+ // on the new auth path. The PR's e2e suite covers this in
+ // auth-cross-cutting.e2e.full.test.ts ("revoked key within grace").
+ if (!env) {
+ const revoked = await this.prisma.revokedApiKey.findFirst({
+ where: { apiKey: rawToken, expiresAt: { gt: new Date() } },
+ include: { runtimeEnvironment: { include } },
+ });
+ env = revoked?.runtimeEnvironment ?? null;
+ }
+
+ if (!env || env.project.deletedAt !== null) {
+ return { ok: false, status: 401, error: "Invalid API key" };
+ }
+
+ const subject: RbacSubject = {
+ type: "user",
+ userId: env.orgMember?.userId ?? "",
+ organizationId: env.organizationId,
+ projectId: env.projectId,
+ };
+
+ return {
+ ok: true,
+ environment: toRbacEnvironment(env),
+ subject,
+ ability: permissiveAbility,
+ };
+ }
+
+ async authenticateSession(
+ request: Request,
+ context: { organizationId?: string; projectId?: string }
+ ): Promise {
+ const userId = await this.helpers.getSessionUserId(request);
+ if (!userId) return { ok: false, reason: "unauthenticated" };
+
+ const user = await this.prisma.user.findFirst({ where: { id: userId } });
+ if (!user) return { ok: false, reason: "unauthenticated" };
+
+ const subject: RbacSubject = {
+ type: "user",
+ userId: user.id,
+ organizationId: context.organizationId ?? "",
+ projectId: context.projectId,
+ };
+
+ return {
+ ok: true,
+ user: toRbacUser(user),
+ subject,
+ ability: buildFallbackAbility(user.admin),
+ };
+ }
+
+ async authenticateAuthorizeBearer(
+ request: Request,
+ check: { action: string; resource: RbacResource | RbacResource[] },
+ options?: { allowJWT?: boolean }
+ ): Promise {
+ const auth = await this.authenticateBearer(request, options);
+ if (!auth.ok) return auth;
+ if (!auth.ability.can(check.action, check.resource)) {
+ return { ok: false, status: 403, error: "Unauthorized" };
+ }
+ return auth;
+ }
+
+ async authenticateAuthorizeSession(
+ request: Request,
+ context: { organizationId?: string; projectId?: string },
+ check: { action: string; resource: RbacResource | RbacResource[] }
+ ): Promise {
+ const auth = await this.authenticateSession(request, context);
+ if (!auth.ok) return auth;
+ if (!auth.ability.can(check.action, check.resource)) {
+ return { ok: false, reason: "unauthorized" };
+ }
+ return auth;
+ }
+
+ async authenticatePat(
+ request: Request,
+ context: { organizationId?: string; projectId?: string }
+ ): Promise {
+ const rawToken = request.headers
+ .get("Authorization")
+ ?.replace(/^Bearer /, "")
+ .trim();
+ if (!rawToken || !rawToken.startsWith("tr_pat_")) {
+ return { ok: false, status: 401, error: "Invalid or Missing PAT" };
+ }
+
+ const hashedToken = createHash("sha256").update(rawToken).digest("hex");
+ const pat = await this.prisma.personalAccessToken.findFirst({
+ where: { hashedToken, revokedAt: null },
+ select: { id: true, userId: true },
+ });
+ if (!pat) {
+ return { ok: false, status: 401, error: "Invalid PAT" };
+ }
+
+ return {
+ ok: true,
+ tokenId: pat.id,
+ userId: pat.userId,
+ subject: {
+ type: "personalAccessToken",
+ tokenId: pat.id,
+ organizationId: context.organizationId ?? "",
+ projectId: context.projectId,
+ },
+ // No plugin β no role lookup. PATs in the OSS world are pure
+ // user-identity tokens; the route's own authorization block (or
+ // the absence of one) decides what they can do, same as it did
+ // before this method existed.
+ ability: permissiveAbility,
+ };
+ }
+
+ async systemRoles(_organizationId: string) {
+ // No plugin installed β no seeded roles. Callers handle null by
+ // hiding role-picker UI / skipping role assignment writes.
+ return null;
+ }
+
+ async allPermissions(): Promise {
+ return [];
+ }
+
+ async allRoles(): Promise {
+ return [];
+ }
+
+ // Permissive β the default fallback applies no gating. The Teams
+ // page UI uses this to decide which role options to render as
+ // disabled; with no plugin installed allRoles() returns [] anyway,
+ // so the practical effect is "no roles to gate".
+ async getAssignableRoleIds(): Promise {
+ return [];
+ }
+
+ async createRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+
+ async updateRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+
+ async deleteRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+
+ async getUserRole(): Promise {
+ return null;
+ }
+
+ async getUserRoles(userIds: string[]): Promise> {
+ return new Map(userIds.map((id) => [id, null]));
+ }
+
+ async setUserRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+
+ async removeUserRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+
+ async getTokenRole(): Promise {
+ return null;
+ }
+
+ async setTokenRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+
+ async removeTokenRole(): Promise {
+ return { ok: false, error: "RBAC plugin not installed" };
+ }
+}
+
+function isPublicJWT(token: string): boolean {
+ const parts = token.split(".");
+ if (parts.length !== 3) return false;
+ try {
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
+ return payload !== null && typeof payload === "object" && payload.pub === true;
+ } catch {
+ return false;
+ }
+}
+
+function extractJWTSub(token: string): string | undefined {
+ const parts = token.split(".");
+ if (parts.length !== 3) return undefined;
+ try {
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
+ return payload !== null && typeof payload === "object" && typeof payload.sub === "string"
+ ? payload.sub
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function toRbacEnvironment(
+ env: {
+ id: string;
+ slug: string;
+ type: string;
+ apiKey: string;
+ pkApiKey: string;
+ organizationId: string;
+ projectId: string;
+ organization: { id: string; slug: string; title: string };
+ project: { id: string; slug: string; name: string; externalRef: string };
+ }
+): RbacEnvironment {
+ return {
+ id: env.id,
+ slug: env.slug,
+ type: env.type,
+ apiKey: env.apiKey,
+ pkApiKey: env.pkApiKey,
+ organizationId: env.organizationId,
+ projectId: env.projectId,
+ organization: {
+ id: env.organization.id,
+ slug: env.organization.slug,
+ title: env.organization.title,
+ },
+ project: {
+ id: env.project.id,
+ slug: env.project.slug,
+ name: env.project.name,
+ externalRef: env.project.externalRef,
+ },
+ };
+}
+
+function toRbacUser(user: {
+ id: string;
+ email: string;
+ name: string | null;
+ displayName: string | null;
+ avatarUrl: string | null;
+ admin: boolean;
+ confirmedBasicDetails: boolean;
+}): RbacUser {
+ return {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ displayName: user.displayName,
+ avatarUrl: user.avatarUrl,
+ admin: user.admin,
+ confirmedBasicDetails: user.confirmedBasicDetails,
+ isImpersonating: false,
+ };
+}
diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts
new file mode 100644
index 00000000000..9b4c34b306b
--- /dev/null
+++ b/internal-packages/rbac/src/index.ts
@@ -0,0 +1,252 @@
+import type {
+ Permission,
+ RbacAbility,
+ Role,
+ RbacResource,
+ RoleAssignmentResult,
+ RoleBaseAccessController,
+ RoleBasedAccessControlPlugin,
+ RoleMutationResult,
+} from "@trigger.dev/plugins";
+import type { PrismaClient } from "@trigger.dev/database";
+import { RoleBaseAccessFallback } from "./fallback.js";
+export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins";
+
+type RbacHelpers = { getSessionUserId: (request: Request) => Promise };
+
+export type RbacCreateOptions = {
+ // When true, skip loading the plugin, useful for tests
+ forceFallback?: boolean;
+};
+
+// Route actions that historically authorised via the legacy checkAuthorization's
+// superScopes escape hatch β e.g. a JWT with scope "write:tasks" was accepted by
+// a route with action: "trigger" because "write:tasks" was listed in the route's
+// superScopes array. The new ability model matches scope-action strictly, so we
+// restore the prior semantic here: when the underlying ability denies for action
+// X, retry with each aliased action.
+const ACTION_ALIASES: Record = {
+ trigger: ["write"],
+ batchTrigger: ["write"],
+ update: ["write"],
+};
+
+export function withActionAliases(underlying: RbacAbility): RbacAbility {
+ return {
+ can(action: string, resource: RbacResource | RbacResource[]): boolean {
+ if (underlying.can(action, resource)) return true;
+ const aliases = ACTION_ALIASES[action] ?? [];
+ return aliases.some((a) => underlying.can(a, resource));
+ },
+ canSuper: () => underlying.canSuper(),
+ };
+}
+
+// Loads the plugin lazily; falls back to the fallback implementation if not installed.
+// Synchronous create() avoids top-level await (not supported in the webapp's CJS build).
+class LazyController implements RoleBaseAccessController {
+ private readonly _init: Promise;
+
+ constructor(prisma: PrismaClient, helpers: RbacHelpers, options?: RbacCreateOptions) {
+ this._init = this.load(prisma, helpers, options);
+ }
+
+ private async load(
+ prisma: PrismaClient,
+ helpers: RbacHelpers,
+ options?: RbacCreateOptions
+ ): Promise {
+ if (options?.forceFallback) {
+ return new RoleBaseAccessFallback(prisma).create(helpers);
+ }
+ const moduleName = "@triggerdotdev/plugins/rbac";
+ try {
+ const module = await import(moduleName);
+ const plugin: RoleBasedAccessControlPlugin = module.default;
+ console.log("RBAC: using plugin implementation");
+ return plugin.create(helpers);
+ } catch (err) {
+ // The dynamic import either succeeded or failed for one of two
+ // distinct reasons. Distinguishing them is critical for debugging
+ // β silently swallowing the error here is what produced "why is
+ // the fallback being used?" mysteries before.
+ //
+ // 1. The plugin itself is absent (no install) β expected.
+ // Logged at info level only when RBAC_LOG_FALLBACK=1 so
+ // production logs stay quiet.
+ // 2. Anything else (transitive dep missing, init error, syntax
+ // error in the plugin's dist, etc.) β a real bug. Always
+ // logged loudly so it surfaces in CI / production logs.
+ //
+ // Node throws ERR_MODULE_NOT_FOUND for both cases β the *plugin*
+ // module being absent and a *transitive* dep of the plugin
+ // being absent. Disambiguate by checking whether the missing
+ // specifier in the error message is the plugin's own moduleName.
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
+ const message = err instanceof Error ? err.message : String(err);
+ const isModuleNotFound =
+ code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
+ const isPluginItselfMissing =
+ isModuleNotFound && message.includes(moduleName);
+
+ if (!isPluginItselfMissing) {
+ // Either the error wasn't a missing-module error at all, or the
+ // plugin was found but a transitive dep failed to resolve.
+ // Either way: a real problem worth surfacing.
+ console.error(
+ "RBAC: plugin found but failed to load; falling back to default implementation",
+ err
+ );
+ } else if (process.env.RBAC_LOG_FALLBACK === "1") {
+ console.log(
+ "RBAC: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback"
+ );
+ }
+ return new RoleBaseAccessFallback(prisma).create(helpers);
+ }
+ }
+
+ private async c(): Promise {
+ return this._init;
+ }
+
+ async authenticateBearer(...args: Parameters) {
+ const result = await (await this.c()).authenticateBearer(...args);
+ return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
+ }
+
+ async authenticateSession(...args: Parameters) {
+ const result = await (await this.c()).authenticateSession(...args);
+ return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
+ }
+
+ // Don't delegate to the underlying Authorize variants β that would run the
+ // inline ability check against the unwrapped ability. Use our wrapped
+ // authenticate* and do the ability check here instead.
+ async authenticateAuthorizeBearer(
+ request: Parameters[0],
+ check: Parameters[1],
+ options?: Parameters[2]
+ ) {
+ const auth = await this.authenticateBearer(request, options);
+ if (!auth.ok) return auth;
+ if (!auth.ability.can(check.action, check.resource)) {
+ return { ok: false as const, status: 403 as const, error: "Unauthorized" };
+ }
+ return auth;
+ }
+
+ async authenticateAuthorizeSession(
+ request: Parameters[0],
+ context: Parameters[1],
+ check: Parameters[2]
+ ) {
+ const auth = await this.authenticateSession(request, context);
+ if (!auth.ok) return auth;
+ if (!auth.ability.can(check.action, check.resource)) {
+ return { ok: false as const, reason: "unauthorized" as const };
+ }
+ return auth;
+ }
+
+ async authenticatePat(...args: Parameters) {
+ const result = await (await this.c()).authenticatePat(...args);
+ return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result;
+ }
+
+ async systemRoles(...args: Parameters) {
+ return (await this.c()).systemRoles(...args);
+ }
+
+ async allPermissions(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).allPermissions(...args);
+ }
+
+ async allRoles(...args: Parameters): Promise {
+ return (await this.c()).allRoles(...args);
+ }
+
+ async getAssignableRoleIds(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).getAssignableRoleIds(...args);
+ }
+
+ async createRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).createRole(...args);
+ }
+
+ async updateRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).updateRole(...args);
+ }
+
+ async deleteRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).deleteRole(...args);
+ }
+
+ async getUserRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).getUserRole(...args);
+ }
+
+ async getUserRoles(
+ ...args: Parameters
+ ): Promise> {
+ return (await this.c()).getUserRoles(...args);
+ }
+
+ async setUserRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).setUserRole(...args);
+ }
+
+ async removeUserRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).removeUserRole(...args);
+ }
+
+ async getTokenRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).getTokenRole(...args);
+ }
+
+ async setTokenRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).setTokenRole(...args);
+ }
+
+ async removeTokenRole(
+ ...args: Parameters
+ ): Promise {
+ return (await this.c()).removeTokenRole(...args);
+ }
+}
+
+class RoleBaseAccess {
+ // Synchronous β returns a lazy controller that resolves any installed
+ // plugin on first call.
+ create(
+ prisma: PrismaClient,
+ helpers: RbacHelpers,
+ options?: RbacCreateOptions
+ ): RoleBaseAccessController {
+ return new LazyController(prisma, helpers, options);
+ }
+}
+
+const loader = new RoleBaseAccess();
+
+export default loader;
diff --git a/internal-packages/rbac/src/loader.test.ts b/internal-packages/rbac/src/loader.test.ts
new file mode 100644
index 00000000000..151bdcf9683
--- /dev/null
+++ b/internal-packages/rbac/src/loader.test.ts
@@ -0,0 +1,69 @@
+import type { RbacAbility } from "@trigger.dev/plugins";
+import { describe, expect, it } from "vitest";
+import { buildJwtAbility } from "./ability.js";
+import { withActionAliases } from "./index.js";
+
+describe("withActionAliases", () => {
+ it("direct action match passes through unchanged", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
+ expect(ability.can("write", { type: "tasks", id: "task_x" })).toBe(true);
+ });
+
+ it("trigger action is satisfied by a write:tasks scope (alias retry)", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
+ expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
+ });
+
+ it("batchTrigger action is satisfied by a write:tasks scope (alias retry)", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:tasks"]));
+ expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true);
+ });
+
+ it("update action is satisfied by a write:prompts scope (alias retry)", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:prompts"]));
+ expect(ability.can("update", { type: "prompts", id: "p_x" })).toBe(true);
+ });
+
+ it("id-scoped write scope satisfies the aliased action on matching id", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
+ expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
+ });
+
+ it("id-scoped write scope denies the aliased action on a different id", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
+ expect(ability.can("trigger", { type: "tasks", id: "task_other" })).toBe(false);
+ });
+
+ it("read scope does not satisfy a trigger action (aliases are write-only)", () => {
+ const ability = withActionAliases(buildJwtAbility(["read:tasks"]));
+ expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(false);
+ });
+
+ it("non-aliased custom action only matches its direct action scope", () => {
+ const ability = withActionAliases(buildJwtAbility(["read:runs"]));
+ expect(ability.can("someOtherAction", { type: "runs", id: "run_x" })).toBe(false);
+ });
+
+ it("admin scope continues to grant everything regardless of aliases", () => {
+ const ability = withActionAliases(buildJwtAbility(["admin"]));
+ expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true);
+ expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true);
+ expect(ability.can("anything", { type: "whatever", id: "x" })).toBe(true);
+ });
+
+ it("array resource form: alias retry applies when any element passes", () => {
+ const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"]));
+ const resources = [
+ { type: "tasks", id: "task_other" },
+ { type: "tasks", id: "task_x" },
+ ];
+ expect(ability.can("trigger", resources)).toBe(true);
+ });
+
+ it("canSuper is delegated unchanged", () => {
+ const allowSuper: RbacAbility = { can: () => false, canSuper: () => true };
+ const denySuper: RbacAbility = { can: () => false, canSuper: () => false };
+ expect(withActionAliases(allowSuper).canSuper()).toBe(true);
+ expect(withActionAliases(denySuper).canSuper()).toBe(false);
+ });
+});
diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json
new file mode 100644
index 00000000000..8da0857b403
--- /dev/null
+++ b/internal-packages/rbac/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2019",
+ "lib": ["ES2019", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "isolatedModules": true,
+ "preserveWatchOutput": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "strict": true,
+ "customConditions": ["@triggerdotdev/source"]
+ },
+ "exclude": ["node_modules"]
+}
diff --git a/internal-packages/rbac/vitest.config.ts b/internal-packages/rbac/vitest.config.ts
new file mode 100644
index 00000000000..e07f05e842b
--- /dev/null
+++ b/internal-packages/rbac/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["**/*.test.ts"],
+ globals: true,
+ isolate: true,
+ testTimeout: 10_000,
+ },
+});
diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts
index b3f69f77d0a..6757ab64e6c 100644
--- a/internal-packages/testcontainers/src/utils.ts
+++ b/internal-packages/testcontainers/src/utils.ts
@@ -7,7 +7,11 @@ import path from "path";
import { isDebug } from "std-env";
import { GenericContainer, StartedNetwork, StartedTestContainer, Wait } from "testcontainers";
import { x } from "tinyexec";
-import { expect, TaskContext } from "vitest";
+// `expect` is only used inside assertNonNullable β lazy-loaded via require
+// inside the function so this module can be imported in non-test contexts
+// (e.g. a vitest globalSetup that starts containers before any worker
+// exists, where vitest's expect-init-at-load-time would crash).
+import type { TaskContext } from "vitest";
import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse";
import { MinIOContainer } from "./minio";
import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs";
@@ -186,6 +190,10 @@ export async function createMinIOContainer(network: StartedNetwork) {
}
export function assertNonNullable(value: T): asserts value is NonNullable {
+ // Loaded lazily so importers of this module don't pay the vitest top-level
+ // init cost outside a test worker. See the import note at the top.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { expect } = require("vitest") as typeof import("vitest");
expect(value).toBeDefined();
expect(value).not.toBeNull();
}
diff --git a/internal-packages/testcontainers/src/webapp.ts b/internal-packages/testcontainers/src/webapp.ts
index 9530f4c38fb..108eb911971 100644
--- a/internal-packages/testcontainers/src/webapp.ts
+++ b/internal-packages/testcontainers/src/webapp.ts
@@ -37,13 +37,29 @@ export interface WebappInstance {
fetch(path: string, init?: RequestInit): Promise;
}
+export interface StartWebappOptions {
+ /**
+ * When true (default), the spawned webapp runs with `RBAC_FORCE_FALLBACK=1`
+ * so the default fallback handles all auth checks. The comprehensive
+ * suite (`*.e2e.full.test.ts`) relies on this β it's pinned to the
+ * fallback so results don't depend on whether `@triggerdotdev/plugins/rbac`
+ * happens to be installed in the local node_modules.
+ *
+ * Set to false to spawn a webapp that loads any installed RBAC
+ * plugin instead, for testing the plugin path.
+ */
+ forceRbacFallback?: boolean;
+}
+
export async function startWebapp(
databaseUrl: string,
- redis: { host: string; port: number }
+ redis: { host: string; port: number },
+ options: StartWebappOptions = {}
): Promise<{
instance: WebappInstance;
stop: () => Promise;
}> {
+ const forceRbacFallback = options.forceRbacFallback ?? true;
const port = await findFreePort();
// Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable
@@ -56,7 +72,12 @@ export async function startWebapp(
cwd: WEBAPP_ROOT,
env: {
...process.env,
- NODE_ENV: "test",
+ // Match `pnpm run start` (production-mode boot). NODE_ENV=test
+ // surfaces a circular-init regression in the production bundle
+ // β see TRI-8731 β that production-mode dodges by initialising
+ // modules in a different order. Tests don't depend on test-mode
+ // semantics; they only need an isolated webapp + DB.
+ NODE_ENV: "production",
DATABASE_URL: databaseUrl,
DIRECT_URL: databaseUrl,
PORT: String(port),
@@ -81,6 +102,11 @@ export async function startWebapp(
RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv)
RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv)
RUN_REPLICATION_ENABLED: "0",
+ // Force the RBAC loader to use the default fallback in e2e tests
+ // so auth behaviour is deterministic regardless of whether a
+ // plugin is installed in the local node_modules. Set to "0" /
+ // undefined to spawn a webapp that loads any installed plugin.
+ ...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}),
NODE_PATH: nodePath,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -147,15 +173,21 @@ export async function startWebapp(
export interface TestServer {
webapp: WebappInstance;
prisma: PrismaClient;
+ // Postgres connection string. Useful when test workers run in separate
+ // processes and need to construct their own clients against the same DB.
+ databaseUrl: string;
stop: () => Promise;
}
/** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */
-export async function startTestServer(): Promise {
+export async function startTestServer(
+ options: StartWebappOptions = {}
+): Promise {
const network = await new Network().start();
// Track each resource as we acquire it so we can tear it down if a later step fails.
let pgContainer: Awaited>["container"] | undefined;
+ let pgUrl: string | undefined;
let redisContainer: Awaited>["container"] | undefined;
let prisma: PrismaClient | undefined;
let stopWebapp: (() => Promise) | undefined;
@@ -164,13 +196,18 @@ export async function startTestServer(): Promise {
try {
const pg = await createPostgresContainer(network);
pgContainer = pg.container;
+ pgUrl = pg.url;
const { container: rc } = await createRedisContainer({ network });
redisContainer = rc;
prisma = new PrismaClient({ datasources: { db: { url: pg.url } } });
await prisma.$connect(); // pre-warm pool; surface connection failures before tests start
- const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() });
+ const started = await startWebapp(
+ pg.url,
+ { host: rc.getHost(), port: rc.getPort() },
+ options
+ );
webapp = started.instance;
stopWebapp = started.stop;
} catch (err) {
@@ -190,5 +227,5 @@ export async function startTestServer(): Promise {
await network.stop().catch((err) => console.error("network.stop failed:", err));
};
- return { webapp, prisma: prisma!, stop };
+ return { webapp, prisma: prisma!, databaseUrl: pgUrl!, stop };
}
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
new file mode 100644
index 00000000000..924abd1d01f
--- /dev/null
+++ b/packages/plugins/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@trigger.dev/plugins",
+ "version": "4.4.4",
+ "description": "Plugin contracts and interfaces for Trigger.dev",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/triggerdotdev/trigger.dev",
+ "directory": "packages/plugins"
+ },
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "clean": "rimraf dist .turbo",
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.14",
+ "rimraf": "6.0.1",
+ "tsup": "^8.4.0",
+ "typescript": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=18.20.0"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ }
+}
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
new file mode 100644
index 00000000000..e210302c246
--- /dev/null
+++ b/packages/plugins/src/index.ts
@@ -0,0 +1,17 @@
+export type {
+ RoleBasedAccessControlPlugin,
+ RoleBaseAccessController,
+ RoleAssignmentResult,
+ RoleMutationResult,
+ Permission,
+ Role,
+ RbacAbility,
+ RbacSubject,
+ RbacResource,
+ RbacEnvironment,
+ RbacUser,
+ BearerAuthResult,
+ SessionAuthResult,
+ PatAuthResult,
+ SystemRole,
+} from "./rbac.js";
diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts
new file mode 100644
index 00000000000..edec2cac0a5
--- /dev/null
+++ b/packages/plugins/src/rbac.ts
@@ -0,0 +1,260 @@
+/**
+ * Plugin-owned metadata for a built-in system role. The plugin returns
+ * these in canonical order (highest authority first) so the dashboard
+ * can render columns / build a level ladder without knowing role names.
+ *
+ * Roles the plugin doesn't expose at all (e.g. seeded but with the
+ * `is_hidden` flag set in the cloud plugin) are not returned by
+ * `systemRoles()` β there's no "advertised but absent" state.
+ *
+ * `available` indicates whether the role is assignable on the *org's
+ * plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
+ * Developer. Consumers may render unavailable rows with an upgrade
+ * badge, hide them, or otherwise gate UI on the flag.
+ */
+export type SystemRole = {
+ id: string;
+ name: string;
+ description: string;
+ available: boolean;
+};
+
+export type Permission = {
+ // `:` β display name, derived from the ability rule.
+ name: string;
+ description: string;
+ // Display bucket for the Roles page (e.g. "Runs", "Tasks"). The page
+ // groups permissions by this string and lists groups in the order they
+ // first appear in `allPermissions()`, so the plugin owns both the
+ // bucket label and the section ordering. Omit for "no grouping".
+ group?: string;
+ // Inverted rules (CASL `cannot`) surface as β in the Roles page.
+ inverted?: boolean;
+ // CASL conditions (e.g. `{ envType: "PRODUCTION" }`) β when present,
+ // the Roles page renders a tier badge alongside the permission row.
+ conditions?: Record;
+};
+
+export type Role = {
+ id: string;
+ name: string;
+ description: string;
+ permissions: Permission[];
+ isSystem: boolean;
+};
+
+export type RbacSubject =
+ | { type: "user"; userId: string; organizationId: string; projectId?: string }
+ | { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string }
+ | { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string };
+
+export type RbacResource = {
+ type: string;
+ id?: string;
+ // Extra fields a route may pass for condition-based ability checks β
+ // e.g. `envType` for env-tier-scoped rules ("Member can read envvars
+ // unless envType === 'PRODUCTION'"). The plugin's ability matcher
+ // (CASL) reads these off the resource object; routes that don't use
+ // conditional rules can keep passing `{ type, id? }`.
+ [key: string]: unknown;
+};
+
+export type RbacEnvironment = {
+ id: string;
+ slug: string;
+ type: string;
+ apiKey: string;
+ pkApiKey: string;
+ organizationId: string;
+ projectId: string;
+ organization: { id: string; slug: string; title: string };
+ project: { id: string; slug: string; name: string; externalRef: string };
+};
+
+export type RbacUser = {
+ id: string;
+ email: string;
+ name: string | null;
+ displayName: string | null;
+ avatarUrl: string | null;
+ admin: boolean;
+ confirmedBasicDetails: boolean;
+ isImpersonating: boolean;
+};
+
+/** Pre-built ability returned by authenticate* β all checks are sync, no DB call. */
+export interface RbacAbility {
+ // Array form means "grant access if any resource in the array passes" β
+ // used by routes that touch multiple resources (e.g. a run also carries
+ // a batch id, tags, a task identifier) so a JWT scoped to any of them
+ // grants access.
+ can(action: string, resource: RbacResource | RbacResource[]): boolean;
+ canSuper(): boolean;
+}
+
+export type BearerAuthResult =
+ | { ok: false; status: 401 | 403; error: string }
+ | {
+ ok: true;
+ environment: RbacEnvironment;
+ subject: RbacSubject;
+ ability: RbacAbility;
+ jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean };
+ };
+
+export type SessionAuthResult =
+ | { ok: false; reason: "unauthenticated" | "unauthorized" }
+ | { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
+
+// PAT auth deliberately omits `environment` β PATs are user identity
+// tokens, not environment tokens. The ability is resolved per-request
+// from the user's role in the target org (passed via `context`),
+// intersected with the PAT's optional max-role cap.
+export type PatAuthResult =
+ | { ok: false; status: 401 | 403; error: string }
+ | {
+ ok: true;
+ tokenId: string;
+ userId: string;
+ subject: RbacSubject;
+ ability: RbacAbility;
+ };
+
+export interface RoleBaseAccessController {
+ // API routes (Bearer token): one DB query β identity + pre-built ability
+ // options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
+ authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise;
+
+ // Dashboard loaders/actions (session cookie): one DB query β user + pre-built ability
+ authenticateSession(
+ request: Request,
+ context: { organizationId?: string; projectId?: string }
+ ): Promise;
+
+ // PAT-authenticated routes (Authorization: Bearer tr_pat_β¦). The token
+ // identifies the user; the effective ability is `min(user's current
+ // role in the target org, the PAT's optional max-role cap)`. The user's
+ // actual org membership is the floor β if they've been demoted or
+ // removed, the PAT auto-narrows. The cap is set at PAT creation and
+ // ceilings the token even when the user is more privileged.
+ //
+ // No plugin installed β fallback returns a permissive ability so PAT
+ // routes that don't yet declare an `authorization` block keep working
+ // exactly as they did pre-RBAC.
+ authenticatePat(
+ request: Request,
+ context: { organizationId?: string; projectId?: string }
+ ): Promise;
+
+ // Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails.
+ // resource accepts the same single-or-array shape as RbacAbility.can β array form means
+ // "grant access if any element passes".
+ authenticateAuthorizeBearer(
+ request: Request,
+ check: { action: string; resource: RbacResource | RbacResource[] },
+ options?: { allowJWT?: boolean }
+ ): Promise;
+
+ authenticateAuthorizeSession(
+ request: Request,
+ context: { organizationId?: string; projectId?: string },
+ check: { action: string; resource: RbacResource | RbacResource[] }
+ ): Promise;
+
+ // Plugin-owned catalogue of built-in system roles for the given org,
+ // in canonical order (highest authority first). Returns null when no
+ // plugin is installed β there are no seeded roles to refer to in that
+ // case (the default fallback's `allRoles` returns []).
+ //
+ // Hidden roles (e.g. Member in v1) are filtered out entirely. Each
+ // entry's `available` flag reflects whether the org's plan permits
+ // assigning that role; consumers can render unavailable entries with
+ // an upgrade badge or hide them.
+ systemRoles(organizationId: string): Promise;
+
+ // Role introspection. The fallback returns []; a plugin may return
+ // its own role catalogue.
+ allPermissions(organizationId: string): Promise;
+ allRoles(organizationId: string): Promise;
+
+ // Of the roles returned by `allRoles(organizationId)`, which IDs may
+ // be assigned right now? Used by the Teams page UI to disable
+ // role-dropdown options the org isn't allowed to assign. The default
+ // fallback returns every role id (permissive β it doesn't apply any
+ // gating). Server-side enforcement lives in setUserRole; this method
+ // is purely a UI affordance.
+ getAssignableRoleIds(organizationId: string): Promise;
+
+ // Role management. Mutation methods return a discriminated Result
+ // rather than throwing β the dashboard surfaces `error` strings
+ // directly to the user (system role edits, gating, validation
+ // conflicts), so a thrown exception is only ever for unexpected
+ // failures (DB outage, bug). The default fallback returns
+ // `{ ok: false, error: "RBAC plugin not installed" }` for these.
+ createRole(params: {
+ organizationId: string;
+ name: string;
+ description: string;
+ permissions: string[];
+ }): Promise;
+
+ updateRole(params: {
+ roleId: string;
+ name?: string;
+ description?: string;
+ permissions?: string[];
+ }): Promise;
+
+ deleteRole(roleId: string): Promise;
+
+ // Role assignments. Same Result discipline as the role-management
+ // methods above. The default fallback returns
+ // `{ ok: false, error: "RBAC plugin not installed" }`.
+ getUserRole(params: {
+ userId: string;
+ organizationId: string;
+ projectId?: string;
+ }): Promise;
+
+ // Batch variant for callers that need per-user roles for many users
+ // in one round-trip (e.g. the Team page rendering N members).
+ // Org-scoped only β project-scoped reads still go through getUserRole.
+ // Returns a Map keyed by userId; users with no resolvable role map to
+ // null. The default fallback returns a Map of all userIds β null.
+ getUserRoles(
+ userIds: string[],
+ organizationId: string
+ ): Promise