fix(apply): pre-flight domain uniqueness, and translate Coolify's 409 (#44) #57

Merged
dan-claude-bot merged 1 commit from fix/domain-preflight into main 2026-07-14 22:43:17 +00:00
dan-claude-bot commented 2026-07-14 22:31:13 +00:00 (Migrated from github.com)

The problem

cast plans inside one project + one environment. Coolify enforces domain uniqueness across the entire instance. So apply can produce a plan that is internally consistent, correct against everything cast can observe, and still be refused — for a reason invisible from cast's own scope, arriving as a raw 409 mid-apply, after the project and the environment have already been created:

POST /applications/private-github-app → 409:
{"message":"Domain conflicts detected. Use force_domain_override=true to proceed.",
 "conflicts":[{"domain":"http://api.89.167.19.110.sslip.io","resource_name":"core",
               "resource_uuid":"tqsmnzdde6oxz3fhl63e2xvl","resource_type":"application",
               "service_name":"api", ...}]}

Same family as the multi-destination 400 (#41) — an instance-wide constraint surfacing untranslated at the worst moment. Unlike that one, this one can be pre-flighted.

What changed

1. The create plan is pre-flighted (preflightDomainConflicts, src/cli.ts). Before apply writes anything — the project and environment are created lazily, by the first create, so this is the last moment a refusal is still free — cast reads GET /applications and checks the domains the plan is about to claim against every one already held. A conflict is a refusal that costs nothing, not a half-applied run. It fires only on a plan that creates an application with a domain: a first apply, and nothing else (databases have no domains, and cast's service creates drop domains on the wire). Both live shapes are covered: fqdn on non-compose apps, per-service docker_compose_domains on dockercompose apps.

2. The 409 is translated (domainConflictRemedy, modelled on multiDestinationRemedy). The pre-flight is a strict subset of Coolify's check — Coolify also compares against service fqdns and the instance fqdn, neither of which appears in any list cast can read — so the translation is not dead code. Both paths say the same three things, the last being the one the operator cannot get from Coolify's own message: the domain, the resource holding it (name + uuid + compose service), and whether that resource is outside the project cast is applying, with the usual cause named — residue from a run cleaned up by deleting a Coolify project, since project deletion does not delete resources.

The scope claim is checked, not assumed: the executor and the pre-flight are both handed the uuids of the live resources cast actually read, so a conflict with something in the plan's own project (a renamed resource) gets a different sentence rather than a lie.

3. force_domain_override=true is never sent. Not on retry, not anywhere — the string does not exist in src/ outside comments and the refusal text that explains why cast declines it. It cannot enter from a manifest either: every manifest object is .strict() (src/manifest.ts), so an unknown key is a parse error, and applicationApiFields's ...rest has nothing to leak. Two resources on one domain is a routing coin-flip, and Coolify says so in the same response it suggests the flag in.

Coolify source evidence

Read from coollabsio/coolify @ v4.1.2 (the vendored OpenAPI is provably incomplete here — it does not document fqdn on GET /applications at all):

  • bootstrap/helpers/domains.php @ checkIfDomainIsAlreadyUsedViaAPI (L142) — the constraint itself. It walks Application::ownedByCurrentTeamAPI($teamId) (every application of the team, any project, any environment) checking fqdn, and — gated on build_pack === 'dockercompose' (L189) — docker_compose_domains; then ServiceApplication fqdns; then the instance fqdn. Comparison strips one trailing slash from both sides and then compares the strings literally, scheme included (L153–177): http://x and https://x are different domains to Coolify. The pre-flight mirrors this exactly, including the build_pack gate — a check stricter than the server would refuse applies Coolify would have allowed.
  • ApplicationsController.php L1112–1127 (and identically at L1353, L1566) — the 409 body: message + conflicts[] + warning, and the force_domain_override bypass.
  • ApplicationsController.php L38 (removeSensitiveData), called at L130 (applications()) and L1980 (application_by_uuid())this is where the issue's suggestion turned out to be half-wrong, and I did the right thing instead. The issue proposes "GET /applications lists every application; a per-app GET exposes its domains… N+1 calls". But the list is serialized by the same removeSensitiveData() as the per-app GET, and neither hides fqdn, docker_compose_domains or build_pack. A per-app GET would return byte-for-byte the same fields. So the pre-flight is one call, not N+1 — and the N+1 would have bought literally nothing.
  • Scope match: applications() lists ownedByCurrentTeamAPI($teamId) — the same population the conflict check walks for applications. That is what makes the pre-flight sound rather than a guess.

Tests

test/domain-preflight.test.ts, 16 new tests, all against fakes (no live instance, no credentials). The refusal and the translated error are what is tested — not the happy path:

  • both live shapes read (fqdn; per-service compose domains), and both desired shapes claimed
  • the real incident's shape: a compose app's api service holding the domain, in a project cast cannot see
  • trailing slash stripped on both sides, and http://https:// — i.e. cast agrees with Coolify's comparison, in both directions
  • stale compose JSON on a nixpacks app does not conflict (Coolify's build_pack gate — a false refusal blocks a correct apply)
  • the pre-flight makes no HTTP call at all unless the plan creates an application with a domain; exactly one when it does
  • the refusal names the uuid and says NOT in <project> / <env>, says deleting a project does not delete its resources, and says Nothing was created
  • the opposite sentence when the conflicting resource is in the applied project
  • the 409 translation: says arrived mid-apply, keeps Coolify's words verbatim, and no request body in the exchange carries force_domain_override
  • a 409 that carries no conflicts (Coolify's duplicate-environment answer) passes through untranslated — the narrowing is on the conflicts, not on the status

npm run check && npm run build && npm test310/310 green (294 baseline + 16).

Noticed, deliberately not fixed here

  • PATCH /applications/{uuid} can 409 too (ApplicationsController.php L2512 runs the same check, excluding self) — an update that moves a domain onto a claimed one still surfaces raw. #44 is about the create path, and the pre-flight is create-only by design; the update-side 409 wants its own issue (the translation function is already reusable for it).
  • This is more evidence for #43: hand-deleting a Coolify project manufactures exactly the invisible orphan this PR now has to explain.

Closes #44.

🤖 Generated with Claude Code

## The problem **cast plans inside one project + one environment. Coolify enforces domain uniqueness across the entire instance.** So `apply` can produce a plan that is internally consistent, correct against everything cast can observe, and still be refused — for a reason invisible from cast's own scope, arriving as a raw 409 mid-apply, *after* the project and the environment have already been created: ``` POST /applications/private-github-app → 409: {"message":"Domain conflicts detected. Use force_domain_override=true to proceed.", "conflicts":[{"domain":"http://api.89.167.19.110.sslip.io","resource_name":"core", "resource_uuid":"tqsmnzdde6oxz3fhl63e2xvl","resource_type":"application", "service_name":"api", ...}]} ``` Same family as the multi-destination 400 (#41) — an instance-wide constraint surfacing untranslated at the worst moment. Unlike that one, this one **can** be pre-flighted. ## What changed **1. The create plan is pre-flighted** (`preflightDomainConflicts`, `src/cli.ts`). Before `apply` writes anything — the project and environment are created *lazily*, by the first create, so this is the last moment a refusal is still free — cast reads `GET /applications` and checks the domains the plan is about to claim against every one already held. A conflict is a **refusal that costs nothing**, not a half-applied run. It fires only on a plan that creates an application with a domain: a first apply, and nothing else (databases have no domains, and cast's service creates drop `domains` on the wire). Both live shapes are covered: `fqdn` on non-compose apps, per-service `docker_compose_domains` on dockercompose apps. **2. The 409 is translated** (`domainConflictRemedy`, modelled on `multiDestinationRemedy`). The pre-flight is a **strict subset** of Coolify's check — Coolify also compares against service `fqdn`s and the instance `fqdn`, neither of which appears in any list cast can read — so the translation is not dead code. Both paths say the same three things, the last being the one the operator cannot get from Coolify's own message: the domain, the resource holding it (name + uuid + compose service), and **whether that resource is outside the project cast is applying**, with the usual cause named — residue from a run cleaned up by deleting a Coolify project, since *project deletion does not delete resources*. The scope claim is *checked*, not assumed: the executor and the pre-flight are both handed the uuids of the live resources cast actually read, so a conflict with something in the plan's own project (a renamed resource) gets a different sentence rather than a lie. **3. `force_domain_override=true` is never sent.** Not on retry, not anywhere — the string does not exist in `src/` outside comments and the refusal text that explains why cast declines it. It cannot enter from a manifest either: every manifest object is `.strict()` (`src/manifest.ts`), so an unknown key is a parse error, and `applicationApiFields`'s `...rest` has nothing to leak. Two resources on one domain is a routing coin-flip, and Coolify says so in the same response it suggests the flag in. ## Coolify source evidence Read from `coollabsio/coolify` @ `v4.1.2` (the vendored OpenAPI is provably incomplete here — it does not document `fqdn` on `GET /applications` at all): - **`bootstrap/helpers/domains.php` @ `checkIfDomainIsAlreadyUsedViaAPI` (L142)** — the constraint itself. It walks `Application::ownedByCurrentTeamAPI($teamId)` (**every application of the team, any project, any environment**) checking `fqdn`, and — gated on `build_pack === 'dockercompose'` (L189) — `docker_compose_domains`; then `ServiceApplication` fqdns; then the instance fqdn. Comparison strips **one trailing slash** from both sides and then compares the strings **literally, scheme included** (L153–177): `http://x` and `https://x` are *different domains* to Coolify. The pre-flight mirrors this exactly, including the `build_pack` gate — a check stricter than the server would refuse applies Coolify would have allowed. - **`ApplicationsController.php` L1112–1127** (and identically at L1353, L1566) — the 409 body: `message` + `conflicts[]` + `warning`, and the `force_domain_override` bypass. - **`ApplicationsController.php` L38 (`removeSensitiveData`), called at L130 (`applications()`) and L1980 (`application_by_uuid()`)** — **this is where the issue's suggestion turned out to be half-wrong, and I did the right thing instead.** The issue proposes "`GET /applications` lists every application; a per-app `GET` exposes its domains… N+1 calls". But the *list* is serialized by the **same** `removeSensitiveData()` as the per-app `GET`, and neither hides `fqdn`, `docker_compose_domains` or `build_pack`. A per-app GET would return byte-for-byte the same fields. So the pre-flight is **one call, not N+1** — and the N+1 would have bought literally nothing. - Scope match: `applications()` lists `ownedByCurrentTeamAPI($teamId)` — the *same* population the conflict check walks for applications. That is what makes the pre-flight sound rather than a guess. ## Tests `test/domain-preflight.test.ts`, 16 new tests, all against fakes (no live instance, no credentials). The refusal and the translated error are what is tested — not the happy path: - both live shapes read (`fqdn`; per-service compose domains), and both desired shapes claimed - the real incident's shape: a compose app's `api` service holding the domain, in a project cast cannot see - **trailing slash stripped on both sides**, and **`http://` ≠ `https://`** — i.e. cast agrees with Coolify's comparison, in both directions - **stale compose JSON on a nixpacks app does not conflict** (Coolify's `build_pack` gate — a false refusal blocks a correct apply) - the pre-flight **makes no HTTP call at all** unless the plan creates an application with a domain; **exactly one** when it does - the refusal names the uuid and says `NOT in <project> / <env>`, says *deleting a project does not delete its resources*, and says `Nothing was created` - the opposite sentence when the conflicting resource **is** in the applied project - the 409 translation: says `arrived mid-apply`, keeps Coolify's words verbatim, and **no request body in the exchange carries `force_domain_override`** - a 409 that carries **no** `conflicts` (Coolify's duplicate-environment answer) passes through **untranslated** — the narrowing is on the conflicts, not on the status `npm run check && npm run build && npm test` → **310/310 green** (294 baseline + 16). ## Noticed, deliberately not fixed here - **`PATCH /applications/{uuid}` can 409 too** (`ApplicationsController.php` L2512 runs the same check, excluding self) — an *update* that moves a domain onto a claimed one still surfaces raw. #44 is about the create path, and the pre-flight is create-only by design; the update-side 409 wants its own issue (the translation function is already reusable for it). - This is more evidence for #43: hand-deleting a Coolify project manufactures exactly the invisible orphan this PR now has to explain. Closes #44. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#57
No description provided.