Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8b02cc4
feat(webapp): PAT-authenticated management API for orgs, members, inv…
nicktrn Jul 2, 2026
62ebd3c
feat(webapp): management API for org/project rename + delete, env var…
nicktrn Jul 2, 2026
946174e
feat(webapp,core): return project defaultRegion (worker-group name, n…
nicktrn Jul 2, 2026
1be874c
chore: add changeset for core defaultRegion field
nicktrn Jul 3, 2026
827d36d
style: apply oxfmt to management API routes
nicktrn Jul 3, 2026
bb7169b
fix(webapp): return 400 (not 500) on malformed JSON body in managemen…
nicktrn Jul 3, 2026
3f26d35
feat(webapp): Owner-gate org/project management API routes (manage:or…
nicktrn Jul 6, 2026
a68b262
chore(webapp): drop over-explicit comments above authorize calls
nicktrn Jul 6, 2026
1b02d0e
feat: require owner role for org and project management dashboard act…
nicktrn Jul 6, 2026
ab3e0b2
docs: tighten defaultRegion changeset wording
nicktrn Jul 6, 2026
fae086c
fix: redirect with error toast instead of raw json on denied dashboar…
nicktrn Jul 6, 2026
e8dafd2
fix: make defaultRegion optional in project API response for version-…
nicktrn Jul 6, 2026
f9641b1
feat: add createActionPATApiRoute builder and use it for set-default-…
nicktrn Jul 6, 2026
01deb7e
refactor: migrate PAT org/project management routes to createActionPA…
nicktrn Jul 7, 2026
dd4778c
fix: let permission-denied redirect propagate in org settings action
nicktrn Jul 7, 2026
fdf858f
fix: enforce last-member guard atomically in removeTeamMember
nicktrn Jul 7, 2026
e096f97
refactor: use the $transaction helper in removeTeamMember + document …
nicktrn Jul 7, 2026
79a0965
fix: retry removeTeamMember on serialization conflict
nicktrn Jul 7, 2026
3101e77
fix: reject unlisted HTTP methods on multi-method PAT action routes
nicktrn Jul 7, 2026
5b36f99
refactor: drop unused authorizePatOrganizationAccess helper
nicktrn Jul 7, 2026
a9ca647
docs: note the PAT membership-floor requirement in webapp CLAUDE.md
nicktrn Jul 7, 2026
73c51ca
feat: gate org-creation API behind a flag and complete create-org fie…
nicktrn Jul 7, 2026
f03f2ec
fix: clearer 'select a plan' error when creating a project on an unac…
nicktrn Jul 7, 2026
fa1877a
chore: drop create-org changeset (endpoint gated off, no user-facing …
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
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
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
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
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