Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
da5218e
feat(webapp): PAT-authenticated management API for orgs, members, inv…
nicktrn Jul 2, 2026
739f2ba
feat(webapp): management API for org/project rename + delete, env var…
nicktrn Jul 2, 2026
a89b30f
feat(webapp,core): return project defaultRegion (worker-group name, n…
nicktrn Jul 2, 2026
ae9d418
chore: add changeset for core defaultRegion field
nicktrn Jul 3, 2026
9a65d0d
style: apply oxfmt to management API routes
nicktrn Jul 3, 2026
c6be8e3
fix(webapp): return 400 (not 500) on malformed JSON body in managemen…
nicktrn Jul 3, 2026
66de64d
feat(webapp): Owner-gate org/project management API routes (manage:or…
nicktrn Jul 6, 2026
cc5d7c8
chore(webapp): drop over-explicit comments above authorize calls
nicktrn Jul 6, 2026
b53f532
feat: require owner role for org and project management dashboard act…
nicktrn Jul 6, 2026
308e808
docs: tighten defaultRegion changeset wording
nicktrn Jul 6, 2026
e71b8d3
fix: redirect with error toast instead of raw json on denied dashboar…
nicktrn Jul 6, 2026
b1cacac
fix: make defaultRegion optional in project API response for version-…
nicktrn Jul 6, 2026
727e5d8
feat: add createActionPATApiRoute builder and use it for set-default-…
nicktrn Jul 6, 2026
b79afb9
refactor: migrate PAT org/project management routes to createActionPA…
nicktrn Jul 7, 2026
c5c03f2
fix: let permission-denied redirect propagate in org settings action
nicktrn Jul 7, 2026
7aab662
fix: enforce last-member guard atomically in removeTeamMember
nicktrn Jul 7, 2026
9af8fed
refactor: use the $transaction helper in removeTeamMember + document …
nicktrn Jul 7, 2026
35174b7
fix: retry removeTeamMember on serialization conflict
nicktrn Jul 7, 2026
316680c
fix: reject unlisted HTTP methods on multi-method PAT action routes
nicktrn Jul 7, 2026
30122d6
refactor: drop unused authorizePatOrganizationAccess helper
nicktrn Jul 7, 2026
0eadfe4
docs: note the PAT membership-floor requirement in webapp CLAUDE.md
nicktrn Jul 7, 2026
a4a905a
feat: gate org-creation API behind a flag and complete create-org fie…
nicktrn Jul 7, 2026
5835ef0
fix: clearer 'select a plan' error when creating a project on an unac…
nicktrn Jul 7, 2026
370d398
chore: drop create-org changeset (endpoint gated off, no user-facing …
nicktrn Jul 7, 2026
532bbd8
fix(webapp): address review findings in management API and settings r…
nicktrn Jul 10, 2026
cd0814e
fix(webapp): centralize the last-member guard in removeTeamMember
nicktrn Jul 10, 2026
9ce880b
fix(webapp): return existing invite on re-invite regardless of origin…
nicktrn Jul 10, 2026
fdf3c05
fix(webapp): only email newly-created invites, report re-invites accu…
nicktrn Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/project-default-region-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Add `defaultRegion` to the project GET and list API responses; null when unset.
10 changes: 10 additions & 0 deletions apps/webapp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
65 changes: 37 additions & 28 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,35 +100,44 @@ export async function inviteMembers({
throw new Error("User does not have access to this organization");
}

const invites = [...new Set(emails)].map(
(email) =>
({
email,
token: tokenGenerator(),
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
rbacRoleId: rbacRoleId ?? null,
}) satisfies Prisma.OrgMemberInviteCreateManyInput
);

await prisma.orgMemberInvite.createMany({
data: invites,
});
// Create one invite per unique email and return ONLY the invites actually
// created by this call. A P2002 means the email is already invited to this org
// (unique org+email) — skip it so one duplicate can't fail the batch, and
// don't return it: callers email exactly what they created, and re-sending an
// already-pending invite is the dedicated resend flow's job (its own cooldown).
const created: Prisma.OrgMemberInviteGetPayload<{
include: { organization: true; inviter: true };
}>[] = [];

for (const email of new Set(emails)) {
try {
const invite = await prisma.orgMemberInvite.create({
data: {
email,
token: tokenGenerator(),
organizationId: org.id,
inviterId: userId,
role: "MEMBER",
rbacRoleId: rbacRoleId ?? null,
},
include: {
organization: true,
inviter: true,
},
});
created.push(invite);
} catch (error) {
if (
error instanceof PrismaNamespace.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
continue;
}
throw error;
}
}

return await prisma.orgMemberInvite.findMany({
where: {
organizationId: org.id,
inviterId: userId,
email: {
in: emails,
},
},
include: {
organization: true,
inviter: true,
},
});
return created;
}

export async function getInviteFromToken({ token }: { token: string }) {
Expand Down
6 changes: 5 additions & 1 deletion apps/webapp/app/models/project.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
);
}
}

Expand Down
48 changes: 33 additions & 15 deletions apps/webapp/app/models/removeTeamMember.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { PrismaClient } from "@trigger.dev/database";
import { ServiceValidationError } from "~/v3/services/common.server";

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

if (!org) {
throw new Error("User does not have access to this organization");
throw new ServiceValidationError("User does not have access to this organization", 403);
}

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

if (!target) {
throw new Error("Member not found in this organization");
}
if (!target) {
throw new ServiceValidationError("Member not found in this organization", 404);
}

await tx.orgMember.delete({ where: { id: target.id } });
return target;
});
const memberCount = await tx.orgMember.count({ where: { organizationId: org.id } });
if (memberCount <= 1) {
throw new ServiceValidationError(
"Cannot remove the last member of an organization",
400
);
}

await tx.orgMember.delete({ where: { id: target.id } });
return target;
},
{ isolationLevel: "Serializable" }
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 await redirectWithErrorMessage(
redirectPath,
request,
"You don't have permission to change the default region"
);
}

if (!project) {
throw await 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 await redirectWithErrorMessage(redirectPath, request, "Project not found");
}

if (!parsedFormData.success) {
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}
const formData = await request.formData();
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));

const service = new SetDefaultRegionService();
const [error, result] = await tryCatch(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
isAdmin: user.admin || user.isImpersonating,
})
);
if (!parsedFormData.success) {
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}

if (error) {
return redirectWithErrorMessage(redirectPath, request, error.message);
}
const service = new SetDefaultRegionService();
const [error, result] = await tryCatch(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
isAdmin: user.admin || user.isImpersonating,
})
);

return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
};
if (error) {
return redirectWithErrorMessage(redirectPath, request, error.message);
}

return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
}
);

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