Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
44e06ad
feat(webapp): PAT-authenticated management API for orgs, members, inv…
nicktrn Jul 2, 2026
146d53a
feat(webapp): management API for org/project rename + delete, env var…
nicktrn Jul 2, 2026
5a3c345
feat(webapp,core): return project defaultRegion (worker-group name, n…
nicktrn Jul 2, 2026
30e52e2
chore: add changeset for core defaultRegion field
nicktrn Jul 3, 2026
80ae824
style: apply oxfmt to management API routes
nicktrn Jul 3, 2026
0e70acd
fix(webapp): return 400 (not 500) on malformed JSON body in managemen…
nicktrn Jul 3, 2026
155784a
feat(webapp): Owner-gate org/project management API routes (manage:or…
nicktrn Jul 6, 2026
4517d38
chore(webapp): drop over-explicit comments above authorize calls
nicktrn Jul 6, 2026
021e360
feat: require owner role for org and project management dashboard act…
nicktrn Jul 6, 2026
ce99002
docs: tighten defaultRegion changeset wording
nicktrn Jul 6, 2026
59b8204
fix: redirect with error toast instead of raw json on denied dashboar…
nicktrn Jul 6, 2026
bce8132
fix: make defaultRegion optional in project API response for version-…
nicktrn Jul 6, 2026
63b25f9
feat: add createActionPATApiRoute builder and use it for set-default-…
nicktrn Jul 6, 2026
5fe26e9
refactor: migrate PAT org/project management routes to createActionPA…
nicktrn Jul 7, 2026
788d3a8
fix: let permission-denied redirect propagate in org settings action
nicktrn Jul 7, 2026
790d3ec
fix: enforce last-member guard atomically in removeTeamMember
nicktrn Jul 7, 2026
dfa9aea
refactor: use the $transaction helper in removeTeamMember + document …
nicktrn Jul 7, 2026
d744109
fix: retry removeTeamMember on serialization conflict
nicktrn Jul 7, 2026
c725875
fix: reject unlisted HTTP methods on multi-method PAT action routes
nicktrn Jul 7, 2026
7cafcb7
refactor: drop unused authorizePatOrganizationAccess helper
nicktrn Jul 7, 2026
530a100
docs: note the PAT membership-floor requirement in webapp CLAUDE.md
nicktrn Jul 7, 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
57 changes: 41 additions & 16 deletions apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -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({
Expand Down
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 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<typeof loader>();
Expand Down
Loading
Loading