diff --git a/.changeset/project-default-region-response.md b/.changeset/project-default-region-response.md new file mode 100644 index 00000000000..e23e80943b9 --- /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. 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 00000000000..7501a06942b --- /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 00000000000..6657826a58b --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts @@ -0,0 +1,106 @@ +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; + + 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, + }); + + // 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 00000000000..6ef89de3a32 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.members.$memberId.ts @@ -0,0 +1,86 @@ +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 00000000000..de856565716 --- /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.$orgParam.projects.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.projects.ts index 33b68ad244a..3d4a202b378 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.orgs.$orgParam.ts b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts new file mode 100644 index 00000000000..2021f21c735 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.orgs.$orgParam.ts @@ -0,0 +1,103 @@ +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"; + +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 }); + } + + 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: "organization", + action: "manage", + }); + if (denied) { + return denied; + } + + 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 }); + } + + 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); + } 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 4e3ad9aa98a..8e40597f6fc 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,56 @@ 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 }); + } + + 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. + 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.pause.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.$env.pause.ts new file mode 100644 index 00000000000..550aedfdca8 --- /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 00000000000..fd172103266 --- /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 00000000000..ad1694cac03 --- /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 00000000000..c1ccdceecf5 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.default-region.ts @@ -0,0 +1,124 @@ +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 { 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, organizationId: 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 }) + ); + + 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 d0a36f3a6de..49990204c1e 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 55cae92e793..2aab2c97f09 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.ts @@ -1,10 +1,13 @@ -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 { authorizePatOrganizationAccess } from "~/services/organizationApiAccess.server"; import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { ProjectSettingsService } from "~/services/projectSettings.server"; const ParamsSchema = z.object({ projectRef: z.string(), @@ -42,6 +45,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, include: { organization: true, + defaultWorkerGroup: { select: { name: true } }, }, }); @@ -59,6 +63,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, @@ -69,3 +74,90 @@ 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) { + 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, + }, + select: { id: true, organizationId: 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; + } + + try { + if (method === "DELETE") { + await new DeleteProjectService().call({ + projectId: project.id, + userId: authenticationResult.userId, + }); + + return json({ id: project.id }); + } + + 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 }); + } 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 625df7bb9eb..11d63ce04a5 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/apps/webapp/app/services/organizationApiAccess.server.ts b/apps/webapp/app/services/organizationApiAccess.server.ts new file mode 100644 index 00000000000..e63c6eb5cbb --- /dev/null +++ b/apps/webapp/app/services/organizationApiAccess.server.ts @@ -0,0 +1,80 @@ +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), + * 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; +} diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index e9b5b03cc64..6b3cf5ed95b 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -37,6 +37,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(), @@ -1226,6 +1229,8 @@ export type ListRunResponse = z.infer; export const CreateEnvironmentVariableRequestBody = z.object({ name: z.string(), value: z.string(), + // When omitted, the variable defaults to non-secret (the DB default is false). + isSecret: z.boolean().optional(), }); export type CreateEnvironmentVariableRequestBody = z.infer<