diff --git a/.changeset/project-default-region-response.md b/.changeset/project-default-region-response.md new file mode 100644 index 0000000000..86d1856ac5 --- /dev/null +++ b/.changeset/project-default-region-response.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add `defaultRegion` to the project GET and list API responses; null when unset. diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index 68efaffd41..3a0959c518 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -125,6 +125,16 @@ 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. + +## 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. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 04c18acb7a..bda1f0cafc 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/models/member.server.ts b/apps/webapp/app/models/member.server.ts index d9906f6eed..a36bc24e9c 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,11 +1,12 @@ 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"; 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 = @@ -88,27 +89,51 @@ 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 the target to this org. A member id is a globally unique key, so - // deleting by id alone would remove members of other orgs; bind it to the - // resolved org and reject a foreign id. - const member = await prisma.orgMember.findFirst({ - where: { id: memberId, organizationId: org.id }, - include: { - organization: true, - user: true, + // Serializable: the "keep at least one member" check and the delete must not + // interleave with a concurrent removal, or two requests could each pass the + // count and leave the org with zero members. Guard lives here (not per-caller) + // so the dashboard and the management API both get it. + const removed = await $transaction( + prisma, + "removeTeamMember", + async (tx) => { + // Scope the target to this org. A member id is a globally unique key, so + // deleting by id alone would remove members of other orgs; bind it to the + // resolved org and reject a foreign id. + const member = await tx.orgMember.findFirst({ + where: { id: memberId, organizationId: org.id }, + include: { + organization: true, + user: true, + }, + }); + + if (!member) { + throw new ServiceValidationError("Member not found in this organization", 404); + } + + 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: member.id } }); + + return member; }, - }); + // Retry the loser of a serialization conflict transparently rather than + // surfacing it (concurrent last-member removals are rare and quick). + { isolationLevel: "Serializable", maxRetries: 3 } + ); - if (!member) { - throw new Error("Member not found in this organization"); + if (!removed) { + throw new Error("removeTeamMember transaction did not return a member"); } - await prisma.orgMember.delete({ where: { id: member.id } }); - - return member; + return removed; } export async function inviteMembers({ 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/_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 f6b3f9e4d7..18fb131cd2 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 { 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,60 @@ 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); + const redirectPath = regionsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam } + ); - const redirectPath = regionsPath( - { slug: organizationSlug }, - { slug: projectParam }, - { slug: envParam } - ); + if (!ability.can("manage", { type: "project" })) { + throw redirectWithErrorMessage( + redirectPath, + request, + "You don't have permission to change the default region" + ); + } - if (!project) { - throw redirectWithErrorMessage(redirectPath, request, "Project not found"); - } + const project = await findProjectBySlug(organizationSlug, projectParam, user.id); - const formData = await request.formData(); - const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData)); + if (!project) { + throw redirectWithErrorMessage(redirectPath, request, "Project not found"); + } - if (!parsedFormData.success) { - throw 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 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..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 @@ -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,120 @@ 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" })) { + throw redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + "You don't have permission to rename this project" + ); + } + 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" })) { + throw redirectWithErrorMessage( + v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }), + request, + "You don't have permission to delete this project" + ); + } + 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 bcf0483db7..dcf75ed8fd 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,145 @@ 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" })) { + throw redirectWithErrorMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + "You don't have permission to rename this organization" + ); + } + 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" })) { + throw redirectWithErrorMessage( + organizationSettingsPath({ slug: organizationSlug }), + request, + "You don't have permission to delete this organization" + ); + } + 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 +293,16 @@ 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) { + // 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 }); } - } 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(); 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..d5617d90a8 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.$inviteId.ts @@ -0,0 +1,54 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { revokeInvite } from "~/models/member.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 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: params.orgParam, + userId: authentication.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { 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; + } + } +); 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..8cdb01f9a3 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -0,0 +1,78 @@ +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 { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.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 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: params.orgParam, + userId: authentication.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const invites = 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) { + 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 } + ); + } +); 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..6703478374 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -0,0 +1,57 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { removeTeamMember } from "~/models/member.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 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: params.orgParam, + userId: authentication.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + // 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, + }); + + return json({ + id: removed.id, + user: { + id: removed.user.id, + name: removed.user.name, + email: removed.user.email, + }, + }); + } +); 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..2f95bc6b90 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.ts @@ -0,0 +1,69 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { getTeamMembersAndInvites } from "~/models/member.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + orgParam: z.string(), +}); + +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: params.orgParam, + userId: authentication.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const result = await getTeamMembersAndInvites({ + userId: authentication.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, + }, + })), + }); + } +); 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..f20bf045c0 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,30 @@ 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 { ServiceValidationError } from "~/v3/services/common.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, }, }, }, @@ -41,6 +37,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, include: { organization: true, + defaultWorkerGroup: { select: { name: true } }, }, }); @@ -54,6 +51,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, @@ -63,30 +61,34 @@ 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 } : {}; + }, + // 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({ where: { - ...orgParamWhereClause(orgParam), + ...orgParamWhereClause(params.orgParam), deletedAt: null, members: { some: { - userId: authenticationResult.userId, + userId: authentication.userId, }, }, }, @@ -96,33 +98,41 @@ 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", }) ); 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 }); } + // 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, @@ -132,12 +142,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 new file mode 100644 index 0000000000..7db470701c --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -0,0 +1,88 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { prisma } from "~/db.server"; +import { DeleteOrganizationService } from "~/services/deleteOrganization.server"; +import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server"; +import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + orgParam: z.string(), +}); + +const RenameOrgRequestBody = z.object({ + title: z.string().min(1), +}); + +// 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. + 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: params.orgParam, + userId: authentication.userId, + }); + + if (!organization) { + return json({ error: "Organization not found" }, { status: 404 }); + } + + const method = request.method.toUpperCase(); + + if (method === "DELETE") { + try { + await new DeleteOrganizationService().call({ + organizationSlug: organization.slug, + userId: authentication.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 }); + } + + 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 }); + } + + 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); + } +); diff --git a/apps/webapp/app/routes/api.v1.orgs.ts b/apps/webapp/app/routes/api.v1.orgs.ts index 4e3ad9aa98..55ed92a290 100644 --- a/apps/webapp/app/routes/api.v1.orgs.ts +++ b/apps/webapp/app/routes/api.v1.orgs.ts @@ -1,44 +1,88 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import type { GetOrgsResponseBody } from "@trigger.dev/core/v3"; +import { CreateOrgRequestBody } from "@trigger.dev/core/v3"; import { prisma } from "~/db.server"; -import { logger } from "~/services/logger.server"; -import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.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"; -export async function loader({ request }: LoaderFunctionArgs) { - try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); +// 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 (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + const result: GetOrgsResponseBody = orgs.map((org) => ({ + id: org.id, + title: org.title, + slug: org.slug, + createdAt: org.createdAt, + })); + + return json(result); +}); + +// 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 }) => { + if (env.ORG_CREATION_API_ENABLED !== "1") { + return json({ error: "Not found" }, { status: 404 }); } - const orgs = await prisma.organization.findMany({ - where: { - deletedAt: null, - members: { - some: { - userId: authenticationResult.userId, - }, - }, - }, - }); + // 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; + } - if (!orgs) { - return json({ error: "Orgs not found" }, { status: 404 }); + let avatar: { type: "image"; url: string } | undefined; + if (body.companyUrl) { + const domain = extractDomain(body.companyUrl); + if (domain) { + avatar = { type: "image", url: faviconUrl(domain) }; + } } - 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 }); + 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( + { + id: organization.id, + title: organization.title, + slug: organization.slug, + createdAt: organization.createdAt, + }, + { status: 201 } + ); } -} +); 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.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.$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..c0c861be2f --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -0,0 +1,89 @@ +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 { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.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), +}); + +// 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: params.projectRef, + organization: { + deletedAt: null, + members: { some: { userId: authentication.userId } }, + }, + deletedAt: null, + }, + select: { id: true, slug: true }, + }); + + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + // 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: authentication.userId, projectSlug: project.slug }) + ); + + if (presenterError) { + return json({ error: presenterError.message }, { status: 400 }); + } + + const region = result.regions.find((r) => r.name === body.region); + + if (!region) { + return json( + { + error: `Region '${body.region}' not found`, + availableRegions: result.regions.map((r) => r.name), + }, + { status: 400 } + ); + } + + // 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 }); + } +); 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 55cae92e79..8508284d4c 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -1,71 +1,142 @@ -import type { 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"; +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 }); +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, + }, + include: { + organization: true, + defaultWorkerGroup: { select: { name: true } }, + }, + }); - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } - if (!authenticationResult) { - return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); - } + if (project.version !== "V3") { + return json({ error: "Project found but was not a v3 project" }, { status: 404 }); + } - const parsedParams = ParamsSchema.safeParse(params); + 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, + }, + }; - if (!parsedParams.success) { - return json({ error: "Invalid Params" }, { status: 400 }); + return json(result); } +); - const { projectRef } = parsedParams.data; +const RenameProjectRequestBody = z.object({ + name: z.string().min(1), +}); - const project = await prisma.project.findFirst({ - where: { - externalRef: projectRef, - organization: { - deletedAt: null, - members: { - some: { - userId: authenticationResult.userId, - }, +// 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. + 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 ({ 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, }, - deletedAt: null, - }, - include: { - organization: true, - }, - }); + 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 }); + } - if (project.version !== "V3") { - return json({ error: "Project found but was not a v3 project" }, { status: 404 }); - } + const method = request.method.toUpperCase(); - const result: GetProjectResponseBody = { - id: project.id, - externalRef: project.externalRef, - name: project.name, - slug: project.slug, - createdAt: project.createdAt, - organization: { - id: project.organization.id, - title: project.organization.title, - slug: project.organization.slug, - createdAt: project.organization.createdAt, - }, - }; + if (method === "DELETE") { + await new DeleteProjectService().call({ + projectId: project.id, + userId: authentication.userId, + }); + + return json({ id: project.id }); + } - return json(result); -} + 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 }); + } + + 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: result.value.id, name: result.value.name }); + } +); diff --git a/apps/webapp/app/routes/api.v1.projects.ts b/apps/webapp/app/routes/api.v1.projects.ts index 625df7bb9e..50d0115f00 100644 --- a/apps/webapp/app/routes/api.v1.projects.ts +++ b/apps/webapp/app/routes/api.v1.projects.ts @@ -1,60 +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, - }, - }); + 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, - 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); +}); diff --git a/apps/webapp/app/services/organizationApiAccess.server.ts b/apps/webapp/app/services/organizationApiAccess.server.ts new file mode 100644 index 0000000000..7545da4c65 --- /dev/null +++ b/apps/webapp/app/services/organizationApiAccess.server.ts @@ -0,0 +1,24 @@ +import { prisma } from "~/db.server"; + +/** + * 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 }, + }); +} diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index e5842bb861..a74d49d229 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -637,6 +637,280 @@ 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 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 & { + // A single verb, or a list for multi-method routes (e.g. ["PATCH", "DELETE"]). + method?: PATActionMethod | PATActionMethod[]; + 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({})); + } + + 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: allowedMethods.join(", ") } } + ), + 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..d41e242b2d 100644 --- a/apps/webapp/test/auth-api.e2e.full.test.ts +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -2910,4 +2910,146 @@ 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); + }); + }); + + // 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); + }); + }); + + // 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); + }); + }); }); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index ccdff3b79d..3b57395b29 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -38,6 +38,10 @@ 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). 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(), @@ -63,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() @@ -1347,6 +1366,8 @@ export type ListBulkActionsResponseBody = z.infer