976171ea16
## 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.
188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import { containerTest } from "@internal/testcontainers";
|
|
import type { PrismaClient } from "@trigger.dev/database";
|
|
import { describe, expect, vi } from "vitest";
|
|
import { removeTeamMember } from "~/models/removeTeamMember.server";
|
|
|
|
vi.setConfig({ testTimeout: 60_000 });
|
|
|
|
async function seedOrgWithMembers(prisma: PrismaClient, slugBase: string) {
|
|
const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`;
|
|
const admin = await prisma.user.create({
|
|
data: { email: `admin_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
|
|
});
|
|
const member = await prisma.user.create({
|
|
data: { email: `member_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
|
|
});
|
|
|
|
const organization = await prisma.organization.create({
|
|
data: {
|
|
title: slug,
|
|
slug,
|
|
members: {
|
|
createMany: {
|
|
data: [
|
|
{ userId: admin.id, role: "ADMIN" },
|
|
{ userId: member.id, role: "MEMBER" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
include: { members: true },
|
|
});
|
|
|
|
const adminMember = organization.members.find((m) => m.userId === admin.id)!;
|
|
const regularMember = organization.members.find((m) => m.userId === member.id)!;
|
|
return { organization, admin, member, adminMember, regularMember };
|
|
}
|
|
|
|
describe("removeTeamMember", () => {
|
|
containerTest(
|
|
"refuses to delete an OrgMember that belongs to a different org",
|
|
async ({ prisma }) => {
|
|
const a = await seedOrgWithMembers(prisma, "orga");
|
|
const b = await seedOrgWithMembers(prisma, "orgb");
|
|
|
|
await expect(
|
|
removeTeamMember(
|
|
{
|
|
userId: a.admin.id,
|
|
slug: a.organization.slug,
|
|
memberId: b.regularMember.id,
|
|
},
|
|
prisma
|
|
)
|
|
).rejects.toThrow();
|
|
|
|
const stillThere = await prisma.orgMember.findUnique({
|
|
where: { id: b.regularMember.id },
|
|
});
|
|
expect(stillThere).not.toBeNull();
|
|
}
|
|
);
|
|
|
|
containerTest("removes a member that belongs to the actor's org", async ({ prisma }) => {
|
|
const a = await seedOrgWithMembers(prisma, "orga");
|
|
|
|
const result = await removeTeamMember(
|
|
{
|
|
userId: a.admin.id,
|
|
slug: a.organization.slug,
|
|
memberId: a.regularMember.id,
|
|
},
|
|
prisma
|
|
);
|
|
expect(result.id).toBe(a.regularMember.id);
|
|
|
|
const gone = await prisma.orgMember.findUnique({
|
|
where: { id: a.regularMember.id },
|
|
});
|
|
expect(gone).toBeNull();
|
|
});
|
|
|
|
containerTest("allows the actor to leave their own org (self-leave)", async ({ prisma }) => {
|
|
const a = await seedOrgWithMembers(prisma, "orga");
|
|
|
|
const result = await removeTeamMember(
|
|
{
|
|
userId: a.member.id,
|
|
slug: a.organization.slug,
|
|
memberId: a.regularMember.id,
|
|
},
|
|
prisma
|
|
);
|
|
expect(result.userId).toBe(a.member.id);
|
|
|
|
const gone = await prisma.orgMember.findUnique({
|
|
where: { id: a.regularMember.id },
|
|
});
|
|
expect(gone).toBeNull();
|
|
});
|
|
|
|
containerTest(
|
|
"throws the in-org not-found error for an unknown memberId (locks the error message the route renders)",
|
|
async ({ prisma }) => {
|
|
const a = await seedOrgWithMembers(prisma, "orga");
|
|
|
|
await expect(
|
|
removeTeamMember(
|
|
{
|
|
userId: a.admin.id,
|
|
slug: a.organization.slug,
|
|
memberId: "doesnotexist",
|
|
},
|
|
prisma
|
|
)
|
|
).rejects.toThrow("Member not found in this organization");
|
|
}
|
|
);
|
|
|
|
containerTest("throws when the actor is not a member of the slug org", async ({ prisma }) => {
|
|
const a = await seedOrgWithMembers(prisma, "orga");
|
|
const b = await seedOrgWithMembers(prisma, "orgb");
|
|
|
|
await expect(
|
|
removeTeamMember(
|
|
{
|
|
userId: a.admin.id,
|
|
slug: b.organization.slug,
|
|
memberId: b.regularMember.id,
|
|
},
|
|
prisma
|
|
)
|
|
).rejects.toThrow("User does not have access to this organization");
|
|
|
|
const stillThere = await prisma.orgMember.findUnique({
|
|
where: { id: b.regularMember.id },
|
|
});
|
|
expect(stillThere).not.toBeNull();
|
|
});
|
|
|
|
containerTest(
|
|
"uses an exact-message error for cross-tenant attempts (locks contract)",
|
|
async ({ prisma }) => {
|
|
const a = await seedOrgWithMembers(prisma, "orga");
|
|
const b = await seedOrgWithMembers(prisma, "orgb");
|
|
|
|
await expect(
|
|
removeTeamMember(
|
|
{
|
|
userId: a.admin.id,
|
|
slug: a.organization.slug,
|
|
memberId: b.regularMember.id,
|
|
},
|
|
prisma
|
|
)
|
|
).rejects.toThrow("Member not found in this organization");
|
|
}
|
|
);
|
|
|
|
containerTest(
|
|
"refuses to remove the sole member (last-member guard, locks the message)",
|
|
async ({ prisma }) => {
|
|
const slug = `orgsolo_${Math.random().toString(36).slice(2, 10)}`;
|
|
const soloUser = await prisma.user.create({
|
|
data: { email: `solo_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
|
|
});
|
|
const organization = await prisma.organization.create({
|
|
data: {
|
|
title: slug,
|
|
slug,
|
|
members: { create: { userId: soloUser.id, role: "ADMIN" } },
|
|
},
|
|
include: { members: true },
|
|
});
|
|
const soloMember = organization.members[0];
|
|
|
|
await expect(
|
|
removeTeamMember(
|
|
{ userId: soloUser.id, slug: organization.slug, memberId: soloMember.id },
|
|
prisma
|
|
)
|
|
).rejects.toThrow("Cannot remove the last member of an organization");
|
|
|
|
const stillThere = await prisma.orgMember.findUnique({ where: { id: soloMember.id } });
|
|
expect(stillThere).not.toBeNull();
|
|
}
|
|
);
|
|
});
|