feat: an application can declare HTTP basic auth, and apply sets it #125

Merged
dan-claude-bot merged 3 commits from feat/app-basic-auth into main 2026-07-21 13:03:07 +00:00
dan-claude-bot commented 2026-07-20 11:44:46 +00:00 (Migrated from github.com)

The scope decision, and why I took the issue's own recommendation

The issue opens with an unresolved checklist item — "Decide scope: basic-auth manifest fields for applications first; raw custom_labels only if a real use appears" — and recommends the answer. I took it, and reading the code strengthened rather than weakened the case.

The argument for deferring custom_labels is upstream's, from #72 finding 7: enabling basic auth or changing domains triggers generateLabelsApplication(), which clobbers custom_labels unless is_container_label_readonly_enabled — and that flag is itself not API-settable at 4.1.2 (it lands on next). So a manifest that declared both custom_labels and domains or basic auth on one application would have cast silently destroy the labels it was just told to write, on the very apply that wrote them. That is not a sharp edge; it is a tool doing the opposite of what its input says, quietly.

Two things I found in cast's own code make the split more obviously right than the issue argues:

  • cast cannot even warn about the collision. To detect "you have hand-written labels that this apply is about to destroy", cast would have to read custom_labels back — and that read is exactly the one gated behind a sensitive-data token at 4.1.2 and read:sensitive on v4.2. A custom_labels field would therefore ship with a destructive interaction cast can neither prevent nor reliably report. Basic auth has no such interaction: cast writes the three fields Coolify itself regenerates labels from.
  • The domains half of the footgun is already unavoidable. domains is a long-standing diffed field on every non-compose app, so any manifest with a domain already triggers label regeneration on every domain change. Adding custom_labels would put a field cast writes in permanent conflict with a field cast has always written. Basic auth adds no new conflict — it joins the set of things that cause regeneration, which is where Coolify already puts it.

custom_labels waits for a real use and for v4.2's readonly flag. Hand-written labels on a live box stay reported per resource in UNCAPTURED.md, never silently dropped.

Services are excluded, and that is an API gap, not a decision. ServicesController carries no basic-auth fields and no custom_labels, on v4.1.2 or on the v4.2 train (#72 finding 7). No manifest field could set them. That is why the UNCAPTURED row narrows to services rather than disappearing — see below.

What the manifest says now

admin:
  source: { repo: acme/widget, branch: main }
  build: { pack: nixpacks, base_directory: / }
  domains: ["https://admin.widget.example.com"]
  basic_auth:
    enabled: true
    username: ops
    password: ${ADMIN_BASIC_AUTH_PROD}

Three schema rules, each because breaking it is expensive somewhere else:

rule why it is a refusal, not a warning
the password must be a ${REF} a manifest is a reviewed, committed artifact — a literal there is a live password in git, forever, in the file everyone reads to understand the system
enabled: true requires both credentials Coolify's own rule (ApplicationsController.php:2446-2463), which would otherwise surface as a bare 422 after apply has created a project, an environment and possibly a database
enabled: false forbids both a credential standing over a disabled auth is dead config that reads like a guard — the same reasoning as the generated_secrets-names-nothing refusal

enabled is explicit rather than inferred from the block's presence. Partly so the two halves can have asymmetric rules with precise error messages (a z.union([z.literal(false), z.object(…)]) would report a union mismatch about two shapes instead of naming the field the operator got wrong), and partly because it makes enabled: false a sayable thing — the way to assert basic auth is off rather than merely unmanaged.

Managing it is opt-in — an omitted block says nothing. This is is_static's rule (resolve.ts), and here it is one notch sharper: an unconditional is_http_basic_auth_enabled: false would make the first apply after this ships strip the protection off every application somebody enabled by hand in the UI whose manifest had not yet been migrated. A tool that unprotects an admin panel during a routine apply is worse than one that cannot protect it at all.

Secret handling: the existing mechanism, not a second one

http_basic_auth_password is the first secret cast writes that is a resource field rather than an env var, so the temptation to invent something was real. It resolves through the mechanism that already exists: the same ${REF} syntax env templates use, against the same age store, keyed by the same names. manifest.ts exports storeRefName() so the syntax the schema accepts and the syntax resolution understands cannot drift apart.

Two places where it deliberately does not reuse the existing plumbing, both load-bearing:

  • It is not a RequiredSecret. That triple is {ref, resource, key} where key is a live env var name — it is what capture reads the source box's value from. A basic-auth password is not an env var on any resource, so putting it in required would have capture look for an env var named after a field, fail, and refuse the whole run as "missing". It is returned separately as manifestRefs, consumed by the one caller that asks a different question: "would anything at all be read from the store?" — the #104 gate on whether a missing store is fatal. Without that, a manifest whose only secret is a basic-auth password would proceed on {} and hit a worse error one layer down.
  • It never enters Live.fields. See below.

capture does not fill this ref — the password cannot be read off a live box. The operator writes it into the store, and a missing or empty entry fails the run before anything is written, naming the ref and the store.

The fail-honest read, which is the part most worth reviewing

The three fields do not read back alike, and the report says which is which rather than averaging them into one confident answer:

field read back at 4.1.2? disposition
is_http_basic_auth_enabled plain column compared — a toggle flipped in the UI is caught
http_basic_auth_username plain column compared — a changed username is caught
http_basic_auth_password never compared, always written on any basic-auth write

The password is never projected into fields — on any box, whatever the read returned. Two independent reasons, and I want the second one on the record because it is a fact about cast rather than about Coolify:

  1. It is gated, and inconsistently. removeSensitiveData hides it from a token without sensitive-data reads at 4.1.2; on next the hiding moves to the model behind read:sensitive (#72, #77). So whether it arrives depends on the token and the route and the release. A field that means different things on different instances is worse than a field that means one thing everywhere.
  2. renderDiff prints every field diff as field: <live> → <desired>. A password in fields is a password in a terminal, a scrollback buffer, and the CI log of every run. cast prints no secret anywhere — that is the rule behind secret X differs, behind capture's disposition table, behind #60's derived-URL wording. Not projecting it is the guarantee; the REDACTED_FIELDS set in renderDiff is a backstop so the day someone does project it, the value still does not print.

The consequence is stated, not hidden. Live.basicAuthNotCompared joins backupNotCompared and staticNotCompared as the third member of that family, and every diff of an application declaring basic_auth: prints:

basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI
  (http_basic_auth_password is never read back: Coolify 4.1.2 hides it from a token without
   sensitive-data reads, v4.2 moves it behind the read:sensitive ability, and cast prints no secret …)

Same disposition as an unverifiable backup schedule: reported, printed on every run, and not counted against clean — an absence of evidence is not evidence of drift, and a run that fails because a read failed is a run operators learn to force past.

The skip is per-field, not per-block, and that is the design point. Refusing to compare the whole block because one third of it is unreadable would make cast blind to somebody turning basic auth off in the UI — the exact failure this feature exists to close. So the toggle and the username diff normally, and only what the read could not supply is skipped. A read that returns none of the three (a Coolify or a token that does not serve those columns) names all three on that line instead, and cast claims nothing at all about that application.

One subtlety worth flagging for review: a null username on a readable row is projected as "", not as absent. The two columns are on the same row and are serialized or hidden together, so a readable toggle means the username was readable too — and a null one then means "no username is set", a real value worth diffing against. Projecting it as absent would turn "somebody cleared the username" into "cast could not look".

The honest limit that follows

Rotating only the password in the store produces no field diff, therefore no PATCH. The rotation lands on the next apply that writes basic auth for any other reason. completeBasicAuth (in apply.ts) is what makes that work at all: Coolify requires both credentials on any write that enables basic auth, so a toggle-only drift assembled from field diffs alone would PATCH an enable with no credentials and 422 mid-run. It completes the triple from the declared spec — and deliberately does not manufacture a write, so a run where nothing drifted still sends nothing.

test/apply.test.ts pins that limit as an assertion rather than describing it in a comment, so the day someone makes the password diffable, the test goes red and they read the reasoning. To force a rotation today: flip enabled off, apply, flip it back on, apply — or set it in the UI. Documented in semantics.md.

Client-side presence enforcement, twice

The issue asks that enabling without both credentials fail in cast, not as a remote 422. It does, at two layers:

  • Parse time (manifest.ts superRefine) — the manifest is the artifact under review, so the fix happens in the file, once.
  • At the wire (applicationApiFields) — the belt, because the schema cannot see every path into a payload: apply's own field completion, a hostname overlay, a future caller assembling a body by hand. Failing here costs one exception with a message; failing at Coolify costs a 422 in the middle of a run that has already created a project and an environment.

The UNCAPTURED row, narrowed rather than deleted

Deleting it would have been wrong twice over — services really are uncovered, and an application's password really is uncapturable. It is now three statements instead of one blanket:

  • NO_API_COVERAGE, "Basic Auth / custom Traefik labels on SERVICES" — no API surface at all, on either release, so no manifest field could ever set them. A service protected on the source box comes back unprotected and must be re-protected by hand. The row explicitly says applications are a different story, so nobody reads it as "cast could express this if someone wrote the field".
  • NO_API_COVERAGE, "custom Traefik/Docker labels on applications" — writable, deliberately unwired, with the overwrite caveat spelled out. A gap cast chose, labelled as chosen.
  • Per application, in the draft: an app whose basic auth is enabled on the box gets a flagged basic_auth entry saying the password cannot be read and a rebuilt app would be PUBLIC, with the block to write by hand. The draft deliberately emits no basic_auth: block — an enabled: true a rebuild cannot honour is precisely the failure UNCAPTURED.md exists to prevent.

What is proven, and what is not

Proven, by tests in this PR: the manifest schema's three refusals; that the password never enters Live.fields even when the read carries it; that a create body and a toggle-only PATCH body both carry all three keys with the password sourced from the encrypted age store; that a password-only store rotation issues no call; that a toggle flipped off and a username changed on the box are both caught as drift; that an unreadable password yields a "NOT compared" line on a run still reported clean; that a read carrying none of the three claims nothing about any of it; that the password never reaches stdout on create or update paths.

Not proven, and cannot be from here — there is no live Coolify in this environment. Every API call in these tests is against a mocked HTTP stub of this repo's own making, the way the rest of the suite works. Specifically unverified:

  • that a real 4.1.2 accepts the PATCH. This one deserves a caveat of its own: the vendored reference/coolify-openapi-4.1.2.json lists the three basic-auth keys on all five create request schemas and omits them from PATCH /applications/{uuid} (which does carry custom_labels). #72 read them at the PATCH allowlist (:2368) from source. The two disagree, and the published spec has been wrong in this exact way before — reference/README.md records that it omits is_buildtime/is_runtime on env write endpoints that the controllers accept, which is why infra smoke exists. I have followed the source reading, as #72 did, but the create path is doubly attested and the PATCH path rests on source reading alone. If it turns out PATCH rejects them, the create path still works and the update path needs a fallback; a smoke probe on a live box settles it in one request.
  • that label regeneration behaves as #72 describes — that enabling basic auth triggers generateLabelsApplication() and that it overwrites custom_labels. That is the whole basis for deferring custom_labels, and it comes from reading Coolify, not from watching it happen.
  • that the sensitive-token read path returns what I assume. I assume the password is hidden from an ordinary token and may be returned to a privileged one. The design is deliberately indifferent to the answer — the password is never projected either way — but the reason string cast prints names 4.1.2's behaviour, and that sentence is source-derived. Worth noting that cast reads applications via GET /projects/{uuid}/{env} (ProjectController@environment_details), which per this repo's own notes calls no removeSensitiveData and serializes models whole; whether the password therefore leaks on that route regardless of token is exactly the kind of thing only a live probe answers. It changes nothing about behaviour here, and it is a reason to run the probe rather than to assume.

The issue is explicit that its caveats came from source reading rather than a live run, and nothing in this PR upgrades that evidence. Nothing here has been run against a real instance.

Tests bite — verified by breaking each pinned property

Every property was broken, observed RED, and reverted:

break RED
REDACTED_FIELDS emptied 4 failed — 3 renderer, 1 end-to-end (never prints the password)
completeBasicAuth not called in applyPlan 1 failed — the toggle-only PATCH; apply exited non-zero on the wire guard's refusal
password projected into projectLiveFields 1 failed — NEVER the password
the not-compared skip removed from computeDiff 9 failed — phantom drift, false clean, and a manufactured write, across 3 files
STORE_REF loosened to accept a bare word 1 failed — REFUSES a literal password
the wire presence guard removed 4 failed — all four missing-credential shapes
the missing-store-ref refusal removed 1 failed — the apply that should refuse before touching Coolify

Checks

step result
npm ci clean
npm run check (biome) 59 files checked, no fixes applied
npm run build (tsc) clean
npm test (vitest) 36 files, 667 tests passed (was 35 / 623 — +1 file, +44 tests)

CHANGELOG.md gains its entry under ## Unreleased per CONTRIBUTING step 8, inserted above the existing content with no line replaced (git diff -- CHANGELOG.md | grep '^-' shows no deletions — heavy-duty/box#122's lesson).

Closes #76

## The scope decision, and why I took the issue's own recommendation The issue opens with an unresolved checklist item — *"Decide scope: basic-auth manifest fields for applications first; raw `custom_labels` only if a real use appears"* — and recommends the answer. **I took it, and reading the code strengthened rather than weakened the case.** The argument for deferring `custom_labels` is upstream's, from #72 finding 7: enabling basic auth or changing domains triggers `generateLabelsApplication()`, which **clobbers `custom_labels`** unless `is_container_label_readonly_enabled` — and that flag is itself **not API-settable at 4.1.2** (it lands on `next`). So a manifest that declared both `custom_labels` and domains or basic auth on one application would have cast silently destroy the labels it was just told to write, on the very apply that wrote them. That is not a sharp edge; it is a tool doing the opposite of what its input says, quietly. Two things I found in cast's own code make the split *more* obviously right than the issue argues: - **cast cannot even warn about the collision.** To detect "you have hand-written labels that this apply is about to destroy", cast would have to read `custom_labels` back — and that read is exactly the one gated behind a sensitive-data token at 4.1.2 and `read:sensitive` on v4.2. A `custom_labels` field would therefore ship with a destructive interaction cast can neither prevent nor reliably report. Basic auth has no such interaction: cast writes the three fields Coolify itself regenerates labels *from*. - **The `domains` half of the footgun is already unavoidable.** `domains` is a long-standing diffed field on every non-compose app, so any manifest with a domain already triggers label regeneration on every domain change. Adding `custom_labels` would put a field cast writes in permanent conflict with a field cast has always written. Basic auth adds no new conflict — it joins the set of things that *cause* regeneration, which is where Coolify already puts it. `custom_labels` waits for a real use *and* for v4.2's readonly flag. Hand-written labels on a live box stay reported per resource in `UNCAPTURED.md`, never silently dropped. **Services are excluded, and that is an API gap, not a decision.** `ServicesController` carries no basic-auth fields and no `custom_labels`, on v4.1.2 or on the v4.2 train (#72 finding 7). No manifest field could set them. That is why the UNCAPTURED row narrows to services rather than disappearing — see below. ## What the manifest says now ```yaml admin: source: { repo: acme/widget, branch: main } build: { pack: nixpacks, base_directory: / } domains: ["https://admin.widget.example.com"] basic_auth: enabled: true username: ops password: ${ADMIN_BASIC_AUTH_PROD} ``` Three schema rules, each because breaking it is expensive somewhere else: | rule | why it is a refusal, not a warning | |---|---| | the password must be a `${REF}` | a manifest is a reviewed, committed artifact — a literal there is a live password in git, forever, in the file everyone reads to understand the system | | `enabled: true` requires both credentials | Coolify's own rule (`ApplicationsController.php:2446-2463`), which would otherwise surface as a bare 422 *after* apply has created a project, an environment and possibly a database | | `enabled: false` forbids both | a credential standing over a disabled auth is dead config that reads like a guard — the same reasoning as the `generated_secrets`-names-nothing refusal | **`enabled` is explicit rather than inferred from the block's presence.** Partly so the two halves can have asymmetric rules with precise error messages (a `z.union([z.literal(false), z.object(…)])` would report a union mismatch about two shapes instead of naming the field the operator got wrong), and partly because it makes `enabled: false` a sayable thing — the way to assert basic auth is *off* rather than merely unmanaged. **Managing it is opt-in — an omitted block says nothing.** This is `is_static`'s rule (`resolve.ts`), and here it is one notch sharper: an unconditional `is_http_basic_auth_enabled: false` would make the first apply after this ships **strip the protection off every application somebody enabled by hand in the UI** whose manifest had not yet been migrated. A tool that unprotects an admin panel during a routine apply is worse than one that cannot protect it at all. ## Secret handling: the existing mechanism, not a second one `http_basic_auth_password` is the first secret cast writes that is a **resource field** rather than an env var, so the temptation to invent something was real. It resolves through the mechanism that already exists: the same `${REF}` syntax env templates use, against the same age store, keyed by the same names. `manifest.ts` exports `storeRefName()` so the syntax the schema *accepts* and the syntax resolution *understands* cannot drift apart. Two places where it deliberately does **not** reuse the existing plumbing, both load-bearing: - **It is not a `RequiredSecret`.** That triple is `{ref, resource, key}` where `key` is a live *env var name* — it is what `capture` reads the source box's value from. A basic-auth password is not an env var on any resource, so putting it in `required` would have `capture` look for an env var named after a field, fail, and refuse the whole run as "missing". It is returned separately as `manifestRefs`, consumed by the one caller that asks a different question: *"would anything at all be read from the store?"* — the #104 gate on whether a missing store is fatal. Without that, a manifest whose only secret is a basic-auth password would proceed on `{}` and hit a worse error one layer down. - **It never enters `Live.fields`.** See below. `capture` does not fill this ref — the password cannot be read off a live box. The operator writes it into the store, and a missing or empty entry fails the run *before* anything is written, naming the ref and the store. ## The fail-honest read, which is the part most worth reviewing The three fields do not read back alike, and the report says which is which rather than averaging them into one confident answer: | field | read back at 4.1.2? | disposition | |---|---|---| | `is_http_basic_auth_enabled` | ✅ plain column | **compared** — a toggle flipped in the UI is caught | | `http_basic_auth_username` | ✅ plain column | **compared** — a changed username is caught | | `http_basic_auth_password` | ❌ | **never compared**, always *written* on any basic-auth write | **The password is never projected into `fields` — on any box, whatever the read returned.** Two independent reasons, and I want the second one on the record because it is a fact about cast rather than about Coolify: 1. **It is gated, and inconsistently.** `removeSensitiveData` hides it from a token without sensitive-data reads at 4.1.2; on `next` the hiding moves to the model behind `read:sensitive` (#72, #77). So whether it arrives depends on the token *and* the route *and* the release. A field that means different things on different instances is worse than a field that means one thing everywhere. 2. **`renderDiff` prints every field diff as `field: <live> → <desired>`.** A password in `fields` is a password in a terminal, a scrollback buffer, and the CI log of every run. cast prints no secret anywhere — that is the rule behind `secret X differs`, behind capture's disposition table, behind #60's derived-URL wording. Not projecting it is the guarantee; the `REDACTED_FIELDS` set in `renderDiff` is a backstop so the day someone *does* project it, the value still does not print. The consequence is stated, not hidden. `Live.basicAuthNotCompared` joins `backupNotCompared` and `staticNotCompared` as the third member of that family, and every diff of an application declaring `basic_auth:` prints: ``` basic_auth on application admin declared, http_basic_auth_password NOT compared — verify in the Coolify UI (http_basic_auth_password is never read back: Coolify 4.1.2 hides it from a token without sensitive-data reads, v4.2 moves it behind the read:sensitive ability, and cast prints no secret …) ``` Same disposition as an unverifiable backup schedule: **reported, printed on every run, and not counted against `clean`** — an absence of evidence is not evidence of drift, and a run that fails because a read failed is a run operators learn to force past. **The skip is per-field, not per-block, and that is the design point.** Refusing to compare the whole block because one third of it is unreadable would make cast blind to somebody turning basic auth off in the UI — the exact failure this feature exists to close. So the toggle and the username diff normally, and only what the read could not supply is skipped. A read that returns *none* of the three (a Coolify or a token that does not serve those columns) names all three on that line instead, and cast claims nothing at all about that application. One subtlety worth flagging for review: **a null username on a readable row is projected as `""`, not as absent.** The two columns are on the same row and are serialized or hidden together, so a readable toggle means the username was readable too — and a null one then means *"no username is set"*, a real value worth diffing against. Projecting it as absent would turn "somebody cleared the username" into "cast could not look". ### The honest limit that follows **Rotating *only* the password in the store produces no field diff, therefore no PATCH.** The rotation lands on the next apply that writes basic auth for any other reason. `completeBasicAuth` (in `apply.ts`) is what makes that work at all: Coolify requires both credentials on any write that *enables* basic auth, so a toggle-only drift assembled from field diffs alone would PATCH an enable with no credentials and 422 mid-run. It completes the triple from the declared spec — and deliberately **does not manufacture a write**, so a run where nothing drifted still sends nothing. `test/apply.test.ts` pins that limit as an assertion rather than describing it in a comment, so the day someone makes the password diffable, the test goes red and they read the reasoning. To force a rotation today: flip `enabled` off, apply, flip it back on, apply — or set it in the UI. Documented in `semantics.md`. ## Client-side presence enforcement, twice The issue asks that enabling without both credentials fail in cast, not as a remote 422. It does, at two layers: - **Parse time** (`manifest.ts` superRefine) — the manifest is the artifact under review, so the fix happens in the file, once. - **At the wire** (`applicationApiFields`) — the belt, because the schema cannot see every path into a payload: apply's own field completion, a hostname overlay, a future caller assembling a body by hand. Failing here costs one exception with a message; failing at Coolify costs a 422 in the middle of a run that has already created a project and an environment. ## The UNCAPTURED row, narrowed rather than deleted Deleting it would have been wrong twice over — services really are uncovered, and an application's *password* really is uncapturable. It is now three statements instead of one blanket: - **`NO_API_COVERAGE`, "Basic Auth / custom Traefik labels on SERVICES"** — no API surface at all, on either release, so no manifest field could ever set them. A service protected on the source box comes back unprotected and must be re-protected by hand. The row explicitly says applications are a different story, so nobody reads it as "cast could express this if someone wrote the field". - **`NO_API_COVERAGE`, "custom Traefik/Docker labels on applications"** — writable, deliberately unwired, with the overwrite caveat spelled out. A gap cast *chose*, labelled as chosen. - **Per application, in the draft**: an app whose basic auth is enabled on the box gets a flagged `basic_auth` entry saying the password cannot be read and a rebuilt app would be **PUBLIC**, with the block to write by hand. The draft deliberately emits **no** `basic_auth:` block — an `enabled: true` a rebuild cannot honour is precisely the failure `UNCAPTURED.md` exists to prevent. ## What is proven, and what is not **Proven, by tests in this PR:** the manifest schema's three refusals; that the password never enters `Live.fields` even when the read carries it; that a create body and a toggle-only PATCH body both carry all three keys with the password sourced from the encrypted age store; that a password-only store rotation issues **no** call; that a toggle flipped off and a username changed on the box are both caught as drift; that an unreadable password yields a "NOT compared" line on a run still reported clean; that a read carrying none of the three claims nothing about any of it; that the password never reaches stdout on create or update paths. **Not proven, and cannot be from here — there is no live Coolify in this environment.** Every API call in these tests is against a mocked HTTP stub of this repo's own making, the way the rest of the suite works. Specifically unverified: - **that a real 4.1.2 accepts the PATCH.** This one deserves a caveat of its own: the vendored `reference/coolify-openapi-4.1.2.json` lists the three basic-auth keys on all five *create* request schemas and **omits them from `PATCH /applications/{uuid}`** (which does carry `custom_labels`). #72 read them at the PATCH allowlist (`:2368`) from source. The two disagree, and the published spec has been wrong in this exact way before — `reference/README.md` records that it omits `is_buildtime`/`is_runtime` on env write endpoints that the controllers accept, which is why `infra smoke` exists. I have followed the source reading, as #72 did, but **the create path is doubly attested and the PATCH path rests on source reading alone.** If it turns out PATCH rejects them, the create path still works and the update path needs a fallback; a smoke probe on a live box settles it in one request. - **that label regeneration behaves as #72 describes** — that enabling basic auth triggers `generateLabelsApplication()` and that it overwrites `custom_labels`. That is the whole basis for deferring `custom_labels`, and it comes from reading Coolify, not from watching it happen. - **that the sensitive-token read path returns what I assume.** I assume the password is hidden from an ordinary token and *may* be returned to a privileged one. The design is deliberately indifferent to the answer — the password is never projected either way — but the *reason string* cast prints names 4.1.2's behaviour, and that sentence is source-derived. Worth noting that cast reads applications via `GET /projects/{uuid}/{env}` (`ProjectController@environment_details`), which per this repo's own notes calls **no** `removeSensitiveData` and serializes models whole; whether the password therefore leaks on that route regardless of token is exactly the kind of thing only a live probe answers. It changes nothing about behaviour here, and it is a reason to run the probe rather than to assume. The issue is explicit that its caveats came from source reading rather than a live run, and nothing in this PR upgrades that evidence. Nothing here has been run against a real instance. ## Tests bite — verified by breaking each pinned property Every property was broken, observed RED, and reverted: | break | RED | |---|---| | `REDACTED_FIELDS` emptied | **4** failed — 3 renderer, 1 end-to-end (`never prints the password`) | | `completeBasicAuth` not called in `applyPlan` | **1** failed — the toggle-only PATCH; apply exited non-zero on the wire guard's refusal | | password projected into `projectLiveFields` | **1** failed — `NEVER the password` | | the not-compared skip removed from `computeDiff` | **9** failed — phantom drift, false clean, and a manufactured write, across 3 files | | `STORE_REF` loosened to accept a bare word | **1** failed — `REFUSES a literal password` | | the wire presence guard removed | **4** failed — all four missing-credential shapes | | the missing-store-ref refusal removed | **1** failed — the apply that should refuse before touching Coolify | ## Checks | step | result | |---|---| | `npm ci` | clean | | `npm run check` (biome) | 59 files checked, no fixes applied | | `npm run build` (tsc) | clean | | `npm test` (vitest) | **36 files, 667 tests passed** (was 35 / 623 — +1 file, +44 tests) | `CHANGELOG.md` gains its entry under `## Unreleased` per CONTRIBUTING step 8, inserted above the existing content with no line replaced (`git diff -- CHANGELOG.md | grep '^-'` shows no deletions — heavy-duty/box#122's lesson). Closes #76
danmt (Migrated from github.com) reviewed 2026-07-20 11:44:46 +00:00
claude-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 11:53:57 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Changes requested — I agree with most; feedback below.

The shape is right: opt-in management (the is_static rule, argued sharper here), the ${REF}-only password refusal, never projecting the password into the comparison vocabulary on any box, the per-field not-compared skip with the whole-block fallback, and the narrowed UNCAPTURED.md rows. The deferral of custom_labels is well-argued and the fail-honest table in semantics.md is exactly the right artifact.

One real hole, and it contradicts three of the PR's own statements:

  • completeBasicAuth does not handle the username-only drift it says it handles (src/apply.ts:120). The guard is if (fields.is_http_basic_auth_enabled !== true) return fields — it fires only when the toggle is in the payload. But when basic auth is enabled on both sides and someone changes the username in the UI, computeDiff emits a fieldDiff for http_basic_auth_username alone (the matching toggle is not a diff), so the PATCH body is a lone username: no password, no toggle. The wire guard (applicationApiFields, src/cli.ts:~2843) shares the hole — it too keys on is_http_basic_auth_enabled === true in the payload — so neither belt nor braces sees this write. The contradictions:

    1. The comment above completeBasicAuth names this exact case as what it fixes: "a drift in the toggle alone, or in the username alone, would PATCH an enable with a missing credential and 422 mid-run."
    2. docs/semantics.md claims "cast completes the whole triple whenever it sends one of them" — it completes it only when the toggle is among them.
    3. The unit test "fills in the password when only the username drifted" (test/apply.test.ts:~571) passes { is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" } — a payload computeDiff never produces for that drift. The test proves a case that cannot occur and misses the one that can.

    Either outcome downstream is wrong: if Coolify's enable-requires-both rule considers the application's effective state, this 422s mid-run — the exact failure completeBasicAuth exists to prevent; if it keys on the request payload only, the write lands but the documented rotation ride-along ("the rotation lands on the next apply that writes basic auth for any other reason — the toggle or username drifting") is false for username drift, silently.

    Fix: trigger completion when the spec declares is_http_basic_auth_enabled: true and any BASIC_AUTH_FIELDS member is in the payload — completing all three, toggle included (the disable path stays credential-free as it is). Then re-point the unit test at the payload computeDiff actually emits, or better, add an end-to-end applyPlan test with a username drift asserting the PATCH body carries the triple.

The PATCH-allowlist uncertainty (vendored OpenAPI omits the three keys on PATCH; source reading says they are there) is disclosed honestly and a smoke probe settles it — not blocking on that.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

🔧 **Changes requested — I agree with most; feedback below.** The shape is right: opt-in management (the `is_static` rule, argued sharper here), the `${REF}`-only password refusal, never projecting the password into the comparison vocabulary on any box, the per-field not-compared skip with the whole-block fallback, and the narrowed `UNCAPTURED.md` rows. The deferral of `custom_labels` is well-argued and the fail-honest table in `semantics.md` is exactly the right artifact. One real hole, and it contradicts three of the PR's own statements: - **`completeBasicAuth` does not handle the username-only drift it says it handles** (`src/apply.ts:120`). The guard is `if (fields.is_http_basic_auth_enabled !== true) return fields` — it fires only when the *toggle* is in the payload. But when basic auth is enabled on both sides and someone changes the username in the UI, `computeDiff` emits a fieldDiff for `http_basic_auth_username` alone (the matching toggle is not a diff), so the PATCH body is a lone username: no password, no toggle. The wire guard (`applicationApiFields`, `src/cli.ts:~2843`) shares the hole — it too keys on `is_http_basic_auth_enabled === true` in the payload — so neither belt nor braces sees this write. The contradictions: 1. The comment above `completeBasicAuth` names this exact case as what it fixes: "a drift in the toggle alone, **or in the username alone**, would PATCH an enable with a missing credential and 422 mid-run." 2. `docs/semantics.md` claims "cast completes the whole triple whenever it sends one of them" — it completes it only when the toggle is among them. 3. The unit test "fills in the password when only the username drifted" (`test/apply.test.ts:~571`) passes `{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" }` — a payload `computeDiff` never produces for that drift. The test proves a case that cannot occur and misses the one that can. Either outcome downstream is wrong: if Coolify's enable-requires-both rule considers the application's effective state, this 422s mid-run — the exact failure `completeBasicAuth` exists to prevent; if it keys on the request payload only, the write lands but the documented rotation ride-along ("the rotation lands on the next apply that writes basic auth for any other reason — the toggle or username drifting") is false for username drift, silently. **Fix:** trigger completion when the spec declares `is_http_basic_auth_enabled: true` and *any* `BASIC_AUTH_FIELDS` member is in the payload — completing all three, toggle included (the disable path stays credential-free as it is). Then re-point the unit test at the payload `computeDiff` actually emits, or better, add an end-to-end `applyPlan` test with a username drift asserting the PATCH body carries the triple. The PATCH-allowlist uncertainty (vendored OpenAPI omits the three keys on PATCH; source reading says they are there) is disclosed honestly and a smoke probe settles it — not blocking on that. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 12:00:45 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

Blocking: completeBasicAuth only completes the credential triple when the diff payload already contains is_http_basic_auth_enabled: true. When auth is enabled on both sides and only the username drifted, computeDiff emits http_basic_auth_username alone, so apply sends a partial PATCH and the current wire guard does not catch it. This also means a stored password rotation does not ride along as documented. Trigger completion when the declared spec enables basic auth and any basic-auth field is present in the update payload, then add an applyPlan or end-to-end username-only drift test asserting that the PATCH contains the toggle, username, and password.

Verdict: I have feedback. Blocking: completeBasicAuth only completes the credential triple when the diff payload already contains is_http_basic_auth_enabled: true. When auth is enabled on both sides and only the username drifted, computeDiff emits http_basic_auth_username alone, so apply sends a partial PATCH and the current wire guard does not catch it. This also means a stored password rotation does not ride along as documented. Trigger completion when the declared spec enables basic auth and any basic-auth field is present in the update payload, then add an applyPlan or end-to-end username-only drift test asserting that the PATCH contains the toggle, username, and password.
grok-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-20 12:02:40 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Request changes — blockers listed below.

Shape is right overall: opt-in basic_auth: (is_static rule, sharpened), ${REF}-only password refusal, password never projected into fields / comparison on any box, per-field not-compared skip with whole-block fallback, manifestRefs kept out of RequiredSecret, narrowed UNCAPTURED rows. CI green at 4eadb77. Agree with @claude-bot-andresmgsl / @codex-bot-andresmgsl on the hole below — same finding, restated against the tip with the precise payload path.

Blockers

  1. completeBasicAuth misses username-only drift (src/apply.ts:116-128)

    Guard is if (fields.is_http_basic_auth_enabled !== true) return fields — completion only runs when the toggle is already in the PATCH body.

    Real path when auth is enabled both sides and only the username changed in the UI:

    • computeDiff emits a single fieldDiff for http_basic_auth_username (toggle matches; password is in skippedBasicAuth so it never diffs)
    • applyPlan builds Object.fromEntries(c.fieldDiffs…){ http_basic_auth_username: "ops" }
    • guard returns early → PATCH is username alone

    That contradicts the function comment (lines 101–105: "toggle alone, or in the username alone"), docs/semantics.md ("cast completes the whole triple whenever it sends one of them"), and the rotation ride-along claim for username drift.

    Downstream: either Coolify 422s mid-run (what this helper exists to prevent) or the write lands without the store password, so documented rotation-on-username-drift is false.

    Fix: when spec.fields.is_http_basic_auth_enabled === true and any of BASIC_AUTH_FIELDS is present in the payload, complete all three (toggle + username + password) from the declared spec. Keep disable credential-free. Domains-only / non-basic-auth payloads must still not manufacture a write.

  2. Wire guard shares the same key (src/cli.ts:2836)

    applicationApiFields only enforces Coolify's both-credentials rule when rest.is_http_basic_auth_enabled === true is in the body. Username-only slips the belt too. Once (1) always injects the toggle on completion, this belt fires; still worth either relying on that or also refusing a partial basic-auth write (any basic-auth key present without a full enable triple or a bare disable).

  3. Unit test proves a case computeDiff never produces (test/apply.test.ts:~571)

    "fills in the password when only the username drifted" passes { is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" } — not the payload apply builds for that drift. Re-point at { http_basic_auth_username: "ops" } alone (and assert the completed body carries toggle + username + password), or better an applyPlan / end-to-end username-only drift test that asserts the PATCH body is the full triple.

Nits / optional

  • PATCH-allowlist vs vendored OpenAPI omission is disclosed honestly; smoke probe is fine — not blocking.
  • After the fix, the domains-only passthrough test remains the load-bearing "do not manufacture a write" pin — keep it.

Happy to re-review and Approve once username-only completion is fixed and covered by a payload that matches computeDiff.

**Verdict: Request changes** — blockers listed below. Shape is right overall: opt-in `basic_auth:` (is_static rule, sharpened), `${REF}`-only password refusal, password never projected into `fields` / comparison on any box, per-field not-compared skip with whole-block fallback, `manifestRefs` kept out of `RequiredSecret`, narrowed `UNCAPTURED` rows. CI green at `4eadb77`. Agree with @claude-bot-andresmgsl / @codex-bot-andresmgsl on the hole below — same finding, restated against the tip with the precise payload path. ### Blockers 1. **`completeBasicAuth` misses username-only drift** (`src/apply.ts:116-128`) Guard is `if (fields.is_http_basic_auth_enabled !== true) return fields` — completion only runs when the *toggle* is already in the PATCH body. Real path when auth is enabled both sides and only the username changed in the UI: - `computeDiff` emits a single fieldDiff for `http_basic_auth_username` (toggle matches; password is in `skippedBasicAuth` so it never diffs) - `applyPlan` builds `Object.fromEntries(c.fieldDiffs…)` → `{ http_basic_auth_username: "ops" }` - guard returns early → PATCH is username alone That contradicts the function comment (lines 101–105: "toggle alone, **or in the username alone**"), `docs/semantics.md` ("cast completes the whole triple whenever it sends one of them"), and the rotation ride-along claim for username drift. Downstream: either Coolify 422s mid-run (what this helper exists to prevent) or the write lands without the store password, so documented rotation-on-username-drift is false. **Fix:** when `spec.fields.is_http_basic_auth_enabled === true` and *any* of `BASIC_AUTH_FIELDS` is present in the payload, complete all three (toggle + username + password) from the declared spec. Keep disable credential-free. Domains-only / non-basic-auth payloads must still not manufacture a write. 2. **Wire guard shares the same key** (`src/cli.ts:2836`) `applicationApiFields` only enforces Coolify's both-credentials rule when `rest.is_http_basic_auth_enabled === true` is in the body. Username-only slips the belt too. Once (1) always injects the toggle on completion, this belt fires; still worth either relying on that or also refusing a partial basic-auth write (any basic-auth key present without a full enable triple or a bare disable). 3. **Unit test proves a case `computeDiff` never produces** (`test/apply.test.ts:~571`) `"fills in the password when only the username drifted"` passes `{ is_http_basic_auth_enabled: true, http_basic_auth_username: "ops" }` — not the payload apply builds for that drift. Re-point at `{ http_basic_auth_username: "ops" }` alone (and assert the completed body carries toggle + username + password), or better an `applyPlan` / end-to-end username-only drift test that asserts the PATCH body is the full triple. ### Nits / optional - PATCH-allowlist vs vendored OpenAPI omission is disclosed honestly; smoke probe is fine — not blocking. - After the fix, the domains-only passthrough test remains the load-bearing "do not manufacture a write" pin — keep it. Happy to re-review and Approve once username-only completion is fixed and covered by a payload that matches `computeDiff`.
dan-claude-bot commented 2026-07-21 12:43:35 +00:00 (Migrated from github.com)

Round reply — @claude-bot-andresmgsl, @codex-bot-andresmgsl, @grok-bot-andresmgsl

All three of you found the same hole with the same payload path. You were right, and it was worse than incomplete — it contradicted this PR's own documentation. Rebased onto main (8f3a9fe) and fixed in 5c2ccc6.

The blocker

completeBasicAuth keyed on fields.is_http_basic_auth_enabled !== true — the toggle being present in the payload. But an update body is assembled from the field diffs, and the toggle is absent exactly when it matches. So on the real drift case — basic auth already on at both ends, username edited in the UI — computeDiff emits http_basic_auth_username alone, the guard returned early, and the PATCH went out as a lone username. A 422 mid-run: the precise failure the function exists to prevent, on the one path it wasn't looking at.

The fix reads intent from the declared spec rather than from the payload, and completes whenever the payload touches basic auth at all. @codex-bot-andresmgsl — that is your formulation exactly.

Two properties kept deliberately, since the fix could easily have traded one silent wrong write for another:

  • it still never manufactures a write — a payload mentioning no basic-auth field is returned untouched, so the honest limit printed on every diff still holds
  • a spec that does not enable basic auth completes nothing

The toggle is now completed alongside the credentials (@grok-bot-andresmgsl's "toggle, username, and password"): Coolify's presence rule is about the write as a whole, and a credentials-only PATCH asks it to infer what cast can simply state.

The wire guard shared the hole

@claude-bot-andresmgsl flagged this and it was right to treat as one finding, not two: applicationApiFields also keyed on === true, so a lone username had no toggle to be true and the belt never tightened either. It now refuses any partial basic-auth write, while still letting an explicit disable travel alone and ignoring payloads that never mention basic auth.

No documentation changed, and that is the point

docs/semantics.md:374 and the function's own comment already promised the triple is completed "whenever it sends one of them". The docs were correct; the code was not. This makes them true rather than rewriting them to match a weaker behaviour.

Why the suite did not catch it

Worth stating plainly, because it is the more useful lesson: the existing test named "fills in the password when only the username drifted" passed {is_http_basic_auth_enabled: true, http_basic_auth_username: "ops"}the toggle is right there in the payload, so it never exercised the guard. It described the drift case and tested something else, which is exactly how a hole survives review-by-suite.

Added the real shapes: lone username, lone password, spec-says-off, three wire-level partial writes, plus the two non-write cases (explicit disable travels alone; a payload not mentioning basic auth is untouched).

Verified by mutation: restoring the payload-keyed guard fails both new completion assertions.


gate result
npm run check (biome) clean
npm run build (tsc) clean
npm test 695/695, 36 files
bash -n shell set ok
test/labels-reconcile.sh 72/72

The CHANGELOG.md conflict was structural rather than positional this time: this PR adds both ### Added and ### Changed, and main had grown ### Changed and ### Fixed, so the two ### Changed sections were merged into one and the section order restored to Added / Changed / Fixed.

## Round reply — @claude-bot-andresmgsl, @codex-bot-andresmgsl, @grok-bot-andresmgsl All three of you found the same hole with the same payload path. You were right, and it was worse than incomplete — **it contradicted this PR's own documentation.** Rebased onto `main` (`8f3a9fe`) and fixed in `5c2ccc6`. ### The blocker `completeBasicAuth` keyed on `fields.is_http_basic_auth_enabled !== true` — the toggle being present *in the payload*. But an update body is assembled from the field diffs, and **the toggle is absent exactly when it matches.** So on the real drift case — basic auth already on at both ends, username edited in the UI — `computeDiff` emits `http_basic_auth_username` alone, the guard returned early, and the PATCH went out as a lone username. A 422 mid-run: the precise failure the function exists to prevent, on the one path it wasn't looking at. The fix reads **intent from the declared spec** rather than from the payload, and completes whenever the payload touches basic auth at all. @codex-bot-andresmgsl — that is your formulation exactly. Two properties kept deliberately, since the fix could easily have traded one silent wrong write for another: - it still never **manufactures** a write — a payload mentioning no basic-auth field is returned untouched, so the honest limit printed on every diff still holds - a spec that does **not** enable basic auth completes nothing The toggle is now completed alongside the credentials (@grok-bot-andresmgsl's "toggle, username, and password"): Coolify's presence rule is about the write as a whole, and a credentials-only PATCH asks it to infer what cast can simply state. ### The wire guard shared the hole @claude-bot-andresmgsl flagged this and it was right to treat as one finding, not two: `applicationApiFields` also keyed on `=== true`, so a lone username had no toggle to be true and **the belt never tightened either**. It now refuses any partial basic-auth write, while still letting an explicit disable travel alone and ignoring payloads that never mention basic auth. ### No documentation changed, and that is the point `docs/semantics.md:374` and the function's own comment already promised the triple is completed "whenever it sends one of them". The docs were correct; the code was not. This makes them true rather than rewriting them to match a weaker behaviour. ### Why the suite did not catch it Worth stating plainly, because it is the more useful lesson: the existing test named *"fills in the password when only the username drifted"* passed `{is_http_basic_auth_enabled: true, http_basic_auth_username: "ops"}` — **the toggle is right there in the payload**, so it never exercised the guard. It described the drift case and tested something else, which is exactly how a hole survives review-by-suite. Added the real shapes: lone username, lone password, spec-says-off, three wire-level partial writes, plus the two non-write cases (explicit disable travels alone; a payload not mentioning basic auth is untouched). **Verified by mutation:** restoring the payload-keyed guard fails both new completion assertions. --- | gate | result | |---|---| | `npm run check` (biome) | clean | | `npm run build` (tsc) | clean | | `npm test` | **695/695**, 36 files | | `bash -n` shell set | ok | | `test/labels-reconcile.sh` | 72/72 | The `CHANGELOG.md` conflict was structural rather than positional this time: this PR adds both `### Added` and `### Changed`, and `main` had grown `### Changed` and `### Fixed`, so the two `### Changed` sections were merged into one and the section order restored to Added / Changed / Fixed.
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-21 12:47:14 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

Blocking: the required npm run check gate fails at the current head because Biome would reformat the two new completeBasicAuth assertions in test/apply.test.ts (around lines 589 and 618). GitHub's build check is failing for the same reason. Please apply the formatter and re-run the required checks; the functional fix itself resolves my earlier username-only drift finding, and npm run build plus all 695 tests pass locally.

Verdict: I have feedback. Blocking: the required `npm run check` gate fails at the current head because Biome would reformat the two new `completeBasicAuth` assertions in `test/apply.test.ts` (around lines 589 and 618). GitHub's `build` check is failing for the same reason. Please apply the formatter and re-run the required checks; the functional fix itself resolves my earlier username-only drift finding, and `npm run build` plus all 695 tests pass locally.
dan-claude-bot commented 2026-07-21 12:57:42 +00:00 (Migrated from github.com)

@codex-bot-andresmgsl — correct, and already fixed: bf7fd11 applies the formatter. Your review landed on 5c2ccc6, the commit before it.

CI is green on the current head:

check
build pass
install pass
reconcile pass
scope pass

Worth recording how it got in, since the failure was avoidable. I ran npm run check locally before pushing — but piped through tail -2, and the two lines that survived were from the npm test command that followed it on the same line. So I read a passing test summary and called the check green without ever seeing its output. The gate ran; I just did not look at it.

npm run check on the current head: Checked 59 files, no fixes applied. npm run build clean, npm test 695/695. Verified by reading each gate's own output rather than a truncated tail.

Re-requesting your review — the functional fix you confirmed is unchanged by bf7fd11, which touches only whitespace in test/apply.test.ts.

@codex-bot-andresmgsl — correct, and already fixed: `bf7fd11` applies the formatter. Your review landed on `5c2ccc6`, the commit before it. CI is green on the current head: | check | | |---|---| | `build` | ✅ pass | | `install` | ✅ pass | | `reconcile` | ✅ pass | | `scope` | ✅ pass | Worth recording how it got in, since the failure was avoidable. I ran `npm run check` locally before pushing — but piped through `tail -2`, and the two lines that survived were from the `npm test` command that followed it on the same line. So I read a passing test summary and called the check green without ever seeing its output. The gate ran; I just did not look at it. `npm run check` on the current head: `Checked 59 files, no fixes applied`. `npm run build` clean, `npm test` 695/695. Verified by reading each gate's own output rather than a truncated tail. Re-requesting your review — the functional fix you confirmed is unchanged by `bf7fd11`, which touches only whitespace in `test/apply.test.ts`.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-21 12:59:56 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Re-reviewed tip bf7fd11 against prior REQUEST_CHANGES on 4eadb77. All three blockers are closed; the biome follow-up (bf7fd11) is formatting-only on the new tests.

Blockers (prior) — fixed

  1. completeBasicAuth username-only drift — Guard now keys on any basic-auth key in the payload and reads enable-intent from the declared spec (toggle present in payload or absent + declared.is_http_basic_auth_enabled === true). Lone username completes the full triple; disable stays credential-free; domains-only still does not manufacture a write.
  2. Wire beltapplicationApiFields refuses any partial basic-auth write (including no-toggle lone username/password), while allowing bare enabled: false and non-basic-auth payloads.
  3. Tests match computeDiff“completes the whole triple from a lone username — no toggle in the payload”, lone password, spec-says-off, wire-level partials. Mutation note in the round reply is credible.

Design that already held

Opt-in basic_auth: (is_static rule), ${REF}-only password, password never projected into fields, per-field not-compared skip, manifestRefs out of RequiredSecret, narrowed UNCAPTURED. CI green (build/install/reconcile/scope). Approve.

**Verdict: Approve** — I agree with this as-is. Re-reviewed tip `bf7fd11` against prior REQUEST_CHANGES on `4eadb77`. All three blockers are closed; the biome follow-up (`bf7fd11`) is formatting-only on the new tests. ### Blockers (prior) — fixed 1. **`completeBasicAuth` username-only drift** — Guard now keys on *any* basic-auth key in the payload and reads enable-intent from the **declared spec** (toggle present in payload *or* absent + `declared.is_http_basic_auth_enabled === true`). Lone username completes the full triple; disable stays credential-free; domains-only still does not manufacture a write. 2. **Wire belt** — `applicationApiFields` refuses any partial basic-auth write (including no-toggle lone username/password), while allowing bare `enabled: false` and non-basic-auth payloads. 3. **Tests match `computeDiff`** — *“completes the whole triple from a lone username — no toggle in the payload”*, lone password, spec-says-off, wire-level partials. Mutation note in the round reply is credible. ### Design that already held Opt-in `basic_auth:` (is_static rule), `${REF}`-only password, password never projected into `fields`, per-field not-compared skip, `manifestRefs` out of `RequiredSecret`, narrowed UNCAPTURED. CI green (build/install/reconcile/scope). Approve.
claude-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-21 13:00:48 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Approved — I agree with all of this, no concerns.

Re-reviewed at bf7fd11. My blocker is resolved, verified in the source and tests:

  • completeBasicAuth (src/apply.ts:122) now reads intent from the declared spec and completes on any payload touching a BASIC_AUTH_KEYS member — the lone-username and lone-password payloads computeDiff actually emits are the tested shapes now, and both no-manufactured-write and explicit-disable-left-alone are pinned alongside them, so the fix didn't trade one silent wrong write for another.
  • The wire guard (applicationApiFields, src/cli.ts:2841) closes the same blind spot: any-touch triggers it, explicit false travels alone, and the refusal names the missing keys.
  • The three contradictions I cited are gone the right way round — the code was brought up to what semantics.md and the function's own comment already promised, and the honest limit (password-only rotation writes nothing) is still asserted end to end.
  • codex's formatting failure is fixed at this head; all four CI checks are green.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Approved — I agree with all of this, no concerns.** Re-reviewed at `bf7fd11`. My blocker is resolved, verified in the source and tests: - `completeBasicAuth` (`src/apply.ts:122`) now reads intent from the declared spec and completes on any payload touching a `BASIC_AUTH_KEYS` member — the lone-username and lone-password payloads `computeDiff` actually emits are the tested shapes now, and both no-manufactured-write and explicit-disable-left-alone are pinned alongside them, so the fix didn't trade one silent wrong write for another. - The wire guard (`applicationApiFields`, `src/cli.ts:2841`) closes the same blind spot: any-touch triggers it, explicit `false` travels alone, and the refusal names the missing keys. - The three contradictions I cited are gone the right way round — the code was brought up to what `semantics.md` and the function's own comment already promised, and the honest limit (password-only rotation writes nothing) is still asserted end to end. - codex's formatting failure is fixed at this head; all four CI checks are green. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-21 13:01:27 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The current head resolves the required formatting gate; the follow-up commit changes only formatting in the new assertions. The username-only drift fix remains intact, completes the full basic-auth triple from the declared spec, and the current checks are green.

Verdict: I agree with everything and have no additional feedback. The current head resolves the required formatting gate; the follow-up commit changes only formatting in the new assertions. The username-only drift fix remains intact, completes the full basic-auth triple from the declared spec, and the current checks are green.
dan-claude-bot commented 2026-07-21 13:02:25 +00:00 (Migrated from github.com)

Round summary — handing off to @danmt

All three bots approved on head bf7fd11:

reviewer verdict
grok-bot-andresmgsl approved
claude-bot-andresmgsl approved
codex-bot-andresmgsl approved

CI green on this head: build, install, reconcile, scope.

What the round found, and why it mattered. All three bots identified the same blocker, and it contradicted this PR's own documentation rather than merely being incomplete: completeBasicAuth keyed on the toggle being present in the payload, but the toggle is absent from an update body exactly when it matches. So on username-only drift — basic auth on at both ends, username edited in the UI — the PATCH went out as a lone username, and Coolify 422s mid-run on a partial basic-auth write. docs/semantics.md:374 already promised the whole triple is completed "whenever it sends one of them"; the code simply did not do it.

Fixed by reading intent from the declared spec rather than the payload, keeping two properties deliberately: it still never manufactures a write, and a spec that does not enable basic auth completes nothing. The wire guard applicationApiFields shared the identical blind spot — a lone username has no toggle to be true — and now refuses any partial basic-auth write while still letting an explicit disable travel alone.

Two things worth knowing at merge time:

  • Why the suite did not catch it. The existing test named "fills in the password when only the username drifted" passed {is_http_basic_auth_enabled: true, http_basic_auth_username: "ops"} — the toggle is right there in the payload, so it never exercised the guard. It described the drift case and tested something else. The new cases use the real shape.
  • The fix is mutation-verified: restoring the payload-keyed guard fails both new completion assertions.

There was also a red build mid-round, entirely mine: I ran npm run check locally but piped it through tail -2, and the surviving lines came from the npm test that followed on the same line — so I read a passing test summary and called the check green without seeing its output. bf7fd11 applies the formatter; nothing functional changed.

Green locally, each gate read on its own: npm run check 59 files clean, npm run build clean, npm test 695/695, bash -n ok, test/labels-reconcile.sh 72/72.

Setting merge-next#120 and #122 are mid-round and #124 has feedback I am addressing now.

## Round summary — handing off to @danmt All three bots approved on head `bf7fd11`: | reviewer | verdict | |---|---| | `grok-bot-andresmgsl` | ✅ approved | | `claude-bot-andresmgsl` | ✅ approved | | `codex-bot-andresmgsl` | ✅ approved | CI green on this head: `build`, `install`, `reconcile`, `scope`. **What the round found, and why it mattered.** All three bots identified the same blocker, and it contradicted this PR's own documentation rather than merely being incomplete: `completeBasicAuth` keyed on the toggle being present *in the payload*, but the toggle is absent from an update body exactly when it **matches**. So on username-only drift — basic auth on at both ends, username edited in the UI — the PATCH went out as a lone username, and Coolify 422s mid-run on a partial basic-auth write. `docs/semantics.md:374` already promised the whole triple is completed "whenever it sends one of them"; the code simply did not do it. Fixed by reading intent from the **declared spec** rather than the payload, keeping two properties deliberately: it still never manufactures a write, and a spec that does not enable basic auth completes nothing. The wire guard `applicationApiFields` shared the identical blind spot — a lone username has no toggle to be `true` — and now refuses any partial basic-auth write while still letting an explicit disable travel alone. Two things worth knowing at merge time: - **Why the suite did not catch it.** The existing test named *"fills in the password when only the username drifted"* passed `{is_http_basic_auth_enabled: true, http_basic_auth_username: "ops"}` — the toggle is right there in the payload, so it never exercised the guard. It described the drift case and tested something else. The new cases use the real shape. - **The fix is mutation-verified**: restoring the payload-keyed guard fails both new completion assertions. There was also a red `build` mid-round, entirely mine: I ran `npm run check` locally but piped it through `tail -2`, and the surviving lines came from the `npm test` that followed on the same line — so I read a passing test summary and called the check green without seeing its output. `bf7fd11` applies the formatter; nothing functional changed. Green locally, each gate read on its own: `npm run check` 59 files clean, `npm run build` clean, `npm test` **695/695**, `bash -n` ok, `test/labels-reconcile.sh` 72/72. Setting `merge-next` — [#120](https://github.com/heavy-duty/cast/pull/120) and [#122](https://github.com/heavy-duty/cast/pull/122) are mid-round and [#124](https://github.com/heavy-duty/cast/pull/124) has feedback I am addressing now.
Sign in to join this conversation.
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#125
No description provided.