From da5218e93368c269934b1e961a3163927da3d1eb Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:04:04 +0100 Subject: [PATCH 01/29] feat(webapp): PAT-authenticated management API for orgs, members, invites, and projects --- ...api.v1.orgs.$orgParam.invites.$inviteId.ts | 69 +++++++++++++ .../routes/api.v1.orgs.$orgParam.invites.ts | 99 +++++++++++++++++++ ...api.v1.orgs.$orgParam.members.$memberId.ts | 89 +++++++++++++++++ .../routes/api.v1.orgs.$orgParam.members.ts | 83 ++++++++++++++++ apps/webapp/app/routes/api.v1.orgs.ts | 50 +++++++++- ...cts.$projectRef.$env.regenerate-api-key.ts | 73 ++++++++++++++ .../app/routes/api.v1.projects.$projectRef.ts | 51 +++++++++- .../services/organizationApiAccess.server.ts | 78 +++++++++++++++ 8 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts create mode 100644 apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts create mode 100644 apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts create mode 100644 apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts create mode 100644 apps/webapp/app/services/organizationApiAccess.server.ts diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts new file mode 100644 index 0000000000..7501a06942 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts @@ -0,0 +1,69 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { revokeInvite } from "~/models/member.server"; +import { logger } from "~/services/logger.server"; +import { + authorizePatOrganizationAccess, + resolveOrganizationForApiUser, +} from "~/services/organizationApiAccess.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; + +const ParamsSchema = z.object({ + orgParam: z.string(), + inviteId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "DELETE") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { orgParam, inviteId } = parsedParams.data; + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const organization = await resolveOrganizationForApiUser({ + orgParam, + userId: authenticationResult.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: organization.id, + resource: "members", + action: "manage", + }); + if (denied) return denied; + + const revoked = await revokeInvite({ + userId: authenticationResult.userId, + orgSlug: organization.slug, + inviteId, + }); + + return json({ id: inviteId, email: revoked.email }); + } catch (error) { + if (error instanceof Response) throw error; + if (error instanceof Error && error.message === "Invite not found") { + return json({ error: error.message }, { status: 404 }); + } + logger.error("Failed to revoke invite", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts new file mode 100644 index 0000000000..b15b7f0e01 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -0,0 +1,99 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { inviteMembers } from "~/models/member.server"; +import { logger } from "~/services/logger.server"; +import { + authorizePatOrganizationAccess, + resolveOrganizationForApiUser, +} from "~/services/organizationApiAccess.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { scheduleEmail } from "~/services/scheduleEmail.server"; +import { acceptInvitePath } from "~/utils/pathBuilder"; + +const ParamsSchema = z.object({ + orgParam: z.string(), +}); + +const InviteRequestBody = z.object({ + emails: z.string().email().array().nonempty("At least one email is required"), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const organization = await resolveOrganizationForApiUser({ + orgParam: parsedParams.data.orgParam, + userId: authenticationResult.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: organization.id, + resource: "members", + action: "manage", + }); + if (denied) return denied; + + const body = InviteRequestBody.safeParse(await request.json()); + + if (!body.success) { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const invites = await inviteMembers({ + slug: organization.slug, + emails: body.data.emails, + userId: authenticationResult.userId, + }); + + // Send invite emails the same way the dashboard invite action does. A + // failed send must not fail the invite (the row already exists); in local + // dev with no SMTP config, scheduleEmail's transport logs instead. + for (const invite of invites) { + try { + await scheduleEmail({ + email: "invite", + to: invite.email, + orgName: invite.organization.title, + inviterName: invite.inviter.name ?? undefined, + inviterEmail: invite.inviter.email, + inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`, + }); + } catch (error) { + logger.error("Failed to send invite email", { error }); + } + } + + return json( + { + invites: invites.map((invite) => ({ id: invite.id, email: invite.email })), + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to invite org members", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts new file mode 100644 index 0000000000..1c50cb315b --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -0,0 +1,89 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { removeTeamMember } from "~/models/member.server"; +import { logger } from "~/services/logger.server"; +import { + authorizePatOrganizationAccess, + resolveOrganizationForApiUser, +} from "~/services/organizationApiAccess.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; + +const ParamsSchema = z.object({ + orgParam: z.string(), + memberId: z.string(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "DELETE") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { orgParam, memberId } = parsedParams.data; + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const organization = await resolveOrganizationForApiUser({ + orgParam, + userId: authenticationResult.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: organization.id, + resource: "members", + action: "manage", + }); + if (denied) return denied; + + // An org must keep at least one member. The dashboard guards this in the + // UI only; enforce it here since removeTeamMember doesn't. + const memberCount = await prisma.orgMember.count({ + where: { organizationId: organization.id }, + }); + if (memberCount <= 1) { + return json( + { error: "Cannot remove the last member of an organization" }, + { status: 400 } + ); + } + + const removed = await removeTeamMember({ + userId: authenticationResult.userId, + slug: organization.slug, + memberId, + }); + + return json({ + id: removed.id, + user: { + id: removed.user.id, + name: removed.user.name, + email: removed.user.email, + }, + }); + } catch (error) { + if (error instanceof Response) throw error; + if (error instanceof Error && error.message === "Member not found in this organization") { + return json({ error: error.message }, { status: 404 }); + } + logger.error("Failed to remove org member", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts new file mode 100644 index 0000000000..de85656571 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts @@ -0,0 +1,83 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { getTeamMembersAndInvites } from "~/models/member.server"; +import { logger } from "~/services/logger.server"; +import { + authorizePatOrganizationAccess, + resolveOrganizationForApiUser, +} from "~/services/organizationApiAccess.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; + +const ParamsSchema = z.object({ + orgParam: z.string(), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const organization = await resolveOrganizationForApiUser({ + orgParam: parsedParams.data.orgParam, + userId: authenticationResult.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: organization.id, + resource: "members", + action: "read", + }); + if (denied) return denied; + + const result = await getTeamMembersAndInvites({ + userId: authenticationResult.userId, + organizationId: organization.id, + }); + + if (!result) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + return json({ + members: result.members.map((member) => ({ + id: member.id, + role: member.role, + user: { + id: member.user.id, + name: member.user.name, + email: member.user.email, + avatarUrl: member.user.avatarUrl, + }, + })), + invites: result.invites.map((invite) => ({ + id: invite.id, + email: invite.email, + updatedAt: invite.updatedAt, + inviter: { + id: invite.inviter.id, + name: invite.inviter.name, + email: invite.inviter.email, + }, + })), + }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to list org members", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.orgs.ts b/apps/webapp/app/routes/api.v1.orgs.ts index 4e3ad9aa98..7cc8d961f9 100644 --- a/apps/webapp/app/routes/api.v1.orgs.ts +++ b/apps/webapp/app/routes/api.v1.orgs.ts @@ -1,7 +1,9 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetOrgsResponseBody } from "@trigger.dev/core/v3"; +import { z } from "zod"; import { prisma } from "~/db.server"; +import { createOrganization } from "~/models/organization.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; @@ -42,3 +44,49 @@ export async function loader({ request }: LoaderFunctionArgs) { return json({ error: "Internal Server Error" }, { status: 500 }); } } + +const CreateOrgRequestBody = z.object({ + title: z.string().min(1), + companySize: z.string().optional(), +}); + +export async function action({ request }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const body = CreateOrgRequestBody.safeParse(await request.json()); + + if (!body.success) { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + // Any authenticated user can create an org; the creator becomes its ADMIN. + const organization = await createOrganization({ + title: body.data.title, + companySize: body.data.companySize ?? null, + userId: authenticationResult.userId, + }); + + return json( + { + id: organization.id, + title: organization.title, + slug: organization.slug, + createdAt: organization.createdAt, + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to create org", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts new file mode 100644 index 0000000000..fd17210326 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.regenerate-api-key.ts @@ -0,0 +1,73 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { regenerateApiKey } from "~/models/api-key.server"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, + branchNameFromRequest, +} from "~/services/apiAuth.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; +import { logger } from "~/services/logger.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), + env: z.enum(["dev", "staging", "prod", "preview"]), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { projectRef, env } = parsedParams.data; + + try { + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: false, + apiKey: false, + }); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const environment = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, + env, + branchNameFromRequest(request) + ); + + // Rotating the key requires env-tier write:apiKeys — same gate the + // dashboard resource route enforces. + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + resource: "apiKeys", + action: "write", + }); + if (denied) return denied; + + const updatedEnvironment = await regenerateApiKey({ + userId: authenticationResult.result.userId, + environmentId: environment.id, + }); + + return json({ apiKey: updatedEnvironment.apiKey }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to regenerate API key", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 55cae92e79..5d72abffca 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -1,8 +1,9 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetProjectResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; +import { DeleteProjectService } from "~/services/deleteProject.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; @@ -69,3 +70,51 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json(result); } + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "DELETE") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { projectRef } = parsedParams.data; + + // Resolve id from ref scoped to membership; DeleteProjectService enforces + // membership again, but this maps a 404 (not member / unknown ref) cleanly. + const project = await prisma.project.findFirst({ + where: { + externalRef: projectRef, + organization: { deletedAt: null, members: { some: { userId: authenticationResult.userId } } }, + deletedAt: null, + }, + select: { id: true }, + }); + + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + try { + await new DeleteProjectService().call({ + projectId: project.id, + userId: authenticationResult.userId, + }); + + return json({ id: project.id }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to delete project", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/services/organizationApiAccess.server.ts b/apps/webapp/app/services/organizationApiAccess.server.ts new file mode 100644 index 0000000000..039ca9e3a6 --- /dev/null +++ b/apps/webapp/app/services/organizationApiAccess.server.ts @@ -0,0 +1,78 @@ +import { json } from "@remix-run/server-runtime"; +import { isUserActorToken } from "@trigger.dev/rbac"; +import { prisma } from "~/db.server"; +import { rbac } from "~/services/rbac.server"; + +type OrganizationScopedResource = "members"; + +const RESOURCE_LABELS: Record = { + members: "members", +}; + +/** + * Resolve an org from a PAT-authenticated request's `$orgParam` (id or slug), + * scoped to the caller's membership. This membership floor matters: the OSS + * RBAC fallback grants a permissive ability to any PAT, so it can't be relied + * on to reject non-members — resolving through the membership relation does. + */ +export async function resolveOrganizationForApiUser({ + orgParam, + userId, +}: { + orgParam: string; + userId: string; +}): Promise<{ id: string; slug: string } | null> { + return prisma.organization.findFirst({ + where: { + OR: [{ id: orgParam }, { slug: orgParam }], + deletedAt: null, + members: { some: { userId } }, + }, + select: { id: true, slug: true }, + }); +} + +/** + * Org-tier RBAC for organization-scoped management API routes (team members, + * invites). A personal access token (or delegated user-actor token) carries a + * user, so enforce that user's role for the target org — mirroring the + * dashboard's `manage:members` / `read:members` gates on the same operations. + * + * Returns a `Response` to short-circuit with when access is denied, or + * `undefined` when the request may proceed. + */ +export async function authorizePatOrganizationAccess({ + request, + organizationId, + resource, + action, +}: { + request: Request; + organizationId: string; + resource: OrganizationScopedResource; + action: "read" | "manage"; +}): Promise { + const bearer = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + const isUat = !!bearer && isUserActorToken(bearer); + + const userAuth = isUat + ? await rbac.authenticateUserActor(request, { organizationId }) + : await rbac.authenticatePat(request, { organizationId }); + if (!userAuth.ok) { + return json({ error: userAuth.error }, { status: userAuth.status }); + } + + if (!userAuth.ability.can(action, { type: resource })) { + return json( + { + error: `You don't have permission to ${action} this organization's ${RESOURCE_LABELS[resource]}.`, + }, + { status: 403 } + ); + } + + return undefined; +} From 739f2ba6ea3f1436a0b7884433cf880c3fbe5cd5 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:26:45 +0100 Subject: [PATCH 02/29] feat(webapp): management API for org/project rename + delete, env var isSecret, default region, and environment pause/resume --- .../app/routes/api.v1.orgs.$orgParam.ts | 85 ++++++++++++++ .../api.v1.projects.$projectRef.$env.pause.ts | 74 ++++++++++++ ...api.v1.projects.$projectRef.$env.resume.ts | 74 ++++++++++++ ....v1.projects.$projectRef.default-region.ts | 106 ++++++++++++++++++ ...i.v1.projects.$projectRef.envvars.$slug.ts | 1 + .../app/routes/api.v1.projects.$projectRef.ts | 41 +++++-- packages/core/src/v3/schemas/api.ts | 2 + 7 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 apps/webapp/app/routes/api.v1.orgs.$orgParam.ts create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.$env.pause.ts create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.$env.resume.ts create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts new file mode 100644 index 0000000000..c42cb39597 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -0,0 +1,85 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; +import { logger } from "~/services/logger.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; + +const ParamsSchema = z.object({ + orgParam: z.string(), +}); + +const RenameOrgRequestBody = z.object({ + title: z.string().min(1), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + const method = request.method.toUpperCase(); + if (method !== "DELETE" && method !== "PATCH") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + // Org delete/rename are membership-scoped only — the dashboard settings + // route gates them on membership, not an RBAC ability. + const organization = await resolveOrganizationForApiUser({ + orgParam: parsedParams.data.orgParam, + userId: authenticationResult.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + if (method === "DELETE") { + try { + await new DeleteOrganizationService().call({ + organizationSlug: organization.slug, + userId: authenticationResult.userId, + request, + }); + } catch (error) { + // The service throws Errors with user-facing messages (active + // subscription, already deleted, etc.). + return json( + { error: error instanceof Error ? error.message : "Failed to delete organization" }, + { status: 400 } + ); + } + + return json({ id: organization.id }); + } + + const body = RenameOrgRequestBody.safeParse(await request.json()); + + if (!body.success) { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const updated = await prisma.organization.update({ + where: { id: organization.id }, + data: { title: body.data.title }, + select: { id: true, title: true, slug: true }, + }); + + return json(updated); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to update organization", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.pause.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.pause.ts new file mode 100644 index 0000000000..550aedfdca --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.pause.ts @@ -0,0 +1,74 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, + branchNameFromRequest, +} from "~/services/apiAuth.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; +import { logger } from "~/services/logger.server"; +import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), + env: z.enum(["dev", "staging", "prod", "preview"]), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { projectRef, env } = parsedParams.data; + + try { + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: false, + apiKey: false, + }); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const environment = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, + env, + branchNameFromRequest(request) + ); + + // Same env-tier gate as the regenerate-api-key route: managing an + // environment's operational state is an env-admin action. + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + resource: "apiKeys", + action: "write", + }); + if (denied) return denied; + + const result = await new PauseEnvironmentService().call(environment, "paused"); + + if (!result.success) { + return json({ error: result.error }, { status: 400 }); + } + + return json({ paused: true, state: result.state }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to pause environment", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.resume.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.resume.ts new file mode 100644 index 0000000000..ad1694cac0 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.resume.ts @@ -0,0 +1,74 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { + authenticatedEnvironmentForAuthentication, + authenticateRequest, + branchNameFromRequest, +} from "~/services/apiAuth.server"; +import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server"; +import { logger } from "~/services/logger.server"; +import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), + env: z.enum(["dev", "staging", "prod", "preview"]), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + if (request.method.toUpperCase() !== "POST") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + const { projectRef, env } = parsedParams.data; + + try { + const authenticationResult = await authenticateRequest(request, { + personalAccessToken: true, + organizationAccessToken: false, + apiKey: false, + }); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const environment = await authenticatedEnvironmentForAuthentication( + authenticationResult, + projectRef, + env, + branchNameFromRequest(request) + ); + + // Same env-tier gate as the regenerate-api-key route: managing an + // environment's operational state is an env-admin action. + const denied = await authorizePatEnvironmentAccess({ + request, + authType: authenticationResult.type, + organizationId: environment.organizationId, + projectId: environment.project.id, + envType: environment.type, + resource: "apiKeys", + action: "write", + }); + if (denied) return denied; + + const result = await new PauseEnvironmentService().call(environment, "resumed"); + + if (!result.success) { + return json({ error: result.error }, { status: 400 }); + } + + return json({ paused: false, state: result.state }); + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to resume environment", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts new file mode 100644 index 0000000000..b4775144f0 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -0,0 +1,106 @@ +import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; +import { logger } from "~/services/logger.server"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; + +const ParamsSchema = z.object({ + projectRef: z.string(), +}); + +const SetDefaultRegionRequestBody = z.object({ + // The worker group name (region name), e.g. "aws-us-east-1". + region: z.string().min(1), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + // Clearing the default is unsupported: SetDefaultRegionService has no path to + // unset defaultWorkerGroupId, so DELETE is intentionally not implemented. + if (request.method.toUpperCase() !== "PUT") { + return json({ error: "Method Not Allowed" }, { status: 405 }); + } + + const parsedParams = ParamsSchema.safeParse(params); + + if (!parsedParams.success) { + return json({ error: "Invalid Params" }, { status: 400 }); + } + + try { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const project = await prisma.project.findFirst({ + where: { + externalRef: parsedParams.data.projectRef, + organization: { + deletedAt: null, + members: { some: { userId: authenticationResult.userId } }, + }, + deletedAt: null, + }, + select: { id: true, slug: true }, + }); + + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + const body = SetDefaultRegionRequestBody.safeParse(await request.json()); + + if (!body.success) { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + // Resolve the region name to a worker group id the same way the dashboard + // does — through the presenter, which filters to regions this project can + // actually use (allowed queues / hidden / compute access). PAT users are + // never admins here. + const presenter = new RegionsPresenter(); + const [presenterError, result] = await tryCatch( + presenter.call({ userId: authenticationResult.userId, projectSlug: project.slug }) + ); + + if (presenterError) { + return json({ error: presenterError.message }, { status: 400 }); + } + + const region = result.regions.find((r) => r.name === body.data.region); + + if (!region) { + return json( + { + error: `Region '${body.data.region}' not found`, + availableRegions: result.regions.map((r) => r.name), + }, + { status: 400 } + ); + } + + try { + const updated = await new SetDefaultRegionService().call({ + projectId: project.id, + regionId: region.id, + }); + + return json({ id: updated.id, name: updated.name }); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 400 }); + } + throw error; + } + } catch (error) { + if (error instanceof Response) throw error; + logger.error("Failed to set default region", { error }); + return json({ error: "Internal Server Error" }, { status: 500 }); + } +} diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts index d0a36f3a6d..49990204c1 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.envvars.$slug.ts @@ -62,6 +62,7 @@ export async function action({ params, request }: ActionFunctionArgs) { const result = await repository.create(environment.project.id, { override: true, environmentIds: [environment.id], + isSecret: body.data.isSecret, variables: [ { key: body.data.name, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 5d72abffca..26d09ebe2d 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -6,6 +6,7 @@ import { prisma } from "~/db.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; import { logger } from "~/services/logger.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -71,8 +72,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return json(result); } +const RenameProjectRequestBody = z.object({ + name: z.string().min(1), +}); + export async function action({ request, params }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "DELETE") { + const method = request.method.toUpperCase(); + if (method !== "DELETE" && method !== "PATCH") { return json({ error: "Method Not Allowed" }, { status: 405 }); } @@ -90,8 +96,8 @@ export async function action({ request, params }: ActionFunctionArgs) { const { projectRef } = parsedParams.data; - // Resolve id from ref scoped to membership; DeleteProjectService enforces - // membership again, but this maps a 404 (not member / unknown ref) cleanly. + // Resolve id from ref scoped to membership; the services enforce membership + // again, but this maps a 404 (not member / unknown ref) cleanly. const project = await prisma.project.findFirst({ where: { externalRef: projectRef, @@ -106,15 +112,32 @@ export async function action({ request, params }: ActionFunctionArgs) { } try { - await new DeleteProjectService().call({ - projectId: project.id, - userId: authenticationResult.userId, - }); + if (method === "DELETE") { + await new DeleteProjectService().call({ + projectId: project.id, + userId: authenticationResult.userId, + }); + + return json({ id: project.id }); + } + + const body = RenameProjectRequestBody.safeParse(await request.json()); + + if (!body.success) { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const result = await new ProjectSettingsService().renameProject(project.id, body.data.name); + + if (result.isErr()) { + logger.error("Failed to rename project", { error: result.error }); + return json({ error: "Failed to rename project" }, { status: 400 }); + } - return json({ id: project.id }); + return json({ id: result.value.id, name: result.value.name }); } catch (error) { if (error instanceof Response) throw error; - logger.error("Failed to delete project", { error }); + logger.error("Failed to update project", { error }); return json({ error: "Internal Server Error" }, { status: 500 }); } } diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ccdff3b79d..ce495059fa 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1347,6 +1347,8 @@ export type ListBulkActionsResponseBody = z.infer Date: Fri, 3 Jul 2026 00:01:04 +0100 Subject: [PATCH 03/29] feat(webapp,core): return project defaultRegion (worker-group name, null when unset) in project GET and list --- .../app/routes/api.v1.orgs.$orgParam.projects.ts | 14 ++++++++++++++ .../app/routes/api.v1.projects.$projectRef.ts | 2 ++ apps/webapp/app/routes/api.v1.projects.ts | 2 ++ packages/core/src/v3/schemas/api.ts | 3 +++ 4 files changed, 21 insertions(+) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 33b68ad244..ddc56293a2 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -41,6 +41,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, include: { organization: true, + defaultWorkerGroup: { select: { name: true } }, }, }); @@ -54,6 +55,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { name: project.name, slug: project.slug, createdAt: project.createdAt, + defaultRegion: project.defaultWorkerGroup?.name ?? null, organization: { id: project.organization.id, title: project.organization.title, @@ -117,12 +119,24 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Failed to create project" }, { status: 400 }); } + // Derive from the stored id rather than assuming new projects are unset, + // so this stays correct if project creation ever inherits a default region. + const defaultRegion = project.defaultWorkerGroupId + ? ( + await prisma.workerInstanceGroup.findFirst({ + where: { id: project.defaultWorkerGroupId }, + select: { name: true }, + }) + )?.name ?? null + : null; + const result: GetProjectResponseBody = { id: project.id, externalRef: project.externalRef, name: project.name, slug: project.slug, createdAt: project.createdAt, + defaultRegion, organization: { id: project.organization.id, title: project.organization.title, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 26d09ebe2d..d189cbcd79 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -44,6 +44,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, include: { organization: true, + defaultWorkerGroup: { select: { name: true } }, }, }); @@ -61,6 +62,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { name: project.name, slug: project.slug, createdAt: project.createdAt, + defaultRegion: project.defaultWorkerGroup?.name ?? null, organization: { id: project.organization.id, title: project.organization.title, diff --git a/apps/webapp/app/routes/api.v1.projects.ts b/apps/webapp/app/routes/api.v1.projects.ts index 625df7bb9e..11d63ce04a 100644 --- a/apps/webapp/app/routes/api.v1.projects.ts +++ b/apps/webapp/app/routes/api.v1.projects.ts @@ -30,6 +30,7 @@ export async function loader({ request }: LoaderFunctionArgs) { }, include: { organization: true, + defaultWorkerGroup: { select: { name: true } }, }, }); @@ -43,6 +44,7 @@ export async function loader({ request }: LoaderFunctionArgs) { name: project.name, slug: project.slug, createdAt: project.createdAt, + defaultRegion: project.defaultWorkerGroup?.name ?? null, organization: { id: project.organization.id, title: project.organization.title, diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ce495059fa..912e10aaaf 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -38,6 +38,9 @@ export const GetProjectResponseBody = z.object({ name: z.string(), slug: z.string(), createdAt: z.coerce.date(), + // Worker-group name of the project's default region, or null when unset + // (the project falls back to the global platform default). + defaultRegion: z.string().nullable(), organization: z.object({ id: z.string(), title: z.string(), From ae9d418c595f28578bc7e5d29fdbfdbc146df489 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:39:52 +0100 Subject: [PATCH 04/29] chore: add changeset for core defaultRegion field --- .changeset/project-default-region-response.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/project-default-region-response.md diff --git a/.changeset/project-default-region-response.md b/.changeset/project-default-region-response.md new file mode 100644 index 0000000000..e23e80943b --- /dev/null +++ b/.changeset/project-default-region-response.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add `defaultRegion` (the project's default worker-group name, or null when unset) to the project GET and list API responses. From 9a65d0de39a1e9aefca1ca1a8f555fe5d742d248 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:45:43 +0100 Subject: [PATCH 05/29] style: apply oxfmt to management API routes --- .../app/routes/api.v1.orgs.$orgParam.members.$memberId.ts | 5 +---- apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts index 1c50cb315b..6ef89de3a3 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -58,10 +58,7 @@ export async function action({ request, params }: ActionFunctionArgs) { where: { organizationId: organization.id }, }); if (memberCount <= 1) { - return json( - { error: "Cannot remove the last member of an organization" }, - { status: 400 } - ); + return json({ error: "Cannot remove the last member of an organization" }, { status: 400 }); } const removed = await removeTeamMember({ diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index ddc56293a2..3d4a202b37 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -122,12 +122,12 @@ export async function action({ request, params }: ActionFunctionArgs) { // Derive from the stored id rather than assuming new projects are unset, // so this stays correct if project creation ever inherits a default region. const defaultRegion = project.defaultWorkerGroupId - ? ( + ? (( await prisma.workerInstanceGroup.findFirst({ where: { id: project.defaultWorkerGroupId }, select: { name: true }, }) - )?.name ?? null + )?.name ?? null) : null; const result: GetProjectResponseBody = { From c6be8e38c9be915f43c8c92ab85bd88f8fd77f74 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:56:56 +0100 Subject: [PATCH 06/29] fix(webapp): return 400 (not 500) on malformed JSON body in management API routes --- apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts | 9 ++++++++- apps/webapp/app/routes/api.v1.orgs.$orgParam.ts | 9 ++++++++- apps/webapp/app/routes/api.v1.orgs.ts | 9 ++++++++- .../routes/api.v1.projects.$projectRef.default-region.ts | 9 ++++++++- apps/webapp/app/routes/api.v1.projects.$projectRef.ts | 9 ++++++++- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts index b15b7f0e01..6657826a58 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -55,7 +55,14 @@ export async function action({ request, params }: ActionFunctionArgs) { }); if (denied) return denied; - const body = InviteRequestBody.safeParse(await request.json()); + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const body = InviteRequestBody.safeParse(rawBody); if (!body.success) { return json({ error: "Invalid request body" }, { status: 400 }); diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts index c42cb39597..e6ec16fbbd 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -64,7 +64,14 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ id: organization.id }); } - const body = RenameOrgRequestBody.safeParse(await request.json()); + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const body = RenameOrgRequestBody.safeParse(rawBody); if (!body.success) { return json({ error: "Invalid request body" }, { status: 400 }); diff --git a/apps/webapp/app/routes/api.v1.orgs.ts b/apps/webapp/app/routes/api.v1.orgs.ts index 7cc8d961f9..8e40597f6f 100644 --- a/apps/webapp/app/routes/api.v1.orgs.ts +++ b/apps/webapp/app/routes/api.v1.orgs.ts @@ -62,7 +62,14 @@ export async function action({ request }: ActionFunctionArgs) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); } - const body = CreateOrgRequestBody.safeParse(await request.json()); + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const body = CreateOrgRequestBody.safeParse(rawBody); if (!body.success) { return json({ error: "Invalid request body" }, { status: 400 }); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts index b4775144f0..c55a6ab2e4 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -54,7 +54,14 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Project not found" }, { status: 404 }); } - const body = SetDefaultRegionRequestBody.safeParse(await request.json()); + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const body = SetDefaultRegionRequestBody.safeParse(rawBody); if (!body.success) { return json({ error: "Invalid request body" }, { status: 400 }); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index d189cbcd79..ff551ce1a1 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -123,7 +123,14 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ id: project.id }); } - const body = RenameProjectRequestBody.safeParse(await request.json()); + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return json({ error: "Invalid request body" }, { status: 400 }); + } + + const body = RenameProjectRequestBody.safeParse(rawBody); if (!body.success) { return json({ error: "Invalid request body" }, { status: 400 }); From 66de64db94a7ded3c63ae0dac4202dd94e7d78b3 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:15:49 +0100 Subject: [PATCH 07/29] feat(webapp): Owner-gate org/project management API routes (manage:organization / manage:project) --- .../app/routes/api.v1.orgs.$orgParam.ts | 19 ++++++++++++++++--- ....v1.projects.$projectRef.default-region.ts | 14 +++++++++++++- .../app/routes/api.v1.projects.$projectRef.ts | 15 ++++++++++++++- .../services/organizationApiAccess.server.ts | 4 +++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts index e6ec16fbbd..836da68478 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -4,7 +4,10 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; import { logger } from "~/services/logger.server"; -import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { + authorizePatOrganizationAccess, + resolveOrganizationForApiUser, +} from "~/services/organizationApiAccess.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; const ParamsSchema = z.object({ @@ -34,8 +37,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); } - // Org delete/rename are membership-scoped only — the dashboard settings - // route gates them on membership, not an RBAC ability. const organization = await resolveOrganizationForApiUser({ orgParam: parsedParams.data.orgParam, userId: authenticationResult.userId, @@ -45,6 +46,18 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Organization not found" }, { status: 404 }); } + // Rename/delete are Owner-only (via manage:all). Membership resolution above + // is the OSS floor; this is the role gate enforced when the RBAC plugin runs. + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: organization.id, + resource: "organization", + action: "manage", + }); + if (denied) { + return denied; + } + if (method === "DELETE") { try { await new DeleteOrganizationService().call({ diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts index c55a6ab2e4..bd389419e6 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { logger } from "~/services/logger.server"; +import { authorizePatOrganizationAccess } from "~/services/organizationApiAccess.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; @@ -47,13 +48,24 @@ export async function action({ request, params }: ActionFunctionArgs) { }, deletedAt: null, }, - select: { id: true, slug: true }, + select: { id: true, slug: true, organizationId: true }, }); if (!project) { return json({ error: "Project not found" }, { status: 404 }); } + // Setting a project's region is Owner-only (via manage:all). + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: project.organizationId, + resource: "project", + action: "manage", + }); + if (denied) { + return denied; + } + let rawBody: unknown; try { rawBody = await request.json(); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index ff551ce1a1..2d188edbc2 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; import { logger } from "~/services/logger.server"; +import { authorizePatOrganizationAccess } from "~/services/organizationApiAccess.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; @@ -106,13 +107,25 @@ export async function action({ request, params }: ActionFunctionArgs) { organization: { deletedAt: null, members: { some: { userId: authenticationResult.userId } } }, deletedAt: null, }, - select: { id: true }, + select: { id: true, organizationId: true }, }); if (!project) { return json({ error: "Project not found" }, { status: 404 }); } + // Rename/delete are Owner-only (via manage:all); membership resolve above is + // the OSS floor, this is the role gate when the RBAC plugin runs. + const denied = await authorizePatOrganizationAccess({ + request, + organizationId: project.organizationId, + resource: "project", + action: "manage", + }); + if (denied) { + return denied; + } + try { if (method === "DELETE") { await new DeleteProjectService().call({ diff --git a/apps/webapp/app/services/organizationApiAccess.server.ts b/apps/webapp/app/services/organizationApiAccess.server.ts index 039ca9e3a6..e63c6eb5cb 100644 --- a/apps/webapp/app/services/organizationApiAccess.server.ts +++ b/apps/webapp/app/services/organizationApiAccess.server.ts @@ -3,10 +3,12 @@ import { isUserActorToken } from "@trigger.dev/rbac"; import { prisma } from "~/db.server"; import { rbac } from "~/services/rbac.server"; -type OrganizationScopedResource = "members"; +type OrganizationScopedResource = "members" | "organization" | "project"; const RESOURCE_LABELS: Record = { members: "members", + organization: "organization", + project: "projects", }; /** From cc5d7c827207beb3d7ee4ef9332ff75b61824fbd Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:21:46 +0100 Subject: [PATCH 08/29] chore(webapp): drop over-explicit comments above authorize calls --- apps/webapp/app/routes/api.v1.orgs.$orgParam.ts | 2 -- .../app/routes/api.v1.projects.$projectRef.default-region.ts | 1 - apps/webapp/app/routes/api.v1.projects.$projectRef.ts | 2 -- 3 files changed, 5 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts index 836da68478..2021f21c73 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -46,8 +46,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Organization not found" }, { status: 404 }); } - // Rename/delete are Owner-only (via manage:all). Membership resolution above - // is the OSS floor; this is the role gate enforced when the RBAC plugin runs. const denied = await authorizePatOrganizationAccess({ request, organizationId: organization.id, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts index bd389419e6..c1ccdceecf 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -55,7 +55,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Project not found" }, { status: 404 }); } - // Setting a project's region is Owner-only (via manage:all). const denied = await authorizePatOrganizationAccess({ request, organizationId: project.organizationId, diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 2d188edbc2..2aab2c97f0 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -114,8 +114,6 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Project not found" }, { status: 404 }); } - // Rename/delete are Owner-only (via manage:all); membership resolve above is - // the OSS floor, this is the role gate when the RBAC plugin runs. const denied = await authorizePatOrganizationAccess({ request, organizationId: project.organizationId, From b53f53207df70828e7f1b662df1acff81b318220 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:48:47 +0100 Subject: [PATCH 09/29] feat: require owner role for org and project management dashboard actions --- .../route.tsx | 76 +++--- .../route.tsx | 165 +++++++------ .../route.tsx | 224 ++++++++++-------- 3 files changed, 256 insertions(+), 209 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 3ca761e4cd..bd8dadc82d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -7,7 +7,7 @@ import { MapPinIcon, } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -51,9 +51,11 @@ import { useFeatures } from "~/hooks/useFeatures"; import { useOrganization } from "~/hooks/useOrganizations"; import { useHasAdminAccess } from "~/hooks/useUser"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { findProjectBySlug } from "~/models/project.server"; import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; import { requireUser } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { docsPath, EnvironmentParamSchema, @@ -90,44 +92,56 @@ const FormSchema = z.object({ regionId: z.string(), }); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const user = await requireUser(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); +export const action = dashboardAction( + { + params: EnvironmentParamSchema, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + }, + async ({ user, ability, request, params }) => { + const { organizationSlug, projectParam, envParam } = params; - const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!ability.can("manage", { type: "project" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } - const redirectPath = regionsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); - if (!project) { - throw await redirectWithErrorMessage(redirectPath, request, "Project not found"); - } + const redirectPath = regionsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); - const formData = await request.formData(); - const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); + if (!project) { + throw await redirectWithErrorMessage(redirectPath, request, "Project not found"); + } - if (!parsedFormData.success) { - throw await redirectWithErrorMessage(redirectPath, request, "No region specified"); - } + const formData = await request.formData(); + const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); - const service = new SetDefaultRegionService(); - const [error, result] = await tryCatch( - service.call({ - projectId: project.id, - regionId: parsedFormData.data.regionId, - isAdmin: user.admin || user.isImpersonating, - }) - ); + if (!parsedFormData.success) { + throw await redirectWithErrorMessage(redirectPath, request, "No region specified"); + } - if (error) { - return redirectWithErrorMessage(redirectPath, request, error.message); - } + const service = new SetDefaultRegionService(); + const [error, result] = await tryCatch( + service.call({ + projectId: project.id, + regionId: parsedFormData.data.regionId, + isAdmin: user.admin || user.isImpersonating, + }) + ); - return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`); -}; + if (error) { + return redirectWithErrorMessage(redirectPath, request, error.message); + } + + return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`); + } +); export default function Page() { const { regions, isPaying: _isPaying } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index 7ca93c2bc5..603229a70b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -2,7 +2,7 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useNavigation } from "@remix-run/react"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { InlineCode } from "~/components/code/InlineCode"; import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout"; @@ -19,9 +19,10 @@ import { Label } from "~/components/primitives/Label"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder"; import { useState } from "react"; @@ -59,98 +60,112 @@ function createSchema( ]); } -export const action: ActionFunction = async ({ request, params }) => { - const userId = await requireUserId(request); - const { organizationSlug, projectParam } = params; - if (!organizationSlug || !projectParam) { - return json( - { errors: { body: "organizationSlug and projectParam are required" } }, - { status: 400 } - ); - } - - const formData = await request.formData(); +const Params = z.object({ + organizationSlug: z.string(), + projectParam: z.string(), +}); - const schema = createSchema({ - getSlugMatch: (slug) => { - return { isMatch: slug === projectParam, projectSlug: projectParam }; +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; }, - }); - const submission = parseWithZod(formData, { schema }); + }, + async ({ user, ability, request, params }) => { + const userId = user.id; + const { organizationSlug, projectParam } = params; - if (submission.status !== "success") { - return json(submission.reply()); - } + const formData = await request.formData(); - const projectSettingsService = new ProjectSettingsService(); - const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( - organizationSlug, - projectParam, - userId - ); + const schema = createSchema({ + getSlugMatch: (slug) => { + return { isMatch: slug === projectParam, projectSlug: projectParam }; + }, + }); + const submission = parseWithZod(formData, { schema }); - if (membershipResultOrFail.isErr()) { - return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); - } + if (submission.status !== "success") { + return json(submission.reply()); + } - const { projectId } = membershipResultOrFail.value; + const projectSettingsService = new ProjectSettingsService(); + const membershipResultOrFail = await projectSettingsService.verifyProjectMembership( + organizationSlug, + projectParam, + userId + ); - switch (submission.value.action) { - case "rename": { - const resultOrFail = await projectSettingsService.renameProject( - projectId, - submission.value.projectName - ); + if (membershipResultOrFail.isErr()) { + return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 }); + } - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; + const { projectId } = membershipResultOrFail.value; - logger.error("Failed to rename project", { - error: resultOrFail.error, - }); - return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + switch (submission.value.action) { + case "rename": { + if (!ability.can("manage", { type: "project" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + const resultOrFail = await projectSettingsService.renameProject( + projectId, + submission.value.projectName + ); + + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; + + logger.error("Failed to rename project", { + error: resultOrFail.error, + }); + return json({ errors: { body: "Failed to rename project" } }, { status: 400 }); + } } } - } - return redirectWithSuccessMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project renamed to ${submission.value.projectName}` - ); - } - case "delete": { - const resultOrFail = await projectSettingsService.deleteProject(projectId, userId); + return redirectWithSuccessMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project renamed to ${submission.value.projectName}` + ); + } + case "delete": { + if (!ability.can("manage", { type: "project" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + const resultOrFail = await projectSettingsService.deleteProject(projectId, userId); - if (resultOrFail.isErr()) { - switch (resultOrFail.error.type) { - case "other": - default: { - resultOrFail.error.type satisfies "other"; + if (resultOrFail.isErr()) { + switch (resultOrFail.error.type) { + case "other": + default: { + resultOrFail.error.type satisfies "other"; - logger.error("Failed to delete project", { - error: resultOrFail.error, - }); - return redirectWithErrorMessage( - v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Project ${projectParam} could not be deleted` - ); + logger.error("Failed to delete project", { + error: resultOrFail.error, + }); + return redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Project ${projectParam} could not be deleted` + ); + } } } - } - return redirectWithSuccessMessage( - organizationPath({ slug: organizationSlug }), - request, - "Project deleted" - ); + return redirectWithSuccessMessage( + organizationPath({ slug: organizationSlug }), + request, + "Project deleted" + ); + } } } -}; +); export default function GeneralSettingsPage() { const project = useProject(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index 185d3e1635..e95e0c92ab 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -9,7 +9,7 @@ import { TrashIcon, } from "@heroicons/react/20/solid"; import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react"; -import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect, useRef, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -45,10 +45,12 @@ import { SpinnerWhite } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { useFaviconUrl } from "~/hooks/useFaviconUrl"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { clearCurrentProject } from "~/services/dashboardPreferences.server"; import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; import { logger } from "~/services/logger.server"; import { requireUser, requireUserId } from "~/services/session.server"; +import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { cn } from "~/utils/cn"; import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon"; import { OrganizationParamsSchema, organizationSettingsPath, rootPath } from "~/utils/pathBuilder"; @@ -143,98 +145,137 @@ export function createSchema( ]); } -export const action: ActionFunction = async ({ request, params }) => { - const user = await requireUser(request); - const { organizationSlug } = params; - if (!organizationSlug) { - return json({ errors: { body: "organizationSlug is required" } }, { status: 400 }); - } +const Params = z.object({ + organizationSlug: z.string(), +}); - const formData = await request.formData(); - const schema = createSchema({ - getSlugMatch: (slug) => { - return { isMatch: slug === organizationSlug, organizationSlug }; +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; }, - }); - const submission = parseWithZod(formData, { schema }); - - if (submission.status !== "success") { - return json(submission.reply()); - } + }, + async ({ ability, request, params }) => { + const user = await requireUser(request); + const { organizationSlug } = params; + + const formData = await request.formData(); + const schema = createSchema({ + getSlugMatch: (slug) => { + return { isMatch: slug === organizationSlug, organizationSlug }; + }, + }); + const submission = parseWithZod(formData, { schema }); + + if (submission.status !== "success") { + return json(submission.reply()); + } - try { - switch (submission.value.action) { - case "rename": { - await prisma.organization.update({ - where: { - slug: organizationSlug, - members: { - some: { - userId: user.id, + try { + switch (submission.value.action) { + case "rename": { + if (!ability.can("manage", { type: "organization" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + await prisma.organization.update({ + where: { + slug: organizationSlug, + members: { + some: { + userId: user.id, + }, }, }, - }, - data: { - title: submission.value.organizationName, - }, - }); - - return redirectWithSuccessMessage( - organizationSettingsPath({ slug: organizationSlug }), - request, - `Organization renamed to ${submission.value.organizationName}` - ); - } - case "delete": { - const deleteOrganizationService = new DeleteOrganizationService(); - try { - await deleteOrganizationService.call({ organizationSlug, userId: user.id, request }); - - //we need to clear the project from the session - await clearCurrentProject({ - user, - }); - return redirect(rootPath()); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : JSON.stringify(error); - logger.error("Organization could not be deleted", { - error: errorMessage, + data: { + title: submission.value.organizationName, + }, }); - return redirectWithErrorMessage( + + return redirectWithSuccessMessage( organizationSettingsPath({ slug: organizationSlug }), request, - errorMessage + `Organization renamed to ${submission.value.organizationName}` ); } - } - case "avatar": { - const orgWhere = { - slug: organizationSlug, - members: { some: { userId: user.id } }, - }; + case "delete": { + if (!ability.can("manage", { type: "organization" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + const deleteOrganizationService = new DeleteOrganizationService(); + try { + await deleteOrganizationService.call({ organizationSlug, userId: user.id, request }); + + //we need to clear the project from the session + await clearCurrentProject({ + user, + }); + return redirect(rootPath()); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + logger.error("Organization could not be deleted", { + error: errorMessage, + }); + return redirectWithErrorMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + errorMessage + ); + } + } + case "avatar": { + const orgWhere = { + slug: organizationSlug, + members: { some: { userId: user.id } }, + }; + + if (submission.value.type === "image") { + const url = submission.value.url ?? ""; + const domain = url ? extractDomain(url) : null; + + const existing = await prisma.organization.findFirst({ + where: orgWhere, + select: { avatar: true, onboardingData: true }, + }); + + const existingData = toRecord(existing?.onboardingData); + const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar); + const lastIconHex = extractLastIconHex(existingAvatar); + + await prisma.organization.update({ + where: orgWhere, + data: { + avatar: { + type: "image", + url: domain ? buildFaviconUrl(domain) : "", + ...(lastIconHex ? { lastIconHex } : {}), + }, + onboardingData: { ...existingData, companyUrl: url }, + }, + }); - if (submission.value.type === "image") { - const url = submission.value.url ?? ""; - const domain = url ? extractDomain(url) : null; + return redirectWithSuccessMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + `Updated logo` + ); + } - const existing = await prisma.organization.findFirst({ - where: orgWhere, - select: { avatar: true, onboardingData: true }, - }); + const avatar = AvatarData.safeParse(submission.value); - const existingData = toRecord(existing?.onboardingData); - const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar); - const lastIconHex = extractLastIconHex(existingAvatar); + if (!avatar.success) { + return redirectWithErrorMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + avatar.error.message + ); + } await prisma.organization.update({ where: orgWhere, data: { - avatar: { - type: "image", - url: domain ? buildFaviconUrl(domain) : "", - ...(lastIconHex ? { lastIconHex } : {}), - }, - onboardingData: { ...existingData, companyUrl: url }, + avatar: avatar.data, }, }); @@ -244,36 +285,13 @@ export const action: ActionFunction = async ({ request, params }) => { `Updated logo` ); } - - const avatar = AvatarData.safeParse(submission.value); - - if (!avatar.success) { - return redirectWithErrorMessage( - organizationSettingsPath({ slug: organizationSlug }), - request, - avatar.error.message - ); - } - - await prisma.organization.update({ - where: orgWhere, - data: { - avatar: avatar.data, - }, - }); - - return redirectWithSuccessMessage( - organizationSettingsPath({ slug: organizationSlug }), - request, - `Updated logo` - ); } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "An unexpected error occurred"; + return json({ errors: { body: message } }, { status: 400 }); } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "An unexpected error occurred"; - return json({ errors: { body: message } }, { status: 400 }); } -}; +); export default function Page() { const { organization } = useTypedLoaderData(); From 308e8085ee8968298165bce7ab4b38ebd0b909ee Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:48:51 +0100 Subject: [PATCH 10/29] docs: tighten defaultRegion changeset wording --- .changeset/project-default-region-response.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/project-default-region-response.md b/.changeset/project-default-region-response.md index e23e80943b..86d1856ac5 100644 --- a/.changeset/project-default-region-response.md +++ b/.changeset/project-default-region-response.md @@ -2,4 +2,4 @@ "@trigger.dev/core": patch --- -Add `defaultRegion` (the project's default worker-group name, or null when unset) to the project GET and list API responses. +Add `defaultRegion` to the project GET and list API responses; null when unset. From e71b8d3ab48411db30508a84483fa0e0e285d3a7 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:15:52 +0100 Subject: [PATCH 11/29] fix: redirect with error toast instead of raw json on denied dashboard management actions --- .../route.tsx | 18 +++++++++++------- .../route.tsx | 12 ++++++++++-- .../route.tsx | 12 ++++++++++-- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index bd8dadc82d..9ca2099a58 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -7,7 +7,7 @@ import { MapPinIcon, } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -103,18 +103,22 @@ export const action = dashboardAction( async ({ user, ability, request, params }) => { const { organizationSlug, projectParam, envParam } = params; - if (!ability.can("manage", { type: "project" })) { - return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); - } - - const project = await findProjectBySlug(organizationSlug, projectParam, user.id); - const redirectPath = regionsPath( { slug: organizationSlug }, { slug: projectParam }, { slug: envParam } ); + if (!ability.can("manage", { type: "project" })) { + throw await redirectWithErrorMessage( + redirectPath, + request, + "You don't have permission to change the default region" + ); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); + if (!project) { throw await redirectWithErrorMessage(redirectPath, request, "Project not found"); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index 603229a70b..ee6f16e282 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -106,7 +106,11 @@ export const action = dashboardAction( switch (submission.value.action) { case "rename": { if (!ability.can("manage", { type: "project" })) { - return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + throw redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + "You don't have permission to rename this project" + ); } const resultOrFail = await projectSettingsService.renameProject( projectId, @@ -135,7 +139,11 @@ export const action = dashboardAction( } case "delete": { if (!ability.can("manage", { type: "project" })) { - return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + throw redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + "You don't have permission to delete this project" + ); } const resultOrFail = await projectSettingsService.deleteProject(projectId, userId); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index e95e0c92ab..2ff2bef4e4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -177,7 +177,11 @@ export const action = dashboardAction( switch (submission.value.action) { case "rename": { if (!ability.can("manage", { type: "organization" })) { - return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + throw redirectWithErrorMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + "You don't have permission to rename this organization" + ); } await prisma.organization.update({ where: { @@ -201,7 +205,11 @@ export const action = dashboardAction( } case "delete": { if (!ability.can("manage", { type: "organization" })) { - return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + throw redirectWithErrorMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + "You don't have permission to delete this organization" + ); } const deleteOrganizationService = new DeleteOrganizationService(); try { From b1cacacc124c1d3095f5fcfb97e5b97e280377ac Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:55:25 +0100 Subject: [PATCH 12/29] fix: make defaultRegion optional in project API response for version-skew safety --- packages/core/src/v3/schemas/api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 912e10aaaf..5c32d21c0e 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -39,8 +39,9 @@ export const GetProjectResponseBody = z.object({ slug: z.string(), createdAt: z.coerce.date(), // Worker-group name of the project's default region, or null when unset - // (the project falls back to the global platform default). - defaultRegion: z.string().nullable(), + // (the project falls back to the global platform default). Optional so a + // newer client still parses responses from an older server that omits it. + defaultRegion: z.string().nullable().optional(), organization: z.object({ id: z.string(), title: z.string(), From 727e5d8ad61cc676ea5f29dcd08419176394e244 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:43:32 +0100 Subject: [PATCH 13/29] feat: add createActionPATApiRoute builder and use it for set-default-region --- ....v1.projects.$projectRef.default-region.ts | 107 +++---- .../routeBuilders/apiBuilder.server.ts | 267 ++++++++++++++++++ apps/webapp/test/auth-api.e2e.full.test.ts | 106 +++++++ 3 files changed, 409 insertions(+), 71 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts index c1ccdceecf..c0c861be2f 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -1,13 +1,9 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import { z } from "zod"; import { prisma } from "~/db.server"; import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server"; -import { logger } from "~/services/logger.server"; -import { authorizePatOrganizationAccess } from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; -import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server"; const ParamsSchema = z.object({ @@ -19,106 +15,75 @@ const SetDefaultRegionRequestBody = z.object({ region: z.string().min(1), }); -export async function action({ request, params }: ActionFunctionArgs) { - // Clearing the default is unsupported: SetDefaultRegionService has no path to - // unset defaultWorkerGroupId, so DELETE is intentionally not implemented. - if (request.method.toUpperCase() !== "PUT") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - +// Clearing the default is unsupported: SetDefaultRegionService has no path to +// unset defaultWorkerGroupId, so only PUT is implemented (other methods 405). +export const action = createActionPATApiRoute( + { + method: "PUT", + params: ParamsSchema, + body: SetDefaultRegionRequestBody, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the manage:project gate below. + context: async ({ projectRef }) => { + const project = await prisma.project.findFirst({ + where: { externalRef: projectRef, deletedAt: null }, + select: { organizationId: true }, + }); + return project ? { organizationId: project.organizationId } : {}; + }, + authorization: { action: "manage", resource: () => ({ type: "project" }) }, + }, + async ({ params, body, authentication }) => { + // Membership floor: resolve scoped to the caller so a non-member gets a 404 + // (the authorization block enforces the role; this enforces membership). const project = await prisma.project.findFirst({ where: { - externalRef: parsedParams.data.projectRef, + externalRef: params.projectRef, organization: { deletedAt: null, - members: { some: { userId: authenticationResult.userId } }, + members: { some: { userId: authentication.userId } }, }, deletedAt: null, }, - select: { id: true, slug: true, organizationId: true }, + select: { id: true, slug: true }, }); if (!project) { return json({ error: "Project not found" }, { status: 404 }); } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: project.organizationId, - resource: "project", - action: "manage", - }); - if (denied) { - return denied; - } - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return json({ error: "Invalid request body" }, { status: 400 }); - } - - const body = SetDefaultRegionRequestBody.safeParse(rawBody); - - if (!body.success) { - return json({ error: "Invalid request body" }, { status: 400 }); - } - // Resolve the region name to a worker group id the same way the dashboard // does — through the presenter, which filters to regions this project can // actually use (allowed queues / hidden / compute access). PAT users are // never admins here. const presenter = new RegionsPresenter(); const [presenterError, result] = await tryCatch( - presenter.call({ userId: authenticationResult.userId, projectSlug: project.slug }) + presenter.call({ userId: authentication.userId, projectSlug: project.slug }) ); if (presenterError) { return json({ error: presenterError.message }, { status: 400 }); } - const region = result.regions.find((r) => r.name === body.data.region); + const region = result.regions.find((r) => r.name === body.region); if (!region) { return json( { - error: `Region '${body.data.region}' not found`, + error: `Region '${body.region}' not found`, availableRegions: result.regions.map((r) => r.name), }, { status: 400 } ); } - try { - const updated = await new SetDefaultRegionService().call({ - projectId: project.id, - regionId: region.id, - }); + // SetDefaultRegionService throws ServiceValidationError; the builder maps it + // to its status (default 400). + const updated = await new SetDefaultRegionService().call({ + projectId: project.id, + regionId: region.id, + }); - return json({ id: updated.id, name: updated.name }); - } catch (error) { - if (error instanceof ServiceValidationError) { - return json({ error: error.message }, { status: 400 }); - } - throw error; - } - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to set default region", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); + return json({ id: updated.id, name: updated.name }); } -} +); diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index e5842bb861..665bb9fc2c 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -637,6 +637,273 @@ export function createLoaderPATApiRoute< }; } +// The mutation counterpart to `createLoaderPATApiRoute`. Same PAT/user-actor +// auth + `context` role-floor + `authorization` gating + `tenantContext` +// user attribution, plus a method guard, body parsing, and — unlike the +// loader — `ServiceValidationError` is mapped to its `.status` so services +// can raise typed 4xx errors instead of the route string-matching messages. +// Deliberately self-contained (not sharing internals with the loader) so +// existing PAT loader routes are untouched. +type PATActionRouteBuilderOptions< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +> = PATRouteBuilderOptions & { + method?: "POST" | "PUT" | "DELETE" | "PATCH"; + body?: TBodySchema; +}; + +type PATActionHandlerFunction< + TParamsSchema extends AnyZodSchema | undefined, + TSearchParamsSchema extends AnyZodSchema | undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +> = (args: { + 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; + body: TBodySchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + authentication: PersonalAccessTokenAuthenticationResult; + ability: RbacAbility; + request: Request; + apiVersion: API_VERSIONS; +}) => Promise; + +export function createActionPATApiRoute< + TParamsSchema extends AnyZodSchema | undefined = undefined, + TSearchParamsSchema extends AnyZodSchema | undefined = undefined, + THeadersSchema extends AnyZodSchema | undefined = undefined, + TBodySchema extends AnyZodSchema | undefined = undefined, +>( + options: PATActionRouteBuilderOptions< + TParamsSchema, + TSearchParamsSchema, + THeadersSchema, + TBodySchema + >, + handler: PATActionHandlerFunction +) { + return async function action({ request, params }: ActionFunctionArgs) { + const { + params: paramsSchema, + searchParams: searchParamsSchema, + headers: headersSchema, + body: bodySchema, + corsStrategy = "none", + context: contextFn, + authorization, + method, + } = options; + + if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { + return apiCors(request, json({})); + } + + if (method && request.method.toUpperCase() !== method) { + return await wrapResponse( + request, + json({ error: "Method not allowed" }, { status: 405, headers: { Allow: method } }), + corsStrategy !== "none" + ); + } + + try { + let parsedParams: any = undefined; + if (paramsSchema) { + const parsed = paramsSchema.safeParse(params); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { error: "Params Error", details: fromZodError(parsed.error).details }, + { status: 400 } + ), + corsStrategy !== "none" + ); + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (searchParamsSchema) { + const searchParams = Object.fromEntries(new URL(request.url).searchParams); + const parsed = searchParamsSchema.safeParse(searchParams); + if (!parsed.success) { + return await wrapResponse( + request, + json( + { error: "Query Error", details: fromZodError(parsed.error).details }, + { status: 400 } + ), + corsStrategy !== "none" + ); + } + parsedSearchParams = parsed.data; + } + + let parsedHeaders: any = undefined; + if (headersSchema) { + const rawHeaders = Object.fromEntries(request.headers); + const headers = headersSchema.safeParse(rawHeaders); + if (!headers.success) { + return await wrapResponse( + request, + json( + { error: "Headers Error", details: fromZodError(headers.error).details }, + { status: 400 } + ), + corsStrategy !== "none" + ); + } + parsedHeaders = headers.data; + } + + let parsedBody: any = undefined; + if (bodySchema) { + const rawBody = await request.text(); + if (rawBody.length === 0) { + return await wrapResponse( + request, + json({ error: "Request body is empty" }, { status: 400 }), + corsStrategy !== "none" + ); + } + + const rawParsedJson = safeJsonParse(rawBody); + if (!rawParsedJson) { + return await wrapResponse( + request, + json({ error: "Invalid JSON" }, { status: 400 }), + corsStrategy !== "none" + ); + } + + const body = bodySchema.safeParse(rawParsedJson); + if (!body.success) { + return await wrapResponse( + request, + json({ error: fromZodError(body.error).toString() }, { status: 400 }), + corsStrategy !== "none" + ); + } + parsedBody = body.data; + } + + const apiVersion = getApiVersion(request); + + // `context` resolves the target org/project so the plugin can compute the + // caller's role floor for the cap intersection (see the loader builder). + const ctx = contextFn ? await contextFn(parsedParams, request) : {}; + + let authenticationResult: PersonalAccessTokenAuthenticationResult; + let ability: RbacAbility; + + const bearer = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (bearer && isUserActorToken(bearer)) { + const uatAuth = await rbac.authenticateUserActor(request, ctx); + if (!uatAuth.ok) { + return await wrapResponse( + request, + json({ error: uatAuth.error }, { status: uatAuth.status }), + corsStrategy !== "none" + ); + } + authenticationResult = { userId: uatAuth.userId }; + ability = uatAuth.ability; + } else { + const patAuth = await rbac.authenticatePat(request, ctx); + if (!patAuth.ok) { + return await wrapResponse( + request, + json({ error: patAuth.error }, { status: patAuth.status }), + corsStrategy !== "none" + ); + } + authenticationResult = { userId: patAuth.userId }; + ability = patAuth.ability; + await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt); + } + + 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" + ); + } + } + + // PAT auth carries `userId` but no environment — enrich the scope the + // Express middleware established so Sentry events get user attribution. + tenantContext.enrich({ userId: authenticationResult.userId }); + + const result = await handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + body: parsedBody, + authentication: authenticationResult, + ability, + request, + apiVersion, + }); + return await wrapResponse(request, result, corsStrategy !== "none"); + } catch (error) { + try { + if (error instanceof Response) { + return await wrapResponse(request, error, corsStrategy !== "none"); + } + + logBoundaryError("Error in action", error, request.url); + + // Typed validation errors map to their own status (default 400); + // logBoundaryError already classified them as expected (no Sentry). + if (error instanceof ServiceValidationError) { + return await wrapResponse( + request, + json({ error: error.message }, { status: error.status ?? 400 }), + corsStrategy !== "none" + ); + } + + return await wrapResponse( + request, + json({ error: "Internal Server Error" }, { status: 500 }), + corsStrategy !== "none" + ); + } catch (innerError) { + logger.error("[apiBuilder] Failed to handle error", { error, innerError }); + + return json({ error: "Internal Server Error" }, { status: 500 }); + } + } + }; +} + type ApiKeyActionRouteBuilderOptions< TParamsSchema extends AnyZodSchema | undefined = undefined, TSearchParamsSchema extends AnyZodSchema | undefined = undefined, diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index 2d25dca6f5..1cf2761c47 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -2910,4 +2910,110 @@ describe("API", () => { }); }); }); + + // PAT *action* routes (createActionPATApiRoute). Target: PUT + // /api/v1/projects/:projectRef/default-region — the first consumer of the + // new mutation builder. Exercises the method guard, body parsing, PAT auth, + // and the membership floor. The manage:project authorization block can't be + // driven to a 403 here: the OSS fallback ability is permissive + // (can: () => true), so role-based denial only bites with the cloud plugin + // loaded — that path is covered by the plugin's own tests. + describe("Default region — PAT action route", () => { + const pathFor = (ref: string) => `/api/v1/projects/${ref}/default-region`; + const putRegion = (path: string, headers: Record, body?: unknown) => + getTestServer().webapp.fetch(path, { + method: "PUT", + headers: { "Content-Type": "application/json", ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + it("missing Authorization: 401", async () => { + const res = await putRegion(pathFor("proj_nope"), {}, { region: "aws-us-east-1" }); + expect(res.status).toBe(401); + }); + + it("non-PAT token: 401", async () => { + const res = await putRegion( + pathFor("proj_nope"), + { Authorization: "Bearer not-a-real-token" }, + { region: "aws-us-east-1" } + ); + expect(res.status).toBe(401); + }); + + it("revoked PAT: 401", async () => { + const server = getTestServer(); + const { user } = await seedTestUserProject(server.prisma); + const revoked = await seedTestPAT(server.prisma, user.id, { revoked: true }); + const res = await putRegion( + pathFor("proj_nope"), + { Authorization: `Bearer ${revoked.token}` }, + { region: "aws-us-east-1" } + ); + expect(res.status).toBe(401); + }); + + it("wrong method (POST): 405", async () => { + // A valid token is needed to clear the global api rate-limit middleware + // (it 401s unauthenticated /api requests before the route runs); the + // builder's method guard then rejects the non-PUT with 405. + const server = getTestServer(); + const { pat } = await seedTestUserProject(server.prisma); + const res = await getTestServer().webapp.fetch(pathFor("proj_nope"), { + method: "POST", + headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ region: "aws-us-east-1" }), + }); + expect(res.status).toBe(405); + }); + + it("valid PAT, empty body: 400", async () => { + const server = getTestServer(); + const { project, pat } = await seedTestUserProject(server.prisma); + const res = await getTestServer().webapp.fetch(pathFor(project.externalRef), { + method: "PUT", + headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, + }); + expect(res.status).toBe(400); + }); + + it("valid PAT, body missing region: 400", async () => { + const server = getTestServer(); + const { project, pat } = await seedTestUserProject(server.prisma); + const res = await putRegion( + pathFor(project.externalRef), + { Authorization: `Bearer ${pat.token}` }, + {} + ); + expect(res.status).toBe(400); + }); + + it("valid PAT, project in another user's org: 404 (membership floor)", async () => { + const server = getTestServer(); + const a = await seedTestUserProject(server.prisma); + const b = await seedTestUserProject(server.prisma); + const res = await putRegion( + pathFor(b.project.externalRef), + { Authorization: `Bearer ${a.pat.token}` }, + { region: "aws-us-east-1" } + ); + expect(res.status).toBe(404); + }); + + it("valid PAT, own project, unknown region: auth passes, handler runs", async () => { + const server = getTestServer(); + const { project, pat } = await seedTestUserProject(server.prisma); + const res = await putRegion( + pathFor(project.externalRef), + { Authorization: `Bearer ${pat.token}` }, + { region: "definitely-not-a-region" } + ); + // No worker groups seeded → the presenter yields no regions → the handler + // returns 400 "region not found". The point: the builder let the request + // through to the handler (auth + method + body all passed). + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + expect(res.status).not.toBe(405); + }); + }); }); From b79afb9eefd265371ff8bdefc43e62087ea136de Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:36:55 +0100 Subject: [PATCH 14/29] refactor: migrate PAT org/project management routes to createActionPATApiRoute --- apps/webapp/app/models/member.server.ts | 1 + ...api.v1.orgs.$orgParam.invites.$inviteId.ts | 87 ++++---- .../routes/api.v1.orgs.$orgParam.invites.ts | 80 +++----- ...api.v1.orgs.$orgParam.members.$memberId.ts | 73 +++---- .../routes/api.v1.orgs.$orgParam.members.ts | 60 +++--- .../routes/api.v1.orgs.$orgParam.projects.ts | 83 ++++---- .../app/routes/api.v1.orgs.$orgParam.ts | 69 +++---- apps/webapp/app/routes/api.v1.orgs.ts | 106 ++++------ .../app/routes/api.v1.projects.$projectRef.ts | 187 ++++++++---------- apps/webapp/app/routes/api.v1.projects.ts | 89 ++++----- 10 files changed, 329 insertions(+), 506 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 7cd515014b..3d8120c17d 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -6,6 +6,7 @@ import { logger } from "~/services/logger.server"; import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; +import { ServiceValidationError } from "~/v3/services/common.server"; export const INVITE_NOT_FOUND = "Invite not found"; export const INVITE_BLOCKED_DIRECTORY_MANAGED = diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts index 7501a06942..d5617d90a8 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts @@ -1,69 +1,54 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { prisma } from "~/db.server"; import { revokeInvite } from "~/models/member.server"; -import { logger } from "~/services/logger.server"; -import { - authorizePatOrganizationAccess, - resolveOrganizationForApiUser, -} from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ orgParam: z.string(), inviteId: z.string(), }); -export async function action({ request, params }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "DELETE") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - const { orgParam, inviteId } = parsedParams.data; - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - +export const action = createActionPATApiRoute( + { + method: "DELETE", + params: ParamsSchema, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the manage:members gate below. + context: async ({ orgParam }) => { + const org = await prisma.organization.findFirst({ + where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: () => ({ type: "members" }) }, + }, + async ({ params, authentication }) => { + // Membership floor: a non-member gets a 404. const organization = await resolveOrganizationForApiUser({ - orgParam, - userId: authenticationResult.userId, + orgParam: params.orgParam, + userId: authentication.userId, }); if (!organization) { return json({ error: "Organization not found" }, { status: 404 }); } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: organization.id, - resource: "members", - action: "manage", - }); - if (denied) return denied; - - const revoked = await revokeInvite({ - userId: authenticationResult.userId, - orgSlug: organization.slug, - inviteId, - }); - - return json({ id: inviteId, email: revoked.email }); - } catch (error) { - if (error instanceof Response) throw error; - if (error instanceof Error && error.message === "Invite not found") { - return json({ error: error.message }, { status: 404 }); + try { + const revoked = await revokeInvite({ + userId: authentication.userId, + orgSlug: organization.slug, + inviteId: params.inviteId, + }); + + return json({ id: params.inviteId, email: revoked.email }); + } catch (error) { + if (error instanceof Error && error.message === "Invite not found") { + return json({ error: error.message }, { status: 404 }); + } + throw error; } - logger.error("Failed to revoke invite", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts index 6657826a58..8cdb01f9a3 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -1,14 +1,11 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { inviteMembers } from "~/models/member.server"; import { logger } from "~/services/logger.server"; -import { - authorizePatOrganizationAccess, - resolveOrganizationForApiUser, -} from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { scheduleEmail } from "~/services/scheduleEmail.server"; import { acceptInvitePath } from "~/utils/pathBuilder"; @@ -20,58 +17,37 @@ const InviteRequestBody = z.object({ emails: z.string().email().array().nonempty("At least one email is required"), }); -export async function action({ request, params }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "POST") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - +export const action = createActionPATApiRoute( + { + method: "POST", + params: ParamsSchema, + body: InviteRequestBody, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the manage:members gate below. + context: async ({ orgParam }) => { + const org = await prisma.organization.findFirst({ + where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: () => ({ type: "members" }) }, + }, + async ({ params, body, authentication }) => { + // Membership floor: a non-member gets a 404. const organization = await resolveOrganizationForApiUser({ - orgParam: parsedParams.data.orgParam, - userId: authenticationResult.userId, + orgParam: params.orgParam, + userId: authentication.userId, }); if (!organization) { return json({ error: "Organization not found" }, { status: 404 }); } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: organization.id, - resource: "members", - action: "manage", - }); - if (denied) return denied; - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return json({ error: "Invalid request body" }, { status: 400 }); - } - - const body = InviteRequestBody.safeParse(rawBody); - - if (!body.success) { - return json({ error: "Invalid request body" }, { status: 400 }); - } - const invites = await inviteMembers({ slug: organization.slug, - emails: body.data.emails, - userId: authenticationResult.userId, + emails: body.emails, + userId: authentication.userId, }); // Send invite emails the same way the dashboard invite action does. A @@ -98,9 +74,5 @@ export async function action({ request, params }: ActionFunctionArgs) { }, { status: 201 } ); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to invite org members", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts index 6ef89de3a3..df4a9518a5 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -1,57 +1,41 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { removeTeamMember } from "~/models/member.server"; -import { logger } from "~/services/logger.server"; -import { - authorizePatOrganizationAccess, - resolveOrganizationForApiUser, -} from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ orgParam: z.string(), memberId: z.string(), }); -export async function action({ request, params }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "DELETE") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - const { orgParam, memberId } = parsedParams.data; - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - +export const action = createActionPATApiRoute( + { + method: "DELETE", + params: ParamsSchema, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the manage:members gate below. + context: async ({ orgParam }) => { + const org = await prisma.organization.findFirst({ + where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: () => ({ type: "members" }) }, + }, + async ({ params, authentication }) => { + // Membership floor: a non-member gets a 404. const organization = await resolveOrganizationForApiUser({ - orgParam, - userId: authenticationResult.userId, + orgParam: params.orgParam, + userId: authentication.userId, }); if (!organization) { return json({ error: "Organization not found" }, { status: 404 }); } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: organization.id, - resource: "members", - action: "manage", - }); - if (denied) return denied; - // An org must keep at least one member. The dashboard guards this in the // UI only; enforce it here since removeTeamMember doesn't. const memberCount = await prisma.orgMember.count({ @@ -61,10 +45,12 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Cannot remove the last member of an organization" }, { status: 400 }); } + // removeTeamMember throws ServiceValidationError; the builder maps it to + // its status (e.g. 404 for a member not in this org). const removed = await removeTeamMember({ - userId: authenticationResult.userId, + userId: authentication.userId, slug: organization.slug, - memberId, + memberId: params.memberId, }); return json({ @@ -75,12 +61,5 @@ export async function action({ request, params }: ActionFunctionArgs) { email: removed.user.email, }, }); - } catch (error) { - if (error instanceof Response) throw error; - if (error instanceof Error && error.message === "Member not found in this organization") { - return json({ error: error.message }, { status: 404 }); - } - logger.error("Failed to remove org member", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts index de85656571..2f95bc6b90 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts @@ -1,51 +1,41 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { prisma } from "~/db.server"; import { getTeamMembersAndInvites } from "~/models/member.server"; -import { logger } from "~/services/logger.server"; -import { - authorizePatOrganizationAccess, - resolveOrganizationForApiUser, -} from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ orgParam: z.string(), }); -export async function loader({ request, params }: LoaderFunctionArgs) { - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - +export const loader = createLoaderPATApiRoute( + { + params: ParamsSchema, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the read:members gate below. + context: async ({ orgParam }) => { + const org = await prisma.organization.findFirst({ + where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "read", resource: () => ({ type: "members" }) }, + }, + async ({ params, authentication }) => { + // Membership floor: a non-member gets a 404. const organization = await resolveOrganizationForApiUser({ - orgParam: parsedParams.data.orgParam, - userId: authenticationResult.userId, + orgParam: params.orgParam, + userId: authentication.userId, }); if (!organization) { return json({ error: "Organization not found" }, { status: 404 }); } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: organization.id, - resource: "members", - action: "read", - }); - if (denied) return denied; - const result = await getTeamMembersAndInvites({ - userId: authenticationResult.userId, + userId: authentication.userId, organizationId: organization.id, }); @@ -75,9 +65,5 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, })), }); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to list org members", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 3d4a202b37..de8fb3a29a 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -1,4 +1,3 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetProjectResponseBody, GetProjectsResponseBody } from "@trigger.dev/core/v3"; import { CreateProjectRequestBody, tryCatch } from "@trigger.dev/core/v3"; @@ -6,33 +5,29 @@ import { z } from "zod"; import { prisma } from "~/db.server"; import { createProject } from "~/models/project.server"; import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { + createActionPATApiRoute, + createLoaderPATApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { isCuid } from "cuid"; const ParamsSchema = z.object({ orgParam: z.string(), }); -export async function loader({ request, params }: LoaderFunctionArgs) { - logger.info("get projects", { url: request.url }); - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - const { orgParam } = ParamsSchema.parse(params); - +export const loader = createLoaderPATApiRoute( + { + params: ParamsSchema, + }, + async ({ params, authentication }) => { const projects = await prisma.project.findMany({ where: { organization: { - ...orgParamWhereClause(orgParam), + ...orgParamWhereClause(params.orgParam), deletedAt: null, members: { some: { - userId: authenticationResult.userId, + userId: authentication.userId, }, }, }, @@ -65,30 +60,33 @@ export async function loader({ request, params }: LoaderFunctionArgs) { })); return json(result); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to list org projects", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} - -export async function action({ request, params }: ActionFunctionArgs) { - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - const { orgParam } = ParamsSchema.parse(params); - +); + +export const action = createActionPATApiRoute( + { + method: "POST", + params: ParamsSchema, + body: CreateProjectRequestBody, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor. + context: async ({ orgParam }) => { + const org = await prisma.organization.findFirst({ + where: { ...orgParamWhereClause(orgParam), deletedAt: null }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + // authorization intentionally omitted — gating TBD + }, + async ({ params, body, authentication }) => { const organization = await prisma.organization.findFirst({ where: { - ...orgParamWhereClause(orgParam), + ...orgParamWhereClause(params.orgParam), deletedAt: null, members: { some: { - userId: authenticationResult.userId, + userId: authentication.userId, }, }, }, @@ -98,18 +96,11 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "Organization not found" }, { status: 404 }); } - const body = await request.json(); - const parsedBody = CreateProjectRequestBody.safeParse(body); - - if (!parsedBody.success) { - return json({ error: "Invalid request body" }, { status: 400 }); - } - const [error, project] = await tryCatch( createProject({ organizationSlug: organization.slug, - name: parsedBody.data.name, - userId: authenticationResult.userId, + name: body.name, + userId: authentication.userId, version: "v3", }) ); @@ -146,12 +137,8 @@ export async function action({ request, params }: ActionFunctionArgs) { }; return json(result); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to create org project", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); function orgParamWhereClause(orgParam: string) { // If the orgParam is an ID, or if it's a slug diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts index 2021f21c73..d32edcc04b 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -1,14 +1,9 @@ -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; -import { logger } from "~/services/logger.server"; -import { - authorizePatOrganizationAccess, - resolveOrganizationForApiUser, -} from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ orgParam: z.string(), @@ -18,49 +13,41 @@ const RenameOrgRequestBody = z.object({ title: z.string().min(1), }); -export async function action({ request, params }: ActionFunctionArgs) { - const method = request.method.toUpperCase(); - if (method !== "DELETE" && method !== "PATCH") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - +// Multi-method (PATCH rename / DELETE), so no `method` and no builder body +// schema — the handler branches and parses the PATCH body itself. +export const action = createActionPATApiRoute( + { + params: ParamsSchema, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the manage:organization gate below. + context: async ({ orgParam }) => { + const org = await prisma.organization.findFirst({ + where: { OR: [{ id: orgParam }, { slug: orgParam }], deletedAt: null }, + select: { id: true }, + }); + return org ? { organizationId: org.id } : {}; + }, + authorization: { action: "manage", resource: () => ({ type: "organization" }) }, + }, + async ({ request, params, authentication }) => { + // Membership floor: a non-member gets a 404 (the authorization block + // enforces the role; this enforces membership). const organization = await resolveOrganizationForApiUser({ - orgParam: parsedParams.data.orgParam, - userId: authenticationResult.userId, + orgParam: params.orgParam, + userId: authentication.userId, }); if (!organization) { return json({ error: "Organization not found" }, { status: 404 }); } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: organization.id, - resource: "organization", - action: "manage", - }); - if (denied) { - return denied; - } + const method = request.method.toUpperCase(); if (method === "DELETE") { try { await new DeleteOrganizationService().call({ organizationSlug: organization.slug, - userId: authenticationResult.userId, + userId: authentication.userId, request, }); } catch (error) { @@ -95,9 +82,5 @@ export async function action({ request, params }: ActionFunctionArgs) { }); return json(updated); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to update organization", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.orgs.ts b/apps/webapp/app/routes/api.v1.orgs.ts index 8e40597f6f..672ec0d8d4 100644 --- a/apps/webapp/app/routes/api.v1.orgs.ts +++ b/apps/webapp/app/routes/api.v1.orgs.ts @@ -1,85 +1,57 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetOrgsResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { createOrganization } from "~/models/organization.server"; -import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { + createActionPATApiRoute, + createLoaderPATApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; -export async function loader({ request }: LoaderFunctionArgs) { - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - const orgs = await prisma.organization.findMany({ - where: { - deletedAt: null, - members: { - some: { - userId: authenticationResult.userId, - }, +// Identity-only: lists the caller's own orgs, so no authorization gate. +export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => { + const orgs = await prisma.organization.findMany({ + where: { + deletedAt: null, + members: { + some: { + userId: authentication.userId, }, }, - }); + }, + }); - if (!orgs) { - return json({ error: "Orgs not found" }, { status: 404 }); - } + if (!orgs) { + return json({ error: "Orgs not found" }, { status: 404 }); + } - const result: GetOrgsResponseBody = orgs.map((org) => ({ - id: org.id, - title: org.title, - slug: org.slug, - createdAt: org.createdAt, - })); + const result: GetOrgsResponseBody = orgs.map((org) => ({ + id: org.id, + title: org.title, + slug: org.slug, + createdAt: org.createdAt, + })); - return json(result); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to list orgs", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); - } -} + return json(result); +}); const CreateOrgRequestBody = z.object({ title: z.string().min(1), companySize: z.string().optional(), }); -export async function action({ request }: ActionFunctionArgs) { - if (request.method.toUpperCase() !== "POST") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - return json({ error: "Invalid request body" }, { status: 400 }); - } - - const body = CreateOrgRequestBody.safeParse(rawBody); - - if (!body.success) { - return json({ error: "Invalid request body" }, { status: 400 }); - } - - // Any authenticated user can create an org; the creator becomes its ADMIN. +// No org exists yet, so no authorization gate; any authenticated user can +// create an org and becomes its ADMIN. +export const action = createActionPATApiRoute( + { + method: "POST", + body: CreateOrgRequestBody, + }, + async ({ body, authentication }) => { const organization = await createOrganization({ - title: body.data.title, - companySize: body.data.companySize ?? null, - userId: authenticationResult.userId, + title: body.title, + companySize: body.companySize ?? null, + userId: authentication.userId, }); return json( @@ -91,9 +63,5 @@ export async function action({ request }: ActionFunctionArgs) { }, { status: 201 } ); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to create org", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 2aab2c97f0..4643337ecb 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -1,134 +1,115 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetProjectResponseBody } from "@trigger.dev/core/v3"; import { z } from "zod"; import { prisma } from "~/db.server"; import { DeleteProjectService } from "~/services/deleteProject.server"; import { logger } from "~/services/logger.server"; -import { authorizePatOrganizationAccess } from "~/services/organizationApiAccess.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { + createActionPATApiRoute, + createLoaderPATApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { ProjectSettingsService } from "~/services/projectSettings.server"; const ParamsSchema = z.object({ projectRef: z.string(), }); -export async function loader({ request, params }: LoaderFunctionArgs) { - logger.info("get project", { url: request.url }); - - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - const { projectRef } = parsedParams.data; - - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { - deletedAt: null, - members: { - some: { - userId: authenticationResult.userId, +export const loader = createLoaderPATApiRoute( + { + params: ParamsSchema, + }, + async ({ params, authentication }) => { + const project = await prisma.project.findFirst({ + where: { + externalRef: params.projectRef, + organization: { + deletedAt: null, + members: { + some: { + userId: authentication.userId, + }, }, }, + deletedAt: null, }, - deletedAt: null, - }, - include: { - organization: true, - defaultWorkerGroup: { select: { name: true } }, - }, - }); + include: { + organization: true, + defaultWorkerGroup: { select: { name: true } }, + }, + }); - if (!project) { - return json({ error: "Project not found" }, { status: 404 }); - } + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } - if (project.version !== "V3") { - return json({ error: "Project found but was not a v3 project" }, { status: 404 }); - } + if (project.version !== "V3") { + return json({ error: "Project found but was not a v3 project" }, { status: 404 }); + } - const result: GetProjectResponseBody = { - id: project.id, - externalRef: project.externalRef, - name: project.name, - slug: project.slug, - createdAt: project.createdAt, - defaultRegion: project.defaultWorkerGroup?.name ?? null, - organization: { - id: project.organization.id, - title: project.organization.title, - slug: project.organization.slug, - createdAt: project.organization.createdAt, - }, - }; + const result: GetProjectResponseBody = { + id: project.id, + externalRef: project.externalRef, + name: project.name, + slug: project.slug, + createdAt: project.createdAt, + defaultRegion: project.defaultWorkerGroup?.name ?? null, + organization: { + id: project.organization.id, + title: project.organization.title, + slug: project.organization.slug, + createdAt: project.organization.createdAt, + }, + }; - return json(result); -} + return json(result); + } +); const RenameProjectRequestBody = z.object({ name: z.string().min(1), }); -export async function action({ request, params }: ActionFunctionArgs) { - const method = request.method.toUpperCase(); - if (method !== "DELETE" && method !== "PATCH") { - return json({ error: "Method Not Allowed" }, { status: 405 }); - } - - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - const parsedParams = ParamsSchema.safeParse(params); - - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); - } - - const { projectRef } = parsedParams.data; - - // Resolve id from ref scoped to membership; the services enforce membership - // again, but this maps a 404 (not member / unknown ref) cleanly. - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { deletedAt: null, members: { some: { userId: authenticationResult.userId } } }, - deletedAt: null, +// Multi-method (PATCH rename / DELETE), so no `method` and no builder body +// schema — the handler branches and parses the PATCH body itself. +export const action = createActionPATApiRoute( + { + params: ParamsSchema, + // Resolve the org (id only, no membership) so the plugin can compute the + // caller's role floor for the manage:project gate below. + context: async ({ projectRef }) => { + const project = await prisma.project.findFirst({ + where: { externalRef: projectRef, deletedAt: null }, + select: { organizationId: true }, + }); + return project ? { organizationId: project.organizationId } : {}; }, - select: { id: true, organizationId: true }, - }); + authorization: { action: "manage", resource: () => ({ type: "project" }) }, + }, + async ({ request, params, authentication }) => { + // Resolve id from ref scoped to membership; the services enforce membership + // again, but this maps a 404 (not member / unknown ref) cleanly. + const project = await prisma.project.findFirst({ + where: { + externalRef: params.projectRef, + organization: { + deletedAt: null, + members: { some: { userId: authentication.userId } }, + }, + deletedAt: null, + }, + select: { id: true, organizationId: true }, + }); - if (!project) { - return json({ error: "Project not found" }, { status: 404 }); - } + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } - const denied = await authorizePatOrganizationAccess({ - request, - organizationId: project.organizationId, - resource: "project", - action: "manage", - }); - if (denied) { - return denied; - } + const method = request.method.toUpperCase(); - try { if (method === "DELETE") { await new DeleteProjectService().call({ projectId: project.id, - userId: authenticationResult.userId, + userId: authentication.userId, }); return json({ id: project.id }); @@ -155,9 +136,5 @@ export async function action({ request, params }: ActionFunctionArgs) { } return json({ id: result.value.id, name: result.value.name }); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to update project", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); } -} +); diff --git a/apps/webapp/app/routes/api.v1.projects.ts b/apps/webapp/app/routes/api.v1.projects.ts index 11d63ce04a..50d0115f00 100644 --- a/apps/webapp/app/routes/api.v1.projects.ts +++ b/apps/webapp/app/routes/api.v1.projects.ts @@ -1,62 +1,47 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetProjectsResponseBody } from "@trigger.dev/core/v3"; import { prisma } from "~/db.server"; -import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; -export async function loader({ request }: LoaderFunctionArgs) { - logger.info("get projects", { url: request.url }); - - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } - - const projects = await prisma.project.findMany({ - where: { - organization: { - deletedAt: null, - members: { - some: { - userId: authenticationResult.userId, - }, +// Identity-only: lists projects across the caller's orgs, so no authorization gate. +export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => { + const projects = await prisma.project.findMany({ + where: { + organization: { + deletedAt: null, + members: { + some: { + userId: authentication.userId, }, }, - version: "V3", - deletedAt: null, }, - include: { - organization: true, - defaultWorkerGroup: { select: { name: true } }, - }, - }); + version: "V3", + deletedAt: null, + }, + include: { + organization: true, + defaultWorkerGroup: { select: { name: true } }, + }, + }); - if (!projects) { - return json({ error: "Projects not found" }, { status: 404 }); - } + if (!projects) { + return json({ error: "Projects not found" }, { status: 404 }); + } - const result: GetProjectsResponseBody = projects.map((project) => ({ - id: project.id, - externalRef: project.externalRef, - name: project.name, - slug: project.slug, - createdAt: project.createdAt, - defaultRegion: project.defaultWorkerGroup?.name ?? null, - organization: { - id: project.organization.id, - title: project.organization.title, - slug: project.organization.slug, - createdAt: project.organization.createdAt, - }, - })); + const result: GetProjectsResponseBody = projects.map((project) => ({ + id: project.id, + externalRef: project.externalRef, + name: project.name, + slug: project.slug, + createdAt: project.createdAt, + defaultRegion: project.defaultWorkerGroup?.name ?? null, + organization: { + id: project.organization.id, + title: project.organization.title, + slug: project.organization.slug, + createdAt: project.organization.createdAt, + }, + })); - return json(result); - } catch (error) { - if (error instanceof Response) throw error; - logger.error("Failed to list projects", { error }); - return json({ error: "Internal Server Error" }, { status: 500 }); - } -} + return json(result); +}); From c5c03f2d6566ac4b8367798b026bb31ce6411640 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:19:29 +0100 Subject: [PATCH 15/29] fix: let permission-denied redirect propagate in org settings action --- .../_app.orgs.$organizationSlug.settings._index/route.tsx | 3 +++ apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index 2ff2bef4e4..1b2b4003ab 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -295,6 +295,9 @@ export const action = dashboardAction( } } } catch (error: unknown) { + // Permission checks throw a redirect Response (toast) — let it through + // rather than flattening it into a generic 400. + if (error instanceof Response) throw error; const message = error instanceof Error ? error.message : "An unexpected error occurred"; return json({ errors: { body: message } }, { status: 400 }); } diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index de8fb3a29a..70b2777f0d 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -77,7 +77,8 @@ export const action = createActionPATApiRoute( }); return org ? { organizationId: org.id } : {}; }, - // authorization intentionally omitted — gating TBD + // No authorization gate: creating a project is a member-level action + // (mirrors the dashboard), not an owner-only one like rename/delete. }, async ({ params, body, authentication }) => { const organization = await prisma.organization.findFirst({ From 7aab6626c443cf21287709c29bfc71d711277fe6 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:30:40 +0100 Subject: [PATCH 16/29] fix: enforce last-member guard atomically in removeTeamMember --- ...api.v1.orgs.$orgParam.members.$memberId.ts | 14 +++---------- apps/webapp/test/auth-api.e2e.full.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts index df4a9518a5..6703478374 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -36,17 +36,9 @@ export const action = createActionPATApiRoute( return json({ error: "Organization not found" }, { status: 404 }); } - // An org must keep at least one member. The dashboard guards this in the - // UI only; enforce it here since removeTeamMember doesn't. - const memberCount = await prisma.orgMember.count({ - where: { organizationId: organization.id }, - }); - if (memberCount <= 1) { - return json({ error: "Cannot remove the last member of an organization" }, { status: 400 }); - } - - // removeTeamMember throws ServiceValidationError; the builder maps it to - // its status (e.g. 404 for a member not in this org). + // removeTeamMember enforces the last-member guard and throws + // ServiceValidationError (member-not-found / last-member), which the + // builder maps to its status. const removed = await removeTeamMember({ userId: authentication.userId, slug: organization.slug, diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index 1cf2761c47..b3e564b929 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -3016,4 +3016,24 @@ describe("API", () => { expect(res.status).not.toBe(405); }); }); + + // Member removal via the PAT action route. The last-member guard lives in + // removeTeamMember and surfaces as a ServiceValidationError the builder maps + // to 400 — so an org can't be emptied of its only member. + describe("Member removal — last-member guard", () => { + it("removing the last member is rejected: 400", async () => { + const server = getTestServer(); + const { user, organization, pat } = await seedTestUserProject(server.prisma); + const member = await server.prisma.orgMember.findFirst({ + where: { organizationId: organization.id, userId: user.id }, + }); + if (!member) throw new Error("seed did not create an org member"); + + const res = await server.webapp.fetch( + `/api/v1/orgs/${organization.id}/members/${member.id}`, + { method: "DELETE", headers: { Authorization: `Bearer ${pat.token}` } } + ); + expect(res.status).toBe(400); + }); + }); }); From 9af8fed44493c87c11ae6974367548a78ab6c14e Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:21:04 +0100 Subject: [PATCH 17/29] refactor: use the $transaction helper in removeTeamMember + document the rule --- apps/webapp/CLAUDE.md | 6 ++++++ apps/webapp/app/models/member.server.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index 68efaffd41..edc94c7a07 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -125,6 +125,12 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t - **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues. +## Transactions + +- **Always use the `$transaction` helper from `~/db.server`, never `prisma.$transaction` (or `$replica.$transaction`) directly.** The helper wraps the raw call with tracing (an OTEL span + an `isolation_level` attribute) and boundary logging for infrastructure errors (e.g. `PrismaClientInitializationError`) that the raw client swallows. Signature: `$transaction(prisma, name?, async (tx) => { ... }, options?)`. +- Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths. +- The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value. + ## React Patterns - Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations. diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 3d8120c17d..933862c802 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,5 +1,5 @@ import type { Organization, OrgMember, Project } from "@trigger.dev/database"; -import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server"; +import { Prisma as PrismaNamespace, type Prisma, prisma, $transaction } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; From 35174b7bc2737c671b8e5b46713a8416132ed4df Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:44:51 +0100 Subject: [PATCH 18/29] fix: retry removeTeamMember on serialization conflict From 316680ce485b56d359aea21c2f29c89d13a040ed Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:50:23 +0100 Subject: [PATCH 19/29] fix: reject unlisted HTTP methods on multi-method PAT action routes --- apps/webapp/app/routes/api.v1.orgs.$orgParam.ts | 6 ++++-- .../app/routes/api.v1.projects.$projectRef.ts | 6 ++++-- .../services/routeBuilders/apiBuilder.server.ts | 13 ++++++++++--- apps/webapp/test/auth-api.e2e.full.test.ts | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts index d32edcc04b..7db470701c 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -13,10 +13,12 @@ const RenameOrgRequestBody = z.object({ title: z.string().min(1), }); -// Multi-method (PATCH rename / DELETE), so no `method` and no builder body -// schema — the handler branches and parses the PATCH body itself. +// Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and +// the handler branches. No builder body schema — DELETE has none, so the PATCH +// branch parses its own body. export const action = createActionPATApiRoute( { + method: ["PATCH", "DELETE"], params: ParamsSchema, // Resolve the org (id only, no membership) so the plugin can compute the // caller's role floor for the manage:organization gate below. diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 4643337ecb..8508284d4c 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -69,10 +69,12 @@ const RenameProjectRequestBody = z.object({ name: z.string().min(1), }); -// Multi-method (PATCH rename / DELETE), so no `method` and no builder body -// schema — the handler branches and parses the PATCH body itself. +// Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and +// the handler branches. No builder body schema — DELETE has none, so the PATCH +// branch parses its own body. export const action = createActionPATApiRoute( { + method: ["PATCH", "DELETE"], params: ParamsSchema, // Resolve the org (id only, no membership) so the plugin can compute the // caller's role floor for the manage:project gate below. diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 665bb9fc2c..a74d49d229 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -644,13 +644,16 @@ export function createLoaderPATApiRoute< // can raise typed 4xx errors instead of the route string-matching messages. // Deliberately self-contained (not sharing internals with the loader) so // existing PAT loader routes are untouched. +type PATActionMethod = "POST" | "PUT" | "DELETE" | "PATCH"; + type PATActionRouteBuilderOptions< TParamsSchema extends AnyZodSchema | undefined = undefined, TSearchParamsSchema extends AnyZodSchema | undefined = undefined, THeadersSchema extends AnyZodSchema | undefined = undefined, TBodySchema extends AnyZodSchema | undefined = undefined, > = PATRouteBuilderOptions & { - method?: "POST" | "PUT" | "DELETE" | "PATCH"; + // A single verb, or a list for multi-method routes (e.g. ["PATCH", "DELETE"]). + method?: PATActionMethod | PATActionMethod[]; body?: TBodySchema; }; @@ -710,10 +713,14 @@ export function createActionPATApiRoute< return apiCors(request, json({})); } - if (method && request.method.toUpperCase() !== method) { + const allowedMethods = method ? (Array.isArray(method) ? method : [method]) : undefined; + if (allowedMethods && !(allowedMethods as string[]).includes(request.method.toUpperCase())) { return await wrapResponse( request, - json({ error: "Method not allowed" }, { status: 405, headers: { Allow: method } }), + json( + { error: "Method not allowed" }, + { status: 405, headers: { Allow: allowedMethods.join(", ") } } + ), corsStrategy !== "none" ); } diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index b3e564b929..d41e242b2d 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -3036,4 +3036,20 @@ describe("API", () => { expect(res.status).toBe(400); }); }); + + // Multi-method PAT action routes declare their allowed verbs, so a verb they + // don't handle (e.g. POST on a PATCH/DELETE route) is rejected rather than + // falling through to the rename branch. + describe("Multi-method routes reject other verbs", () => { + it("POST to the org rename/delete route: 405", async () => { + const server = getTestServer(); + const { organization, pat } = await seedTestUserProject(server.prisma); + const res = await server.webapp.fetch(`/api/v1/orgs/${organization.id}`, { + method: "POST", + headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "New name" }), + }); + expect(res.status).toBe(405); + }); + }); }); From 30122d6a36819899f1377bce5a2d5d3a3f261a6f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:21:38 +0100 Subject: [PATCH 20/29] refactor: drop unused authorizePatOrganizationAccess helper --- .../services/organizationApiAccess.server.ts | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/apps/webapp/app/services/organizationApiAccess.server.ts b/apps/webapp/app/services/organizationApiAccess.server.ts index e63c6eb5cb..7545da4c65 100644 --- a/apps/webapp/app/services/organizationApiAccess.server.ts +++ b/apps/webapp/app/services/organizationApiAccess.server.ts @@ -1,15 +1,4 @@ -import { json } from "@remix-run/server-runtime"; -import { isUserActorToken } from "@trigger.dev/rbac"; import { prisma } from "~/db.server"; -import { rbac } from "~/services/rbac.server"; - -type OrganizationScopedResource = "members" | "organization" | "project"; - -const RESOURCE_LABELS: Record = { - members: "members", - organization: "organization", - project: "projects", -}; /** * Resolve an org from a PAT-authenticated request's `$orgParam` (id or slug), @@ -33,48 +22,3 @@ export async function resolveOrganizationForApiUser({ select: { id: true, slug: true }, }); } - -/** - * Org-tier RBAC for organization-scoped management API routes (team members, - * invites). A personal access token (or delegated user-actor token) carries a - * user, so enforce that user's role for the target org — mirroring the - * dashboard's `manage:members` / `read:members` gates on the same operations. - * - * Returns a `Response` to short-circuit with when access is denied, or - * `undefined` when the request may proceed. - */ -export async function authorizePatOrganizationAccess({ - request, - organizationId, - resource, - action, -}: { - request: Request; - organizationId: string; - resource: OrganizationScopedResource; - action: "read" | "manage"; -}): Promise { - const bearer = request.headers - .get("Authorization") - ?.replace(/^Bearer /, "") - .trim(); - const isUat = !!bearer && isUserActorToken(bearer); - - const userAuth = isUat - ? await rbac.authenticateUserActor(request, { organizationId }) - : await rbac.authenticatePat(request, { organizationId }); - if (!userAuth.ok) { - return json({ error: userAuth.error }, { status: userAuth.status }); - } - - if (!userAuth.ability.can(action, { type: resource })) { - return json( - { - error: `You don't have permission to ${action} this organization's ${RESOURCE_LABELS[resource]}.`, - }, - { status: 403 } - ); - } - - return undefined; -} From 0eadfe41237c8f05d47299b4bd07f06cf9214aa8 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:35:27 +0100 Subject: [PATCH 21/29] docs: note the PAT membership-floor requirement in webapp CLAUDE.md --- apps/webapp/CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index edc94c7a07..3a0959c518 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -131,6 +131,10 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t - Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths. - The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value. +## PAT-authenticated API routes + +- **A PAT route must resolve its target org/project scoped to the caller's membership** (`members: { some: { userId } }`, or a helper like `findProjectByRef` / `resolveOrganizationForApiUser`). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive — so `ability.can(...)` alone does NOT reject a non-member on self-hosted. The RBAC `authorization` gate enforces the *role*; the membership-scoped query is the *tenant* floor. Skipping it opens cross-org access on OSS. + ## React Patterns - Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations. From a4a905aec890aa38801ab5569939911679ec624f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:43:31 +0100 Subject: [PATCH 22/29] feat: gate org-creation API behind a flag and complete create-org field parity --- .changeset/create-org-api-schemas.md | 5 ++++ apps/webapp/app/env.server.ts | 2 ++ apps/webapp/app/routes/api.v1.orgs.ts | 33 ++++++++++++++++++++++----- packages/core/src/v3/schemas/api.ts | 15 ++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 .changeset/create-org-api-schemas.md diff --git a/.changeset/create-org-api-schemas.md b/.changeset/create-org-api-schemas.md new file mode 100644 index 0000000000..592e753487 --- /dev/null +++ b/.changeset/create-org-api-schemas.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add typed schemas for the create-organization management API endpoint. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 36c2302a5c..9bdb3e1303 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -113,6 +113,8 @@ const EnvironmentSchema = z // agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides // (org featureFlags) win regardless. DASHBOARD_AGENT_ENABLED: z.string().default("0"), + // Gates the create-org management API endpoint (default off). + ORG_CREATION_API_ENABLED: z.string().default("0"), // "1" gives admins/impersonators an everywhere-preview (default off), // separate from the per-org rollout flag above. DASHBOARD_AGENT_ADMIN_PREVIEW: z.string().default("0"), diff --git a/apps/webapp/app/routes/api.v1.orgs.ts b/apps/webapp/app/routes/api.v1.orgs.ts index 672ec0d8d4..55ed92a290 100644 --- a/apps/webapp/app/routes/api.v1.orgs.ts +++ b/apps/webapp/app/routes/api.v1.orgs.ts @@ -1,12 +1,14 @@ import { json } from "@remix-run/server-runtime"; import type { GetOrgsResponseBody } from "@trigger.dev/core/v3"; -import { z } from "zod"; +import { CreateOrgRequestBody } from "@trigger.dev/core/v3"; import { prisma } from "~/db.server"; +import { env } from "~/env.server"; import { createOrganization } from "~/models/organization.server"; import { createActionPATApiRoute, createLoaderPATApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; +import { extractDomain, faviconUrl } from "~/utils/favicon"; // Identity-only: lists the caller's own orgs, so no authorization gate. export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => { @@ -35,11 +37,6 @@ export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => return json(result); }); -const CreateOrgRequestBody = z.object({ - title: z.string().min(1), - companySize: z.string().optional(), -}); - // No org exists yet, so no authorization gate; any authenticated user can // create an org and becomes its ADMIN. export const action = createActionPATApiRoute( @@ -48,10 +45,34 @@ export const action = createActionPATApiRoute( body: CreateOrgRequestBody, }, async ({ body, authentication }) => { + if (env.ORG_CREATION_API_ENABLED !== "1") { + return json({ error: "Not found" }, { status: 404 }); + } + + // Mirror the dashboard: stash companyUrl/companySize as onboarding data and + // derive the org avatar from the company domain's favicon. + const onboardingData: Record = {}; + if (body.companyUrl) { + onboardingData.companyUrl = body.companyUrl; + } + if (body.companySize) { + onboardingData.companySize = body.companySize; + } + + let avatar: { type: "image"; url: string } | undefined; + if (body.companyUrl) { + const domain = extractDomain(body.companyUrl); + if (domain) { + avatar = { type: "image", url: faviconUrl(domain) }; + } + } + const organization = await createOrganization({ title: body.title, companySize: body.companySize ?? null, userId: authentication.userId, + onboardingData: Object.keys(onboardingData).length > 0 ? onboardingData : undefined, + avatar, }); return json( diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 5c32d21c0e..3b57395b29 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -67,6 +67,21 @@ export const GetOrgsResponseBody = z.array( export type GetOrgsResponseBody = z.infer; +export const CreateOrgRequestBody = z.object({ + title: z.string().trim().min(3).max(50), + companySize: z.string().optional(), + companyUrl: z.string().optional(), +}); +export type CreateOrgRequestBody = z.infer; + +export const CreateOrgResponseBody = z.object({ + id: z.string(), + title: z.string(), + slug: z.string(), + createdAt: z.coerce.date(), +}); +export type CreateOrgResponseBody = z.infer; + export const CreateProjectRequestBody = z.object({ name: z .string() From 5835ef0bbd790998befd4fae206b1300dace7fe5 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:43:38 +0100 Subject: [PATCH 23/29] fix: clearer 'select a plan' error when creating a project on an unactivated org --- apps/webapp/app/models/project.server.ts | 6 +++++- apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index a9feb39475..2b3c4c03f0 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid"; import slug from "slug"; import { $replica, prisma } from "~/db.server"; import { projectCreated } from "~/services/projectCreated.server"; +import { ServiceValidationError } from "~/v3/services/common.server"; import { type Organization, createEnvironment } from "./organization.server"; export type { Project } from "@trigger.dev/database"; @@ -50,7 +51,10 @@ export async function createProject( if (version === "v3") { if (!organization.isActivated) { - throw new Error(`Organization can't create v3 projects.`); + throw new ServiceValidationError( + "You must select a plan for this organization before creating projects.", + 402 + ); } } diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 70b2777f0d..f20bf045c0 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts @@ -9,6 +9,7 @@ import { createActionPATApiRoute, createLoaderPATApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; +import { ServiceValidationError } from "~/v3/services/common.server"; import { isCuid } from "cuid"; const ParamsSchema = z.object({ @@ -108,6 +109,9 @@ export const action = createActionPATApiRoute( if (error) { logger.error("Failed to create project", { error }); + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: error.status ?? 400 }); + } return json({ error: "Failed to create project" }, { status: 400 }); } From 370d3988b3e524ad5482b7d8791aa343f5bad838 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:19:19 +0100 Subject: [PATCH 24/29] chore: drop create-org changeset (endpoint gated off, no user-facing change yet) --- .changeset/create-org-api-schemas.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/create-org-api-schemas.md diff --git a/.changeset/create-org-api-schemas.md b/.changeset/create-org-api-schemas.md deleted file mode 100644 index 592e753487..0000000000 --- a/.changeset/create-org-api-schemas.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Add typed schemas for the create-organization management API endpoint. From 532bbd801227a7396a65782888229f59e343aee1 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:11:14 +0100 Subject: [PATCH 25/29] fix(webapp): address review findings in management API and settings routes Review fixes: * await the async redirect helpers on denial/guard paths so they redirect with a toast instead of rendering the error boundary (settings index, env settings, regions) * apply the directory-sync membership checks to the new PAT invite and remove-member endpoints, matching the dashboard * skip already-invited emails when inviting so re-inviting one address doesn't fail the whole batch * bound org and project rename input lengths * e2e coverage for the above --- apps/webapp/app/models/member.server.ts | 3 ++ .../route.tsx | 4 +- .../route.tsx | 6 ++- .../routes/api.v1.orgs.$orgParam.invites.ts | 8 +++ ...api.v1.orgs.$orgParam.members.$memberId.ts | 18 +++++++ .../app/routes/api.v1.orgs.$orgParam.ts | 2 +- .../app/routes/api.v1.projects.$projectRef.ts | 2 +- apps/webapp/test/auth-api.e2e.full.test.ts | 50 ++++++++++++++++--- 8 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 933862c802..b65e04969b 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -113,8 +113,11 @@ export async function inviteMembers({ }) satisfies Prisma.OrgMemberInviteCreateManyInput ); + // Skip already-invited emails (unique org+email) so re-inviting one address + // doesn't P2002 the whole batch. await prisma.orgMemberInvite.createMany({ data: invites, + skipDuplicates: true, }); return await prisma.orgMemberInvite.findMany({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx index ee6f16e282..ccc61fc5ac 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings.general/route.tsx @@ -106,7 +106,7 @@ export const action = dashboardAction( switch (submission.value.action) { case "rename": { if (!ability.can("manage", { type: "project" })) { - throw redirectWithErrorMessage( + throw await redirectWithErrorMessage( v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), request, "You don't have permission to rename this project" @@ -139,7 +139,7 @@ export const action = dashboardAction( } case "delete": { if (!ability.can("manage", { type: "project" })) { - throw redirectWithErrorMessage( + throw await redirectWithErrorMessage( v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), request, "You don't have permission to delete this project" diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx index 1b2b4003ab..6f37ac02dd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings._index/route.tsx @@ -158,6 +158,8 @@ export const action = dashboardAction( }, }, async ({ ability, request, params }) => { + // clearCurrentProject (delete branch) needs the full UserFromSession + // (dashboardPreferences), which the builder's SessionUser doesn't carry. const user = await requireUser(request); const { organizationSlug } = params; @@ -177,7 +179,7 @@ export const action = dashboardAction( switch (submission.value.action) { case "rename": { if (!ability.can("manage", { type: "organization" })) { - throw redirectWithErrorMessage( + throw await redirectWithErrorMessage( organizationSettingsPath({ slug: organizationSlug }), request, "You don't have permission to rename this organization" @@ -205,7 +207,7 @@ export const action = dashboardAction( } case "delete": { if (!ability.can("manage", { type: "organization" })) { - throw redirectWithErrorMessage( + throw await redirectWithErrorMessage( organizationSettingsPath({ slug: organizationSlug }), request, "You don't have permission to delete this organization" diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts index 8cdb01f9a3..8f5c68fa17 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -7,6 +7,7 @@ import { logger } from "~/services/logger.server"; import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { scheduleEmail } from "~/services/scheduleEmail.server"; +import { ssoController } from "~/services/sso.server"; import { acceptInvitePath } from "~/utils/pathBuilder"; const ParamsSchema = z.object({ @@ -44,6 +45,13 @@ export const action = createActionPATApiRoute( return json({ error: "Organization not found" }, { status: 404 }); } + // Directory-managed membership: inviting is disabled (mirrors the dashboard + // invite action). Fail-open on a plugin error. + const policy = await ssoController.getMembershipPolicy(organization.id); + if (policy.isOk() && !policy.value.manualMembershipAllowed) { + return json({ error: "Membership is managed by Directory Sync" }, { status: 403 }); + } + const invites = await inviteMembers({ slug: organization.slug, emails: body.emails, diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts index 6703478374..7855f9e72e 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -4,6 +4,7 @@ import { prisma } from "~/db.server"; import { removeTeamMember } from "~/models/member.server"; import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { ssoController } from "~/services/sso.server"; const ParamsSchema = z.object({ orgParam: z.string(), @@ -36,6 +37,13 @@ export const action = createActionPATApiRoute( return json({ error: "Organization not found" }, { status: 404 }); } + // Directory-managed membership: manual removal is disabled (mirrors the + // dashboard Team page). Fail-open on a plugin error. + const policy = await ssoController.getMembershipPolicy(organization.id); + if (policy.isOk() && !policy.value.manualMembershipAllowed) { + return json({ error: "Membership is managed by Directory Sync" }, { status: 403 }); + } + // removeTeamMember enforces the last-member guard and throws // ServiceValidationError (member-not-found / last-member), which the // builder maps to its status. @@ -45,6 +53,16 @@ export const action = createActionPATApiRoute( memberId: params.memberId, }); + // Sticky removal: record a tombstone so passive SSO-JIT won't re-add them + // (best-effort; no-op without the SSO plugin). + await ssoController + .recordMembershipRemoval({ + organizationId: organization.id, + userId: removed.userId, + reason: "manual_removal", + }) + .unwrapOr(undefined); + return json({ id: removed.id, user: { diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts index 7db470701c..cadecab6c4 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -10,7 +10,7 @@ const ParamsSchema = z.object({ }); const RenameOrgRequestBody = z.object({ - title: z.string().min(1), + title: z.string().trim().min(3).max(50), }); // Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts index 8508284d4c..c1fa0acc91 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -66,7 +66,7 @@ export const loader = createLoaderPATApiRoute( ); const RenameProjectRequestBody = z.object({ - name: z.string().min(1), + name: z.string().trim().min(1).max(255), }); // Multi-method (PATCH rename / DELETE): declare both so other verbs 405, and diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index d41e242b2d..c991c88178 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -3008,12 +3008,10 @@ describe("API", () => { { Authorization: `Bearer ${pat.token}` }, { region: "definitely-not-a-region" } ); - // No worker groups seeded → the presenter yields no regions → the handler - // returns 400 "region not found". The point: the builder let the request - // through to the handler (auth + method + body all passed). - expect(res.status).not.toBe(401); - expect(res.status).not.toBe(403); - expect(res.status).not.toBe(405); + // No worker groups seeded → the presenter throws → the route returns 400. + // The point: the builder let the request through to the handler (auth + + // method + body all passed). + expect(res.status).toBe(400); }); }); @@ -3052,4 +3050,44 @@ describe("API", () => { expect(res.status).toBe(405); }); }); + + // Invites are idempotent per email: createMany uses skipDuplicates, so + // re-inviting an already-invited address returns success rather than a + // P2002-driven 500. + describe("Member invites — re-invite is idempotent", () => { + it("inviting the same email twice does not 500", async () => { + const server = getTestServer(); + const { organization, pat } = await seedTestUserProject(server.prisma); + const path = `/api/v1/orgs/${organization.id}/invites`; + const invite = () => + server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ emails: ["dup-invite@example.com"] }), + }); + + const first = await invite(); + expect(first.status).toBe(201); + + const second = await invite(); + expect(second.status).toBe(201); + expect(second.status).not.toBe(500); + }); + }); + + // Org creation via the management API is gated behind + // ORG_CREATION_API_ENABLED (default "0"). Without it, a valid PAT gets a 404 + // so the endpoint stays invisible. + describe("Org creation — disabled by default", () => { + it("POST /api/v1/orgs with a valid PAT returns 404 when the flag is unset", async () => { + const server = getTestServer(); + const { pat } = await seedTestUserProject(server.prisma); + const res = await server.webapp.fetch("/api/v1/orgs", { + method: "POST", + headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ title: "New org from API" }), + }); + expect(res.status).toBe(404); + }); + }); }); From cd0814e7b17e4c9e69603265cdf2080a76de8be3 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:00:28 +0100 Subject: [PATCH 26/29] fix(webapp): centralize the last-member guard in removeTeamMember removeTeamMember now lives in its own leaf module with an org-scoped delete. Add an atomic (Serializable) guard so an organization can't be left with zero members, enforced centrally for both the dashboard team page and the management API. It throws ServiceValidationError (404 member-not-found, 400 last-member) so the PAT action builder maps the response status; existing messages are unchanged so the model's tests still hold. The API members route imports the relocated module and passes the prisma client, and drops its own now-removed copy plus the now-unused imports. --- apps/webapp/app/models/member.server.ts | 3 +- .../app/models/removeTeamMember.server.ts | 48 +++++++++++++------ ...api.v1.orgs.$orgParam.members.$memberId.ts | 21 ++++---- apps/webapp/test/removeTeamMember.test.ts | 30 ++++++++++++ 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index b65e04969b..89af6ae3a0 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,12 +1,11 @@ import type { Organization, OrgMember, Project } from "@trigger.dev/database"; -import { Prisma as PrismaNamespace, type Prisma, prisma, $transaction } from "~/db.server"; +import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; import { logger } from "~/services/logger.server"; import { getDefaultEnvironmentConcurrencyLimit } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; -import { ServiceValidationError } from "~/v3/services/common.server"; export const INVITE_NOT_FOUND = "Invite not found"; export const INVITE_BLOCKED_DIRECTORY_MANAGED = diff --git a/apps/webapp/app/models/removeTeamMember.server.ts b/apps/webapp/app/models/removeTeamMember.server.ts index d6ae3cb85a..4a76b903b0 100644 --- a/apps/webapp/app/models/removeTeamMember.server.ts +++ b/apps/webapp/app/models/removeTeamMember.server.ts @@ -1,8 +1,10 @@ import type { PrismaClient } from "@trigger.dev/database"; +import { ServiceValidationError } from "~/v3/services/common.server"; // Leaf module with a type-only Prisma import (caller passes the client) so it // can be unit-tested without importing `~/db.server`, which eagerly connects -// the global prisma singleton. +// the global prisma singleton. ServiceValidationError is a plain error class +// with no imports, so it stays leaf-safe and lets callers map it to a status. export async function removeTeamMember( { userId, @@ -20,22 +22,38 @@ export async function removeTeamMember( }); if (!org) { - throw new Error("User does not have access to this organization"); + throw new ServiceValidationError("User does not have access to this organization", 403); } - // Scope both the lookup and the delete to org.id, in a transaction, so the - // member id is only ever resolved within the actor's organization. - return prismaClient.$transaction(async (tx) => { - const target = await tx.orgMember.findFirst({ - where: { id: memberId, organizationId: org.id }, - include: { organization: true, user: true }, - }); + // Serializable so the "keep at least one member" check and the delete are + // atomic: at ReadCommitted two concurrent removals could each see >1 member + // and both delete, orphaning the org. The guard lives here, not per-caller, + // so every surface (dashboard + management API) is TOCTOU-safe. Raw + // $transaction (not the ~/db.server helper) keeps this module leaf/testable. + return prismaClient.$transaction( + async (tx) => { + // Scope both the lookup and the delete to org.id, so the member id is + // only ever resolved within the actor's organization. + const target = await tx.orgMember.findFirst({ + where: { id: memberId, organizationId: org.id }, + include: { organization: true, user: true }, + }); - if (!target) { - throw new Error("Member not found in this organization"); - } + if (!target) { + throw new ServiceValidationError("Member not found in this organization", 404); + } - await tx.orgMember.delete({ where: { id: target.id } }); - return target; - }); + const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } }); + if (memberCount <= 1) { + throw new ServiceValidationError( + "Cannot remove the last member of an organization", + 400 + ); + } + + await tx.orgMember.delete({ where: { id: target.id } }); + return target; + }, + { isolationLevel: "Serializable" } + ); } diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts index 7855f9e72e..ffef926088 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { prisma } from "~/db.server"; -import { removeTeamMember } from "~/models/member.server"; +import { removeTeamMember } from "~/models/removeTeamMember.server"; import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { ssoController } from "~/services/sso.server"; @@ -44,14 +44,17 @@ export const action = createActionPATApiRoute( return json({ error: "Membership is managed by Directory Sync" }, { status: 403 }); } - // removeTeamMember enforces the last-member guard and throws - // ServiceValidationError (member-not-found / last-member), which the - // builder maps to its status. - const removed = await removeTeamMember({ - userId: authentication.userId, - slug: organization.slug, - memberId: params.memberId, - }); + // Org-scoped, TOCTOU-safe delete shared with the dashboard Team page. The + // model throws ServiceValidationError (member-not-found 404, last-member + // guard 400), which the builder maps to the response status. + const removed = await removeTeamMember( + { + userId: authentication.userId, + slug: organization.slug, + memberId: params.memberId, + }, + prisma + ); // Sticky removal: record a tombstone so passive SSO-JIT won't re-add them // (best-effort; no-op without the SSO plugin). diff --git a/apps/webapp/test/removeTeamMember.test.ts b/apps/webapp/test/removeTeamMember.test.ts index 4cbaadfcbc..4a9f32f76b 100644 --- a/apps/webapp/test/removeTeamMember.test.ts +++ b/apps/webapp/test/removeTeamMember.test.ts @@ -155,4 +155,34 @@ describe("removeTeamMember", () => { ).rejects.toThrow("Member not found in this organization"); } ); + + containerTest( + "refuses to remove the sole member (last-member guard, locks the message)", + async ({ prisma }) => { + const slug = `orgsolo_${Math.random().toString(36).slice(2, 10)}`; + const soloUser = await prisma.user.create({ + data: { email: `solo_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + const organization = await prisma.organization.create({ + data: { + title: slug, + slug, + members: { create: { userId: soloUser.id, role: "ADMIN" } }, + }, + include: { members: true }, + }); + const soloMember = organization.members[0]; + + await expect( + removeTeamMember( + { userId: soloUser.id, slug: organization.slug, memberId: soloMember.id }, + prisma + ) + ).rejects.toThrow("Cannot remove the last member of an organization"); + + const stillThere = await prisma.orgMember.findUnique({ where: { id: soloMember.id } }); + expect(stillThere).not.toBeNull(); + } + ); + }); From 9ce880b285d32635b7e0be7fad80633cfed5677c Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:59:45 +0100 Subject: [PATCH 27/29] fix(webapp): return existing invite on re-invite regardless of original inviter With skipDuplicates, an email already invited by a different org member is skipped on re-insert; scoping the follow-up findMany to the current inviter then dropped it from the response, so the caller saw an empty invite list for an email that is in fact invited. Scope the lookup to the org + emails instead. Adds a cross-inviter re-invite test. --- apps/webapp/app/models/member.server.ts | 6 +++- apps/webapp/test/auth-api.e2e.full.test.ts | 34 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 89af6ae3a0..e144ce40cc 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -119,10 +119,14 @@ export async function inviteMembers({ skipDuplicates: true, }); + // Return the invite for each submitted email scoped to the org, NOT to the + // current inviter: with skipDuplicates an email already invited by someone + // else is skipped, and filtering by inviterId here would drop it from the + // result — leaving the caller with a misleading empty list for an email that + // is in fact invited. return await prisma.orgMemberInvite.findMany({ where: { organizationId: org.id, - inviterId: userId, email: { in: emails, }, diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index c991c88178..be600bcc3f 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -3073,6 +3073,40 @@ describe("API", () => { expect(second.status).toBe(201); expect(second.status).not.toBe(500); }); + + it("re-inviting an email another org member already invited still returns it", async () => { + const server = getTestServer(); + const { organization, pat } = await seedTestUserProject(server.prisma); + + // A different user invited this email first; skipDuplicates will skip the + // re-insert, so the response must still surface the existing invite rather + // than an empty list scoped to the current caller. + const otherUser = await server.prisma.user.create({ + data: { + email: `other_${organization.id}@example.com`, + authenticationMethod: "MAGIC_LINK", + }, + }); + const sharedEmail = `shared_${organization.id}@example.com`; + await server.prisma.orgMemberInvite.create({ + data: { + email: sharedEmail, + token: `tok_${Math.random().toString(36).slice(2)}`, + organizationId: organization.id, + inviterId: otherUser.id, + role: "MEMBER", + }, + }); + + const res = await server.webapp.fetch(`/api/v1/orgs/${organization.id}/invites`, { + method: "POST", + headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ emails: [sharedEmail] }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { invites: { email: string }[] }; + expect(body.invites.map((i) => i.email)).toContain(sharedEmail); + }); }); // Org creation via the management API is gated behind From fdf3c05b1a4173664c07be6481295785b149b4d2 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:55:42 +0100 Subject: [PATCH 28/29] fix(webapp): only email newly-created invites, report re-invites accurately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inviteMembers now creates one invite per email and returns ONLY the invites it actually created (per-email create, P2002 = already invited → skipped), instead of createMany + a broad findMany that returned pre-existing rows too. Callers email exactly what they created, so re-inviting an already-pending address no longer re-sends the invite email (re-sending is the dedicated resend flow). The management API now reports { invited, alreadyInvited } and returns 201 only when something was created (200 otherwise), and caps the emails array at 50. --- apps/webapp/app/models/member.server.ts | 72 ++++++++++--------- .../routes/api.v1.orgs.$orgParam.invites.ts | 31 +++++--- apps/webapp/test/auth-api.e2e.full.test.ts | 40 +++++++---- 3 files changed, 86 insertions(+), 57 deletions(-) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index e144ce40cc..b786ce7e35 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -100,42 +100,44 @@ export async function inviteMembers({ throw new Error("User does not have access to this organization"); } - const invites = [...new Set(emails)].map( - (email) => - ({ - email, - token: tokenGenerator(), - organizationId: org.id, - inviterId: userId, - role: "MEMBER", - rbacRoleId: rbacRoleId ?? null, - }) satisfies Prisma.OrgMemberInviteCreateManyInput - ); - - // Skip already-invited emails (unique org+email) so re-inviting one address - // doesn't P2002 the whole batch. - await prisma.orgMemberInvite.createMany({ - data: invites, - skipDuplicates: true, - }); + // Create one invite per unique email and return ONLY the invites actually + // created by this call. A P2002 means the email is already invited to this org + // (unique org+email) — skip it so one duplicate can't fail the batch, and + // don't return it: callers email exactly what they created, and re-sending an + // already-pending invite is the dedicated resend flow's job (its own cooldown). + const created: Prisma.OrgMemberInviteGetPayload<{ + include: { organization: true; inviter: true }; + }>[] = []; + + for (const email of new Set(emails)) { + try { + const invite = await prisma.orgMemberInvite.create({ + data: { + email, + token: tokenGenerator(), + organizationId: org.id, + inviterId: userId, + role: "MEMBER", + rbacRoleId: rbacRoleId ?? null, + }, + include: { + organization: true, + inviter: true, + }, + }); + created.push(invite); + } catch (error) { + if ( + error instanceof PrismaNamespace.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + continue; + } + throw error; + } + } - // Return the invite for each submitted email scoped to the org, NOT to the - // current inviter: with skipDuplicates an email already invited by someone - // else is skipped, and filtering by inviterId here would drop it from the - // result — leaving the caller with a misleading empty list for an email that - // is in fact invited. - return await prisma.orgMemberInvite.findMany({ - where: { - organizationId: org.id, - email: { - in: emails, - }, - }, - include: { - organization: true, - inviter: true, - }, - }); + return created; } export async function getInviteFromToken({ token }: { token: string }) { diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts index 8f5c68fa17..26ca7706ac 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -15,7 +15,12 @@ const ParamsSchema = z.object({ }); const InviteRequestBody = z.object({ - emails: z.string().email().array().nonempty("At least one email is required"), + emails: z + .string() + .email() + .array() + .nonempty("At least one email is required") + .max(50, "At most 50 emails per request"), }); export const action = createActionPATApiRoute( @@ -52,16 +57,17 @@ export const action = createActionPATApiRoute( return json({ error: "Membership is managed by Directory Sync" }, { status: 403 }); } - const invites = await inviteMembers({ + // Returns only the invites created by this call; already-invited emails are + // skipped (re-sending is the dashboard's dedicated resend flow, not this). + const created = await inviteMembers({ slug: organization.slug, emails: body.emails, userId: authentication.userId, }); - // Send invite emails the same way the dashboard invite action does. A - // failed send must not fail the invite (the row already exists); in local - // dev with no SMTP config, scheduleEmail's transport logs instead. - for (const invite of invites) { + // Email only the newly-created invites. A failed send must not fail the + // request (the row exists); locally scheduleEmail's transport just logs. + for (const invite of created) { try { await scheduleEmail({ email: "invite", @@ -76,11 +82,20 @@ export const action = createActionPATApiRoute( } } + // Report per-email outcome so callers aren't misled by an empty list on + // re-invite. 201 when something was created, 200 when everything already + // existed. + const createdEmails = new Set(created.map((invite) => invite.email)); + const alreadyInvited = [...new Set(body.emails)].filter( + (email) => !createdEmails.has(email) + ); + return json( { - invites: invites.map((invite) => ({ id: invite.id, email: invite.email })), + invited: created.map((invite) => ({ id: invite.id, email: invite.email })), + alreadyInvited, }, - { status: 201 } + { status: created.length > 0 ? 201 : 200 } ); } ); diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts index be600bcc3f..988a8f7071 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -3051,36 +3051,44 @@ describe("API", () => { }); }); - // Invites are idempotent per email: createMany uses skipDuplicates, so - // re-inviting an already-invited address returns success rather than a - // P2002-driven 500. + // Re-invite is idempotent: an already-invited email is skipped (not created, + // not re-emailed) and reported as alreadyInvited, so a repeat call neither + // 500s (P2002) nor sends a duplicate invite email. describe("Member invites — re-invite is idempotent", () => { - it("inviting the same email twice does not 500", async () => { + it("inviting the same email twice: 201 then 200, second reports alreadyInvited", async () => { const server = getTestServer(); const { organization, pat } = await seedTestUserProject(server.prisma); const path = `/api/v1/orgs/${organization.id}/invites`; + const email = "dup-invite@example.com"; const invite = () => server.webapp.fetch(path, { method: "POST", headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, - body: JSON.stringify({ emails: ["dup-invite@example.com"] }), + body: JSON.stringify({ emails: [email] }), }); const first = await invite(); expect(first.status).toBe(201); + const firstBody = (await first.json()) as { invited: { email: string }[] }; + expect(firstBody.invited.map((i) => i.email)).toContain(email); const second = await invite(); - expect(second.status).toBe(201); - expect(second.status).not.toBe(500); + expect(second.status).toBe(200); + const secondBody = (await second.json()) as { + invited: { email: string }[]; + alreadyInvited: string[]; + }; + expect(secondBody.invited).toHaveLength(0); + expect(secondBody.alreadyInvited).toContain(email); }); - it("re-inviting an email another org member already invited still returns it", async () => { + it("re-inviting an email another org member already invited reports alreadyInvited", async () => { const server = getTestServer(); const { organization, pat } = await seedTestUserProject(server.prisma); - // A different user invited this email first; skipDuplicates will skip the - // re-insert, so the response must still surface the existing invite rather - // than an empty list scoped to the current caller. + // A different user invited this email first: the create hits P2002, so it's + // skipped (not re-created, not re-emailed) and surfaced as alreadyInvited + // rather than being reported as a fresh invite. const otherUser = await server.prisma.user.create({ data: { email: `other_${organization.id}@example.com`, @@ -3103,9 +3111,13 @@ describe("API", () => { headers: { Authorization: `Bearer ${pat.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ emails: [sharedEmail] }), }); - expect(res.status).toBe(201); - const body = (await res.json()) as { invites: { email: string }[] }; - expect(body.invites.map((i) => i.email)).toContain(sharedEmail); + expect(res.status).toBe(200); + const body = (await res.json()) as { + invited: { email: string }[]; + alreadyInvited: string[]; + }; + expect(body.invited).toHaveLength(0); + expect(body.alreadyInvited).toContain(sharedEmail); }); }); From 1d51bb78b0b06f4f7ceb943b9a5082bdbe9894f5 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:32:19 +0100 Subject: [PATCH 29/29] style: oxfmt the invite/removeTeamMember changes --- apps/webapp/app/models/removeTeamMember.server.ts | 5 +---- apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts | 4 +--- apps/webapp/test/removeTeamMember.test.ts | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/models/removeTeamMember.server.ts b/apps/webapp/app/models/removeTeamMember.server.ts index 4a76b903b0..f09f9a7315 100644 --- a/apps/webapp/app/models/removeTeamMember.server.ts +++ b/apps/webapp/app/models/removeTeamMember.server.ts @@ -45,10 +45,7 @@ export async function removeTeamMember( const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } }); if (memberCount <= 1) { - throw new ServiceValidationError( - "Cannot remove the last member of an organization", - 400 - ); + throw new ServiceValidationError("Cannot remove the last member of an organization", 400); } await tx.orgMember.delete({ where: { id: target.id } }); diff --git a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts index 26ca7706ac..76aeeb171b 100644 --- a/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -86,9 +86,7 @@ export const action = createActionPATApiRoute( // re-invite. 201 when something was created, 200 when everything already // existed. const createdEmails = new Set(created.map((invite) => invite.email)); - const alreadyInvited = [...new Set(body.emails)].filter( - (email) => !createdEmails.has(email) - ); + const alreadyInvited = [...new Set(body.emails)].filter((email) => !createdEmails.has(email)); return json( { diff --git a/apps/webapp/test/removeTeamMember.test.ts b/apps/webapp/test/removeTeamMember.test.ts index 4a9f32f76b..64b4365bd8 100644 --- a/apps/webapp/test/removeTeamMember.test.ts +++ b/apps/webapp/test/removeTeamMember.test.ts @@ -184,5 +184,4 @@ describe("removeTeamMember", () => { expect(stillThere).not.toBeNull(); } ); - });