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:
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user