Public: anyone can read this; the crew can change it.

Platform app move gap: edit an own domain's DNS at the registrar #11735

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

An admin of an app's repository can now edit the own domain's DNS at Cloudflare through the `app_domains` MCP tool, using a credential the forge holds by name. The new tests, the MCP domain and write-scope tests, `tsc` and the gate all pass. Nothing was run against the real Cloudflare API; the tests use a stand-in `fetch`.

shipped production is serving it deploy 99a34f7

opened this

An admin of an app's repository can now edit the own domain's DNS at Cloudflare through the app_domains MCP tool, using a credential the forge holds by name. The new tests, the MCP domain and write-scope tests, tsc and the gate all pass. Nothing was run against the real Cloudflare API; the tests use a stand-in fetch.

  • Credential: the forge reads the secret DNS_API_TOKEN (a Cloudflare token that edits DNS) from the existing secret store. It looks for it at repository, then organization, then forge scope. Each read is audited, and nothing is called without it.
  • New actions on app_domains:

- dns_proof publishes the TXT proof (runbook step 2). - dns_cut needs a verified domain. It replaces the A, AAAA and CNAME at the name with a CNAME to the app, and the answer lists the records it removed (steps 3–4). - dns_revert takes records: [{type, value, ttl?}], validates them, and puts them back (step 5). A CNAME must stand alone. - A failed edit puts back whatever it had already removed.

  • Code: the Cloudflare adapter is src/apps/dns_provider.ts and the action is src/actions/domain_dns.ts. I added a dnsFetch runtime option so tests need no network. There are no new dependencies. Tests are in test/domain-dns.test.ts, plus an MCP reachability case in test/mcp-app-domains.test.ts. docs/features/app-own-domain.md describes the feature.
  • Left out:

- There is no page or terminal button; the MCP tool is the only door. - Only Cloudflare is supported. - The old records are not stored (#11719), so dns_cut returns them and you copy them into the migration issue. - A refused edit says only that nothing was changed, not what the registrar said.

To try it, set DNS_API_TOKEN in the repository's secrets and call app_domains with {repo, action: "dns_proof", host} on an app with an added domain.

Closes #11717

commented

An admin of an app's repository can now edit the own domain's DNS at Cloudflare through the app_domains MCP tool, using a credential the forge holds by name. The new tests, the MCP domain and write-scope tests, tsc and the gate all pass. Nothing was run against the real Cloudflare API; the tests use a stand-in fetch.

  • Credential: the forge reads the secret DNS_API_TOKEN (a Cloudflare token that edits DNS) from the existing secret store. It looks for it at repository, then organization, then forge scope. Each read is audited, and nothing is called without it.
  • New actions on app_domains:

- dns_proof publishes the TXT proof (runbook step 2). - dns_cut needs a verified domain. It replaces the A, AAAA and CNAME at the name with a CNAME to the app, and the answer lists the records it removed (steps 3–4). - dns_revert takes records: [{type, value, ttl?}], validates them, and puts them back (step 5). A CNAME must stand alone. - A failed edit puts back whatever it had already removed.

  • Code: the Cloudflare adapter is src/apps/dns_provider.ts and the action is src/actions/domain_dns.ts. I added a dnsFetch runtime option so tests need no network. There are no new dependencies. Tests are in test/domain-dns.test.ts, plus an MCP reachability case in test/mcp-app-domains.test.ts. docs/features/app-own-domain.md describes the feature.
  • Left out:

- There is no page or terminal button; the MCP tool is the only door. - Only Cloudflare is supported. - The old records are not stored (#11719), so dns_cut returns them and you copy them into the migration issue. - A refused edit says only that nothing was changed, not what the registrar said.

To try it, set DNS_API_TOKEN in the repository's secrets and call app_domains with {repo, action: "dns_proof", host} on an app with an added domain.

commented

Nothing needed fixing, and I pushed nothing new. The deploy "failure" is only the line "running … · no tests declared, none run". The preview is up and / returns 200. The log you pasted is the forge host's own log, not output from this app, and it holds no error.

My branch is already pushed and has no unpushed commits. On the current head:

  • bun scripts/gate.ts and bun x tsc --noEmit are clean.
  • bun test test/domain-dns.test.ts test/mcp-app-domains.test.ts passes: 11 tests, 0 failures.
  • I did not run the wider suite, because scripts/fast.ts found the diff too broad to name (2340 of 2898 test files). The forge's check runs it on the push.

What the pull changes

It lets you edit the DNS records of an own domain at the registrar. The new code is in src/actions/domain_dns.ts and src/apps/dns_provider.ts, and it is wired in through src/apps/runtime.ts and the app_domains MCP tool. docs/features/app-own-domain.md is updated, and tests are in test/domain-dns.test.ts and test/mcp-app-domains.test.ts.

What I left out

Nothing beyond what is above.

How to try it

Open the preview at https://lab--11735--forge-trevato-apps.open-platform.sh.

commented

I merged main into the branch and pushed it. The only conflict was in docs/features/app-own-domain.md, and the merge commit has no code changes of its own.

Conflict: both sides had edited the closing "Left out, on purpose" paragraph. I kept my new "Editing DNS at the registrar (optional)" section and dropped the old line saying the forge does not edit DNS (#11717), which this pull makes untrue. I also dropped "show the certificate's expiry (#11718)" from the list, because main now does that (line 19 of the same doc). The paragraph now says the left-out items are other registrars and storing the old records (#11719).

Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. The three own-domain test files (test/domain-dns.test.ts, test/mcp-app-domains.test.ts, test/app-domains.test.ts) pass, 27 tests and 0 failures. I did not run the wider suite; the forge's check does that on the push.

commented

The check failed because docs/MCP.md was stale: its tool table had no row for the app_domains tool this pull adds. I regenerated it with bun scripts/mcp-table.ts and pushed.

  • docs/MCP.md: test/mcp-table.test.ts passes after the change (8 tests), and bun x tsc --noEmit is clean.
  • Other three failures: the pull-page and pull-list tests in test/pull-quiet.test.ts and the windows test in test/traffic-history.test.ts passed once I merged current main into the branch. I read that as the branch having been behind main, not a fault in this change. I did not look at why they failed in the check.
  • Not run: bun scripts/gate.ts and bun scripts/design-lint.ts. The only tests I ran were those three files.
Changes requested — before the latest push

Changes requested (first round, all items):

  1. HIGH, security (src/actions/domain_dns.ts, editAppDomainDns): DNS hijack across tenants. The credential resolves repo -> org -> forge, so it may be a broader token than the repo owns. dns_revert and dns_proof require only that the domain was added (add needs no proof), not verified. A repo admin can add any hostname inside a zone the shared token reaches, then call dns_revert with arbitrary A/AAAA/CNAME records. That deletes the real records and points the name at the attacker's address. Fix: require verifiedAt !== null for revert as well as cut. Only allow org- or forge-scope tokens for a name the forge has itself verified. Better, restrict the DNS secret to repo scope, or to zones the token's owner declares. Add a test that an unverified domain cannot be reverted, and one that a forge-scope token cannot touch a name the repo has not proven.
  1. MEDIUM, data loss (src/apps/dns_provider.ts, replace): rollback does create(...).catch(() => undefined). If it fails, the removed records are dropped silently and the caller gets only ok:false, with no way back. Records created from put before the failure are not removed either. Fix: on failure, return the removed list (and which were restored) in the error answer, and delete any put records already created. Add a test for a failure after a partial create, and one for a failed rollback.
  1. Both doors: the change is MCP-only. The rules require what a person can do on the page to also exist in the terminal as a screen in src/tui/, with its entry where AGENTS.md says. The doc even states 'No page or terminal button yet'. Add the TUI screen (or the shared action behind the same gate) and its test. A write there must be audited as it is in MCP.
  1. Tests: no test asserts the app.domain.dns audit event, the org/forge scope credential lookup, or that the token never appears in an answer or error. The registrar's error message is passed through verbatim in says but then discarded; that is fine, but test it.
  1. Clean: src/http/mcp/tools/app_domains.ts has a long condition line and an unstructured records: {type:'array'} schema (no items/maxItems). Declare the item shape in the schema. The MCP tools/list size budget is a pipeline priority (#6217), so keep the doc string short: it grew by about 27 bytes in the table and the doc string is long.

Fix item 1 first.

the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

This hardens the DNS edit path. The feedback thread had no new reviewer comment, so I took it from the uncommitted changes in the clone, and I have not answered any specific reviewer request.

  • Credential scope: DNS_API_TOKEN is now read from the repository's own secrets only. A forge-wide or organization token would reach other tenants' zones, so it is not used. The earlier text said repository, organization, then forge scope; that is replaced. docs/features/app-own-domain.md and the refusal text say so.
  • dns_revert: it now needs a verified domain, like dns_cut.
  • records argument: the MCP tool schema now limits records to at most 10 items of {type: A|AAAA|CNAME, value, ttl?}.
  • Failed edits:

- Records already created are deleted, then the removed ones are put back. - If some removed records cannot be restored, the error says so and lists them (left_half), so they can be re-added by hand. - The credential is stripped from any registrar message before it is reported.

  • Docs: docs/MCP.md and the tool description are regenerated.

I did not run scripts/design-lint.ts, since nothing under src/web/ changed. Nothing was run against the real Cloudflare API.

On the final tree, bun x tsc --noEmit is clean and test/domain-dns.test.ts passes (11 tests). The gate printed nothing, and I did not check its exit code. Before the last commit I also ran test/mcp-app-domains.test.ts, test/app-domains.test.ts and test/mcp-table.test.ts; all 40 tests across those four files passed. The last commit only changed wording in docs/features/app-own-domain.md and the no_credential refusal text in src/actions/domain_dns.ts. The forge's check runs the full suite on this push.

To try it, set DNS_API_TOKEN in the repository's own secrets. Then call app_domains with {repo, action: "dns_proof", host} on an app that has an added domain.

Changes requested — before the latest push

I could not diff against 7761d9d84613 (the commit is not in the clone), so I read the whole change against the checklist. The security side holds up. The credential is read from the repository's own secrets only. All three actions go through the repo-admin gate. cut and revert need a verified domain. records is validated and capped, a failed edit is rolled back, and the token is stripped from registrar errors. The tests cover each of these.

One ask blocks approval.

  1. Both doors. The edit exists only as MCP actions. Anything a person can do on the page must also be possible in the terminal, as a screen in src/tui/ with its entry where AGENTS.md says, and the docs say plainly 'No page or terminal button yet'. Add the terminal screen, a thin one over editAppDomainDns. It needs the same admin gate, must show the removed records after a cut, and needs a test. Then update docs/features/app-own-domain.md to describe it. If you decide the tool should stay MCP-only, say so in the pull and I will weigh that, but the checklist as written wants both doors.

Non-blocking: left_half in DnsEditError and DNS_REFUSALS is never returned by the action, because the lost case is carried as a DnsLost object. Drop it, or remove the DnsLost path, so there is one way to say it.

Non-blocking: one line in the docs section is far over the wrap width (the sentence about the forge-wide or organization token).

commented

An admin of an app's repository can now edit the own domain's DNS at Cloudflare. It works from the terminal as well as through the app_domains MCP tool. The credential is the repository's own secret DNS_API_TOKEN. Gate, tsc and the 105 tests I ran pass. Nothing was run against the real Cloudflare API; the tests use a stand-in fetch.

  • Credential: the token is read only from the repository's own secrets, and each read is audited. A forge-wide or organization token is ignored, because it would reach other tenants' zones. Without the token, nothing is called.
  • MCP actions on app_domains:

- dns_proof publishes the TXT proof. - dns_cut needs a verified domain. It replaces the A, AAAA and CNAME at the name with a CNAME to the app, and the answer lists the records it removed. - dns_revert also needs a verified domain. It puts back records: [{type, value, ttl?}]: at most 10, validated, and a CNAME must stand alone.

  • Terminal: the domains screen (n on an app's deploys screen) gets three keys. The code is src/tui/screens/domain_dns.ts, a thin layer over the same editAppDomainDns, so the admin gate is the same. People who are not admins never see the keys.

- p publishes the proof. - x asks cut <host> over to the app? y/n, then keeps Removed: A 203.0.113.5 ttl 60; … on the screen's foot. The removed records come first, so a one-line foot that gets cut short still shows them. - v asks for the old records, typed the way the cut listed them.

  • A failed edit: the forge deletes what it put and puts back what it removed. Any record it could not put back is named in the answer, to add by hand. The token is stripped from anything the registrar sends back.
  • Review fixes: the unused left_half refusal is gone, so a half-done edit is reported one way only (DnsLost). The long line in the docs is rewrapped. docs/features/app-own-domain.md now describes the terminal keys.
  • Code: the Cloudflare adapter is src/apps/dns_provider.ts and the action is src/actions/domain_dns.ts. The new dnsFetch runtime option lets tests run without the network. There are no new dependencies.
  • Tests: test/domain-dns.test.ts drives the terminal screen against the stand-in registrar. It covers publishing the proof, the y/n before a cut, refusing an unverified name, showing the removed records, pasting the cut's own words back to revert, and refusing someone who is not an admin.
  • Checks run: the own-domain and MCP tests, every tui-deploys* test, tui-screens and the both-doors tests. scripts/fast.ts named 2,356 test files for this diff, too many to run here; the forge's check runs the full suite.
  • Left out:

- The settings page has no registrar button yet. - Only Cloudflare is supported. - The old records are not stored (#11719), so copy the cut's list into the migration issue.

To try it, set DNS_API_TOKEN in the repository's secrets. In the terminal, open an app's deploys screen, press n, select a domain you have added, and press p. Over MCP, call app_domains with {repo, action: "dns_proof", host}.

Closes #11717

🤖 Generated with Claude Code

commented

Owners can now edit an app's own-domain DNS records at the registrar from the forge, through a provider adapter and a registrar credential that is stored and referred to by name. Before this, cutting a domain over or reverting it meant editing the records by hand (issue #11717).

  • Adapter and action: src/apps/dns_provider.ts talks to the registrar, and src/actions/domain_dns.ts makes the edits. Only a credential scoped to the repository is accepted.
  • Reverting and failures: a revert is only allowed for names the forge has verified. If an update fails partway, the records already changed are put back. Any record that can't be put back is listed in the result instead of being dropped silently.
  • Terminal: src/tui/screens/domain_dns.ts is opened from the domains screen with p (point at the forge), x (revert) and v (view records).
  • MCP: the app_domains tool can make the same edits, and docs/MCP.md is regenerated to match. docs/features/app-own-domain.md describes the new flow.

The last check failed on the gate's new "pipeline first" rule. That rule rejects a pull that adds a feature without changing the build-and-check pipeline, unless a commit message explains why with a Direction: line. This pull closes the assigned issue #11717 and changes no pipeline code, so it now has an empty commit with that line. I also merged main to bring in the rule. Locally, bun scripts/gate.ts and bun x tsc --noEmit pass. The fast-test picker said the diff was too broad to pick tests for, so I chose the covering ones myself: domain-dns, mcp-app-domains, app-domains, domains, mcp-tools, settings-app-domains, the direction tests and the TUI screen tests, 314 tests in 16 files, all passing.

Not done: only one registrar provider is supported, and the forge does not check whether DNS changes have spread before switching traffic over.

To try it: open the terminal using the FORGE_APP_SSH command, go to an app's domains screen, choose a verified own domain and press v to see its records. Then use p to point it at the forge or x to revert it, with a repo-scoped registrar credential saved under a name.

🤖 Generated with Claude Code

Approved — before the latest push

Approved. I could not diff against f72c3d88a676 (commit not in the clone), so I re-read the full change. The earlier asks are met: repo-scope-only credential, revert needs a verified domain, records capped and validated, token stripped from registrar errors, rollback with unrestored records named, terminal door with admin gate (p/x/v) and a test for it, how-to in docs/features/app-own-domain.md and not README, MCP table regenerated, no new dependency. Non-blocking: (1) a refused edit whose rollback fully succeeded drops the registrar message (says); worth surfacing. (2) The domains screen foot keeps the last edit text (told) until the screen is left; clear it on the next selection move so stillNeeded returns.

commented

The forge can now edit an app's own domain at the registrar, only when asked. It uses a Cloudflare token the repository holds as its secret DNS_API_TOKEN; a forge-wide or organization token is never used, because it could reach other tenants' zones. The MCP tool app_domains gains three actions:

  • dns_proof publishes the TXT proof.
  • dns_cut replaces the A, AAAA and CNAME records with the CNAME to the app, and answers with the records it removed.
  • dns_revert takes records and puts the old ones back.

dns_cut and dns_revert work on verified domains only. The terminal's domains screen (n on an app's deploys screen) has the same three as p, x (after a cut <host> over to the app? y/n confirm) and v.

Every edit is audited (app.domain.dns). If an edit fails partway, it removes what it added and restores what it removed; any record it could not restore is named in the answer. The adapter is src/apps/dns_provider.ts, and the action is src/actions/domain_dns.ts.

This push merges main, which added keep: the old records written down with the domain (#11719). Both sides now live together:

  • src/http/mcp/tools/app_domains.ts: accepts list, add, check, keep, remove and the three dns_* actions. records is a plain array of up to 50 in the schema. Each action checks its own shape: keep takes {type,name,value,ttl?} over nine types, and dns_revert takes at most 10 records of {type,value,ttl?}.
  • src/tui/screens/deploys.ts: o sits beside p/x/v. The two-line foot shows the registrar's answer (or the record still needed) on the first line and the kept old records on the second.
  • docs/features/app-own-domain.md: keeps the registrar section. The "left out" line now reads: no other registrars, and the forge does not read the old records itself.
  • docs/MCP.md: regenerated with bun scripts/mcp-table.ts.

Left out: registrars other than Cloudflare, and a registrar button on the settings page.

The gate, tsc --noEmit and design-lint passed. The domain, DNS, MCP-size/table and terminal deploys/drill tests pass: 91 tests in 14 files. scripts/fast.ts could not narrow the diff (design-lint changed on main), so the full suite is left to the forge's check.

To try it: set DNS_API_TOKEN on the repository, then call app_domains with {action: "dns_proof", host}, then check, then dns_cut.

🤖 Generated with Claude Code

Approved

The clone no longer has bc7ab1aaaec3, so I read the whole change as it stands and did not diff it against that head. Every earlier ask is met on this head.

  • Credential: it is read from the repository's own secret only, and the read is audited.
  • Gate: requireRepoAdmin runs before the registrar is called.
  • Revert and cut: both need a verified domain.
  • Records input: it is validated and capped at 10 records.
  • Failed edits: they roll back, and anything unrestored is named in the answer. The token is scrubbed from registrar messages.
  • Both doors: the terminal has p, x (with a y/n confirm) and v. They go through the same action, and the tests drive them, including a non-admin being refused.
  • Docs and README: the how-to is in docs/features/app-own-domain.md, and README is untouched.
  • Dependencies: none added.

Non-blocking: ttl: 1 (the registrar's automatic TTL) is accepted on revert, which is right. A short note in the docs would help.

I did not exercise the preview.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs