Summary
DELETE /api/admin/projects/:projectId/environments/:environment will remove a project's last environment and answer 200, leaving the project with zero environments. The guard that used to refuse this with 400 You must always have one active environment was removed in #9427, together with the e2e test that pinned it. The invariant is still enforced in the frontend, still stated in the docs, and the error class it used is still in the tree with no callers.
Tested on main at 3d91a51635859f13a5bc23547d323c19718629fb (unleash-server 8.1.0), OSS, stock config, Postgres 15, admin token.
Reproduction
Against a fresh instance (default project, environments development + production):
POST /api/admin/projects/default/environments
body: {"environment":"tcref-nowhere"}
-> 400
{"name":"BadDataError","message":"Request validation failed: your request body or params contain invalid data: Environment tcref-nowhere does not exist","details":[{"message":"Environment tcref-nowhere does not exist"}]}
DELETE /api/admin/projects/default/environments/production
-> 200
(empty body)
GET /api/admin/projects/default/overview
-> 200
{... "environments":[{"environment":"development"}] ...}
DELETE /api/admin/projects/default/environments/development <-- the last one
-> 200
(empty body)
The two DELETEs are indistinguishable: same status, same empty body. Afterwards, on a hand re-run of the same sequence on a clean instance:
GET /api/admin/projects/default/overview
-> "environments":[]
select * from project_environments where project_id = 'default';
-> 0 rows
The first request above is the control: the endpoint's other validation still works, so this is not a case of the route ignoring its input.
What the project looks like with zero environments
Not bricked (an environment can be added back with POST /api/admin/projects/default/environments), but in between:
POST /api/admin/projects/default/features -> 201 (flag accepted)
GET /api/admin/projects/default/features/<flag>
-> "environments":[{"name":null,"lastSeenAt":null,"enabled":null,"yes":0,"no":0,"type":null,"sortOrder":null,"strategies":[]}]
POST /api/admin/projects/default/features/<flag>/environments/development/on
-> 404 {"name":"NotFoundError","message":"Could not find environment development for feature: <flag>", ...}
So the project accepts flags it can never enable, and serves a null-named environment entry in the flag response.
Where the check went
src/lib/features/project-environments/environment-service.ts:281-297 at this SHA:
async removeEnvironmentFromProject(
environment: string,
projectId: string,
auditUser: IAuditUser,
): Promise<void> {
const _projectEnvs =
await this.projectStore.getEnvironmentsForProject(projectId);
await this.forceRemoveEnvironmentFromProject(environment, projectId);
await this.eventService.storeEvent(
new ProjectEnvironmentRemoved({
project: projectId,
environment,
auditUser,
}),
);
}
The current environment list is fetched and then never read (_projectEnvs). git log -L 281,297:... points at e7ac420, "feat: project environments include visible property (#9427)", merged 2025-03-05:
- if (projectEnvs.length > 1) {
- await this.forceRemoveEnvironmentFromProject(
+ await this.forceRemoveEnvironmentFromProject(environment, projectId);
+ await this.eventService.storeEvent(
+ new ProjectEnvironmentRemoved({
+ project: projectId,
environment,
- projectId,
- );
- ...
- return;
- }
- throw new MinimumOneEnvironmentError(
- 'You must always have one active environment',
+ auditUser,
+ }),
The same commit deleted the test that pinned the behaviour, in src/lib/features/project-environments/environments.e2e.test.ts:
-test('Should not remove environment from project if project only has one environment enabled', async () => {
- await app.request
- .delete(`/api/admin/projects/default/environments/default`)
- .expect(400)
- .expect((r) => {
- expect(r.body.details[0].message).toBe(
- 'You must always have one active environment',
- );
- });
-
- const envs =
- await db.stores.projectStore.getEnvironmentsForProject('default');
-
- expect(envs).toHaveLength(1);
-});
Given that the PR's stated purpose is adding a visible property to a response shape, this looks unintended rather than a deliberate relaxation.
Why it reads as a regression rather than an intentional change
Three things still assert the invariant:
- The UI.
frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx:156-172 disables the toggle and shows "Cannot disable, at least one environment must be visible in the project" when only one environment is visible. Any client that is not that toggle is accepted.
- The docs. https://docs.getunleash.io/concepts/environments still says: "The set of all available environments is defined at the instance level. Additionally, each project can choose which of the environments are visible on the project level. The set of environments available to any given project is always a subset of the environments at the instance level. Each project must always have at least one active environment."
- The error class.
src/lib/error/minimum-one-environment-error.ts is still present and exported; grep -rn 'MinimumOneEnvironment' src/ finds only its own definition, no throw site.
Suggestion
Restore the length check in removeEnvironmentFromProject (the fetched list is already there) and re-add the deleted e2e case. If removing the last environment is in fact intended now, the frontend guard, the error class and the docs sentence all need to change with it.
Suggested labels: bug
Summary
DELETE /api/admin/projects/:projectId/environments/:environmentwill remove a project's last environment and answer200, leaving the project with zero environments. The guard that used to refuse this with400 You must always have one active environmentwas removed in #9427, together with the e2e test that pinned it. The invariant is still enforced in the frontend, still stated in the docs, and the error class it used is still in the tree with no callers.Tested on
mainat3d91a51635859f13a5bc23547d323c19718629fb(unleash-server 8.1.0), OSS, stock config, Postgres 15, admin token.Reproduction
Against a fresh instance (
defaultproject, environmentsdevelopment+production):The two
DELETEs are indistinguishable: same status, same empty body. Afterwards, on a hand re-run of the same sequence on a clean instance:The first request above is the control: the endpoint's other validation still works, so this is not a case of the route ignoring its input.
What the project looks like with zero environments
Not bricked (an environment can be added back with
POST /api/admin/projects/default/environments), but in between:So the project accepts flags it can never enable, and serves a
null-named environment entry in the flag response.Where the check went
src/lib/features/project-environments/environment-service.ts:281-297at this SHA:The current environment list is fetched and then never read (
_projectEnvs).git log -L 281,297:...points at e7ac420, "feat: project environments include visible property (#9427)", merged 2025-03-05:The same commit deleted the test that pinned the behaviour, in
src/lib/features/project-environments/environments.e2e.test.ts:Given that the PR's stated purpose is adding a
visibleproperty to a response shape, this looks unintended rather than a deliberate relaxation.Why it reads as a regression rather than an intentional change
Three things still assert the invariant:
frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx:156-172disables the toggle and shows "Cannot disable, at least one environment must be visible in the project" when only one environment is visible. Any client that is not that toggle is accepted.src/lib/error/minimum-one-environment-error.tsis still present and exported;grep -rn 'MinimumOneEnvironment' src/finds only its own definition, no throw site.Suggestion
Restore the length check in
removeEnvironmentFromProject(the fetched list is already there) and re-add the deleted e2e case. If removing the last environment is in fact intended now, the frontend guard, the error class and the docs sentence all need to change with it.Suggested labels:
bug