Skip to content

Commit 976171e

Browse files
authored
feat(webapp): management API for orgs, projects, members, and settings (#4146)
## Summary Adds a set of PAT-authenticated management API endpoints so orgs, projects, members/invites, environment variables, and a few project/environment settings can be managed programmatically (scripting, automation) rather than only through the dashboard. Each route is a thin wrapper over the **existing** service the dashboard already uses, with the same authorization applied at the route layer - no new business logic. ## Endpoints **Organizations** - `POST /api/v1/orgs` - create an org (`createOrganization`) - `PATCH /api/v1/orgs/:orgParam` - rename (title) - `DELETE /api/v1/orgs/:orgParam` - soft-delete (`DeleteOrganizationService`; keeps the active-subscription guard) **Members & invites** - `GET /api/v1/orgs/:orgParam/members` - list members + pending invites - `DELETE /api/v1/orgs/:orgParam/members/:memberId` - remove a member (last-member guarded) - `POST /api/v1/orgs/:orgParam/invites` - invite by email (`inviteMembers`, sends the invite email) - `DELETE /api/v1/orgs/:orgParam/invites/:inviteId` - revoke an invite **Projects** - `PATCH /api/v1/projects/:projectRef` - rename (`ProjectSettingsService`) - `DELETE /api/v1/projects/:projectRef` - soft-delete (`DeleteProjectService`) - `PUT /api/v1/projects/:projectRef/default-region` - set the default region by worker-group name (`SetDefaultRegionService`) - project GET/list now return `defaultRegion` (worker-group name, or null when unset) **Environments** - `POST /api/v1/projects/:projectRef/:env/pause` and `/resume` (`PauseEnvironmentService`) - `POST /api/v1/projects/:projectRef/:env/regenerate-api-key` - rotate the env secret key (`regenerateApiKey`, RBAC `write:apiKeys`) - env var create now accepts an optional `isSecret` flag ## Auth & authorization - All routes authenticate with a **Personal Access Token** (`Authorization: Bearer tr_pat_...`). - Org/project routes are built on the PAT route builders in `apiBuilder.server.ts`: `createLoaderPATApiRoute` (already existed) and **`createActionPATApiRoute`** (added here - the loader builder had no mutation counterpart). The builder runs auth, resolves the org/project role-floor via `context`, and enforces a declarative `authorization` block using the same RBAC actions the dashboard applies (`manage:organization` / `read:members` / `manage:members` / `manage:project`). Handlers keep a membership-scoped query as the floor, so a non-member gets a 404. This also gives these routes `tenantContext` user attribution (Sentry) and `ServiceValidationError`-to-status mapping for free. - **Membership floor (important).** The OSS RBAC fallback grants a permissive ability, so `ability.can(...)` can't reject a non-member on self-hosted. Every handler therefore resolves the target scoped to the caller's membership (`members: { some: { userId } }`) → 404 for non-members. `authorization` is the *role* gate; this is the *tenant* gate. `resolveOrganizationForApiUser` (`organizationApiAccess.server.ts`) is the org-tier version of the existing `findProjectByRef` - org-addressed PAT routes are new, so no such helper existed before. - Env-tier routes reuse the existing `authorizePatEnvironmentAccess` (`write:apiKeys`). ### What `createActionPATApiRoute` gives you A route is pure declaration - the builder handles auth, RBAC, validation, tracing, and error mapping: ```ts export const action = createActionPATApiRoute( { method: "PUT", // one verb, or ["PATCH", "DELETE"] for multi-verb routes params: ParamsSchema, body: SetDefaultRegionRequestBody, // zod-validated context: async ({ projectRef }) => { // resolve the org for the RBAC role-floor 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, ability }) => { // auth + authz already enforced. Just do the work. // `throw new ServiceValidationError("Region not found", 400)` → mapped to that status. return json({ ok: true }); } ); ``` Handled for you, so handlers stay thin: - **Method allowlist** - `method` accepts a verb or an array; any other verb → `405` with an `Allow` header, *before* auth runs: ```ts const allowedMethods = method ? (Array.isArray(method) ? method : [method]) : undefined; if (allowedMethods && !(allowedMethods as string[]).includes(request.method.toUpperCase())) { return json({ error: "Method not allowed" }, { status: 405, headers: { Allow: allowedMethods.join(", ") } }); } ``` - **PAT / user-actor auth** in a single roundtrip → `401` on missing/invalid/revoked token. - **RBAC** - `context` computes the caller's role-floor for the target org/project; `authorization` gates it → `403` with a structured error body. - **Sentry attribution** - `tenantContext.enrich({ userId })` so events from the handler carry the acting user. - **Typed errors** - a thrown `ServiceValidationError` is mapped to its `.status` (default 400); anything else → `500`, and expected boundary errors are logged as `warn` (kept out of Sentry). - **Validation** - params / query / headers / body are all zod-checked → `400` with details. ## Notes for reviewers - Everything wraps an existing service; the intent is API parity for things that are currently dashboard-only, not new behaviour. - `createActionPATApiRoute` is new shared infra (the PAT + RBAC mutation builder that didn't exist). It's self-contained - the loader builder and existing routes are untouched. - `@trigger.dev/core` gets one additive field (`defaultRegion` on the project response, optional/nullable for client-server version skew) - changeset included, patch. - `removeTeamMember`'s last-member guard is now atomic (Serializable transaction via the `$transaction` helper, with retry), so the dashboard and API both get it server-side. Added a `## Transactions` rule to `apps/webapp/CLAUDE.md` (always use the `$transaction` helper); migrating the remaining direct usages is tracked in TRI-11698. ## Open questions - ~~Is PAT the right auth (vs OAT for automation)?~~ **Resolved: PAT.** Organization Access Tokens are currently internal-only (used by the image builder) and not user-accessible, so they can't back this yet. - Should any of these be gated behind a flag or scope? - Naming/shape of the routes.
1 parent 1ab5066 commit 976171e

28 files changed

Lines changed: 1930 additions & 432 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Add `defaultRegion` to the project GET and list API responses; null when unset.

apps/webapp/CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t
115115

116116
- **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.
117117

118+
## Transactions
119+
120+
- **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?)`.
121+
- 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.
122+
- The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value.
123+
124+
## PAT-authenticated API routes
125+
126+
- **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.
127+
118128
## React Patterns
119129

120130
- 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.

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const EnvironmentSchema = z
113113
// agent dark; flip to "1" to enable it for everyone at GA. Per-org overrides
114114
// (org featureFlags) win regardless.
115115
DASHBOARD_AGENT_ENABLED: z.string().default("0"),
116+
// Gates the create-org management API endpoint (default off).
117+
ORG_CREATION_API_ENABLED: z.string().default("0"),
116118
// "1" gives admins/impersonators an everywhere-preview (default off),
117119
// separate from the per-org rollout flag above.
118120
DASHBOARD_AGENT_ADMIN_PREVIEW: z.string().default("0"),

apps/webapp/app/models/member.server.ts

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -100,35 +100,44 @@ export async function inviteMembers({
100100
throw new Error("User does not have access to this organization");
101101
}
102102

103-
const invites = [...new Set(emails)].map(
104-
(email) =>
105-
({
106-
email,
107-
token: tokenGenerator(),
108-
organizationId: org.id,
109-
inviterId: userId,
110-
role: "MEMBER",
111-
rbacRoleId: rbacRoleId ?? null,
112-
}) satisfies Prisma.OrgMemberInviteCreateManyInput
113-
);
114-
115-
await prisma.orgMemberInvite.createMany({
116-
data: invites,
117-
});
103+
// Create one invite per unique email and return ONLY the invites actually
104+
// created by this call. A P2002 means the email is already invited to this org
105+
// (unique org+email) — skip it so one duplicate can't fail the batch, and
106+
// don't return it: callers email exactly what they created, and re-sending an
107+
// already-pending invite is the dedicated resend flow's job (its own cooldown).
108+
const created: Prisma.OrgMemberInviteGetPayload<{
109+
include: { organization: true; inviter: true };
110+
}>[] = [];
111+
112+
for (const email of new Set(emails)) {
113+
try {
114+
const invite = await prisma.orgMemberInvite.create({
115+
data: {
116+
email,
117+
token: tokenGenerator(),
118+
organizationId: org.id,
119+
inviterId: userId,
120+
role: "MEMBER",
121+
rbacRoleId: rbacRoleId ?? null,
122+
},
123+
include: {
124+
organization: true,
125+
inviter: true,
126+
},
127+
});
128+
created.push(invite);
129+
} catch (error) {
130+
if (
131+
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
132+
error.code === "P2002"
133+
) {
134+
continue;
135+
}
136+
throw error;
137+
}
138+
}
118139

119-
return await prisma.orgMemberInvite.findMany({
120-
where: {
121-
organizationId: org.id,
122-
inviterId: userId,
123-
email: {
124-
in: emails,
125-
},
126-
},
127-
include: {
128-
organization: true,
129-
inviter: true,
130-
},
131-
});
140+
return created;
132141
}
133142

134143
export async function getInviteFromToken({ token }: { token: string }) {

apps/webapp/app/models/project.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid";
33
import slug from "slug";
44
import { $replica, prisma } from "~/db.server";
55
import { projectCreated } from "~/services/projectCreated.server";
6+
import { ServiceValidationError } from "~/v3/services/common.server";
67
import { type Organization, createEnvironment } from "./organization.server";
78
export type { Project } from "@trigger.dev/database";
89

@@ -50,7 +51,10 @@ export async function createProject(
5051

5152
if (version === "v3") {
5253
if (!organization.isActivated) {
53-
throw new Error(`Organization can't create v3 projects.`);
54+
throw new ServiceValidationError(
55+
"You must select a plan for this organization before creating projects.",
56+
402
57+
);
5458
}
5559
}
5660

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { PrismaClient } from "@trigger.dev/database";
2+
import { ServiceValidationError } from "~/v3/services/common.server";
23

34
// Leaf module with a type-only Prisma import (caller passes the client) so it
45
// can be unit-tested without importing `~/db.server`, which eagerly connects
5-
// the global prisma singleton.
6+
// the global prisma singleton. ServiceValidationError is a plain error class
7+
// with no imports, so it stays leaf-safe and lets callers map it to a status.
68
export async function removeTeamMember(
79
{
810
userId,
@@ -20,22 +22,35 @@ export async function removeTeamMember(
2022
});
2123

2224
if (!org) {
23-
throw new Error("User does not have access to this organization");
25+
throw new ServiceValidationError("User does not have access to this organization", 403);
2426
}
2527

26-
// Scope both the lookup and the delete to org.id, in a transaction, so the
27-
// member id is only ever resolved within the actor's organization.
28-
return prismaClient.$transaction(async (tx) => {
29-
const target = await tx.orgMember.findFirst({
30-
where: { id: memberId, organizationId: org.id },
31-
include: { organization: true, user: true },
32-
});
28+
// Serializable so the "keep at least one member" check and the delete are
29+
// atomic: at ReadCommitted two concurrent removals could each see >1 member
30+
// and both delete, orphaning the org. The guard lives here, not per-caller,
31+
// so every surface (dashboard + management API) is TOCTOU-safe. Raw
32+
// $transaction (not the ~/db.server helper) keeps this module leaf/testable.
33+
return prismaClient.$transaction(
34+
async (tx) => {
35+
// Scope both the lookup and the delete to org.id, so the member id is
36+
// only ever resolved within the actor's organization.
37+
const target = await tx.orgMember.findFirst({
38+
where: { id: memberId, organizationId: org.id },
39+
include: { organization: true, user: true },
40+
});
3341

34-
if (!target) {
35-
throw new Error("Member not found in this organization");
36-
}
42+
if (!target) {
43+
throw new ServiceValidationError("Member not found in this organization", 404);
44+
}
3745

38-
await tx.orgMember.delete({ where: { id: target.id } });
39-
return target;
40-
});
46+
const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } });
47+
if (memberCount <= 1) {
48+
throw new ServiceValidationError("Cannot remove the last member of an organization", 400);
49+
}
50+
51+
await tx.orgMember.delete({ where: { id: target.id } });
52+
return target;
53+
},
54+
{ isolationLevel: "Serializable" }
55+
);
4156
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
MapPinIcon,
88
} from "@heroicons/react/20/solid";
99
import { Form } from "@remix-run/react";
10-
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
10+
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
1111
import { tryCatch } from "@trigger.dev/core";
1212
import { useState } from "react";
1313
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -51,9 +51,11 @@ import { useFeatures } from "~/hooks/useFeatures";
5151
import { useOrganization } from "~/hooks/useOrganizations";
5252
import { useHasAdminAccess } from "~/hooks/useUser";
5353
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
54+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
5455
import { findProjectBySlug } from "~/models/project.server";
5556
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
5657
import { requireUser } from "~/services/session.server";
58+
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
5759
import {
5860
docsPath,
5961
EnvironmentParamSchema,
@@ -90,44 +92,60 @@ const FormSchema = z.object({
9092
regionId: z.string(),
9193
});
9294

93-
export const action = async ({ request, params }: ActionFunctionArgs) => {
94-
const user = await requireUser(request);
95-
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
95+
export const action = dashboardAction(
96+
{
97+
params: EnvironmentParamSchema,
98+
context: async (params) => {
99+
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
100+
return orgId ? { organizationId: orgId } : {};
101+
},
102+
},
103+
async ({ user, ability, request, params }) => {
104+
const { organizationSlug, projectParam, envParam } = params;
96105

97-
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
106+
const redirectPath = regionsPath(
107+
{ slug: organizationSlug },
108+
{ slug: projectParam },
109+
{ slug: envParam }
110+
);
98111

99-
const redirectPath = regionsPath(
100-
{ slug: organizationSlug },
101-
{ slug: projectParam },
102-
{ slug: envParam }
103-
);
112+
if (!ability.can("manage", { type: "project" })) {
113+
throw await redirectWithErrorMessage(
114+
redirectPath,
115+
request,
116+
"You don't have permission to change the default region"
117+
);
118+
}
104119

105-
if (!project) {
106-
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
107-
}
120+
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
108121

109-
const formData = await request.formData();
110-
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
122+
if (!project) {
123+
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
124+
}
111125

112-
if (!parsedFormData.success) {
113-
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
114-
}
126+
const formData = await request.formData();
127+
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
115128

116-
const service = new SetDefaultRegionService();
117-
const [error, result] = await tryCatch(
118-
service.call({
119-
projectId: project.id,
120-
regionId: parsedFormData.data.regionId,
121-
isAdmin: user.admin || user.isImpersonating,
122-
})
123-
);
129+
if (!parsedFormData.success) {
130+
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
131+
}
124132

125-
if (error) {
126-
return redirectWithErrorMessage(redirectPath, request, error.message);
127-
}
133+
const service = new SetDefaultRegionService();
134+
const [error, result] = await tryCatch(
135+
service.call({
136+
projectId: project.id,
137+
regionId: parsedFormData.data.regionId,
138+
isAdmin: user.admin || user.isImpersonating,
139+
})
140+
);
128141

129-
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
130-
};
142+
if (error) {
143+
return redirectWithErrorMessage(redirectPath, request, error.message);
144+
}
145+
146+
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
147+
}
148+
);
131149

132150
export default function Page() {
133151
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();

0 commit comments

Comments
 (0)