feat(webapp,core): add an endpoint to list a project's environments (#3880)

## Summary

Adds `GET /api/v1/projects/{projectRef}/environments` (personal access
token auth), which lists the base environments a user can access for a
project — their own dev environment plus the project's staging, preview,
and production environments.

## Details

- Built on the PAT route builder, so it inherits org-membership auth and
the per-resource ability check.
- `dev` is scoped to the token owner; archived environments are
excluded.
- Returns the branchable **parent** preview environment — preview branch
children are not included. A consumer targets the parent; branch-level
overrides are handled separately.
- Sorted to match the dashboard's environment switcher (dev → staging →
preview → prod), and never returns API keys.

Example response:

```json
[
  { "id": "...", "slug": "dev",     "type": "DEVELOPMENT", "isBranchableEnvironment": false, "branchName": null, "paused": false },
  { "id": "...", "slug": "stg",     "type": "STAGING",     "isBranchableEnvironment": false, "branchName": null, "paused": false },
  { "id": "...", "slug": "preview", "type": "PREVIEW",     "isBranchableEnvironment": true,  "branchName": null, "paused": false },
  { "id": "...", "slug": "prod",    "type": "PRODUCTION",  "isBranchableEnvironment": false, "branchName": null, "paused": false }
]
```
This commit is contained in:
Eric Allam
2026-06-10 10:13:16 +01:00
committed by GitHub
parent b28c6d0b90
commit 87448ccaf2
3 changed files with 93 additions and 0 deletions
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Add `GetProjectEnvironmentsResponseBody` and `ProjectEnvironment` schemas for the new `GET /api/v1/projects/{projectRef}/environments` endpoint, which lists the parent environments (dev, staging, preview, prod) a personal access token can access for a project. Dev is scoped to the token owner and branch (preview child) environments are excluded.
@@ -0,0 +1,71 @@
import { json } from "@remix-run/server-runtime";
import { type GetProjectEnvironmentsResponseBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { $replica } from "~/db.server";
import { findProjectByRef } from "~/models/project.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { sortEnvironments } from "~/utils/environmentSort";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
corsStrategy: "all",
// Resolve projectRef → org so the PAT plugin can ground its role-floor
// calculation. Membership is enforced by the plugin (`authenticatePat`
// rejects users who aren't members of the target org) and again by
// `findProjectByRef` below.
context: async (params) => {
const project = await $replica.project.findFirst({
where: { externalRef: params.projectRef },
select: { organizationId: true },
});
return project ? { organizationId: project.organizationId } : {};
},
authorization: { action: "read", resource: () => ({ type: "environments" }) },
},
async ({ params, authentication }) => {
const project = await findProjectByRef(params.projectRef, authentication.userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environments = await $replica.runtimeEnvironment.findMany({
where: {
projectId: project.id,
// Only base/parent environments. Branch children (preview branches)
// are excluded — syncs target the parent and branches override elsewhere.
parentEnvironmentId: null,
archivedAt: null,
OR: [
{ type: { in: ["STAGING", "PRODUCTION", "PREVIEW"] } },
// dev is per-user: only return the caller's own dev environment
{ type: "DEVELOPMENT", orgMember: { userId: authentication.userId } },
],
},
select: {
id: true,
slug: true,
type: true,
isBranchableEnvironment: true,
branchName: true,
paused: true,
},
});
const result: GetProjectEnvironmentsResponseBody = sortEnvironments(environments).map((env) => ({
id: env.id,
slug: env.slug,
type: env.type,
isBranchableEnvironment: env.isBranchableEnvironment,
branchName: env.branchName,
paused: env.paused,
}));
return json(result);
}
);
+17
View File
@@ -81,6 +81,23 @@ export const GetProjectEnvResponse = z.object({
export type GetProjectEnvResponse = z.infer<typeof GetProjectEnvResponse>;
export const ProjectEnvironment = z.object({
id: z.string(),
/// The slug used as the environment identifier in env var endpoints (e.g. "dev", "stg", "prod", "preview")
slug: z.string(),
type: z.enum(["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"]),
/// Whether this is the branchable parent (preview); individual branches are not returned
isBranchableEnvironment: z.boolean(),
branchName: z.string().nullable(),
paused: z.boolean(),
});
export type ProjectEnvironment = z.infer<typeof ProjectEnvironment>;
export const GetProjectEnvironmentsResponseBody = z.array(ProjectEnvironment);
export type GetProjectEnvironmentsResponseBody = z.infer<typeof GetProjectEnvironmentsResponseBody>;
// Zod schema for the response body type
export const GetWorkerTaskResponse = z.object({
id: z.string(),