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

scout/forge:scout/11717-platform-app-move-gap-edit-an-own-domain → main 9 commits fork 1 thread · 1 unresolved

  • Merged. scout/11717-platform-app-move-gap-edit-an-own-domain was merged into main as 2bd7942.

Review

Sign in to review this change.
10 files changed +782 −14
docs/MCP.md +1 −1
@@ -52,7 +52,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
5252 | `agents` | — | Agents you may see, as GET /api/v1/agents | 160 |
5353 | `app_data_import` | — | Load a SQL dump into an app's database and report per-table counts and checksums; admins only | 339 |
5454 | `app_deploy` | `redeploy`, `stop`, `pull`, `schedules`, `run_schedule`, `pause_schedule`, `resume_schedule`, `jobs`, `cancel_job`, `retry_job` | Redeploy or stop an env, pull, list and steer schedules and jobs | 408 |
55 | `app_domains` | — | App domains: list, add, check, keep, remove | 234 |
55 | `app_domains` | — | App domains: list, add, check, keep, remove; dns_proof, dns_cut, dns_revert | 266 |
5656 | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 |
5757 | `app_members` | — | People a private app names, who may enter it and its previews | 297 |
5858 | `app_notes` | `hide`, `ban`, `unban` | Notes from an app's forge button and banned senders; hide a note, ban or unban its sender | 306 |
docs/features/app-own-domain.md +31 −4
@@ -2,7 +2,7 @@
22
33 The step of a move that can strand a
44 shop (`platform-app-move.md`). The forge serves a verified name
5 and its proxy fetches the certificate; it never writes DNS, so the revert is
5 and its proxy fetches the certificate; it writes DNS only when asked (below), so the revert is
66 the owner's records, put back by hand in one edit. In order:
77
88 1. **Write the old records down first.** Every record at the name (A, AAAA,
@@ -33,9 +33,36 @@ the owner's records, put back by hand in one edit. In order:
3333 (`remove`); removing it is only tidiness once DNS no longer points here,
3434 and the old host's certificate was never touched.
3535
36 Left out, on purpose: the forge does not edit DNS at a registrar (#11717),
37 or read the old records itself: it keeps only
38 what you wrote down (#11719). This closes #11700 as a runbook over a feature that already ships,
36 ## Editing DNS at the registrar (optional)
37
38 The forge edits DNS only when asked, with a credential you hand it by name:
39 set the secret `DNS_API_TOKEN` (a Cloudflare API token that edits DNS in the
40 zone) for the repository itself. A forge-wide or organization token is not
41 used: it would reach other tenants' zones. Then, as an admin of the
42 repository, the MCP tool `app_domains` takes three more actions:
43 - `dns_proof` publishes the TXT proof (step 2);
44 - `dns_cut` (verified domains only) replaces the A, AAAA and CNAME at the name
45 with the CNAME to the app (step 4) and **answers the records it removed**:
46 paste them in the migration issue, they are the way back;
47 - `dns_revert` (verified domains only) with `records: [{type, value, ttl?}]`
48 puts your step-1 list back (step 5).
49
50 The terminal has the same three, on the domains screen (`n` on an app's
51 deploys screen), for the same admins:
52 - `p` publishes the TXT proof of the selected domain;
53 - `x` asks `cut <host> over to the app? y/n`, then cuts it over and keeps
54 `Removed: A 203.0.113.5 ttl 60; …` on the screen's foot;
55 - `v` asks for the old records and puts them back; type them as the cut
56 listed them (`A 203.0.113.5 ttl 60; AAAA 2001:db8::1`).
57
58 Every edit is audited (`app.domain.dns`, and the secret read). A failed edit
59 deletes what it put and puts back what it removed; any record it could not
60 put back is named in the answer, to add by hand. Only Cloudflare is
61 supported; the adapter is `src/apps/dns_provider.ts`. The settings page has
62 no registrar button yet.
63
64 Left out, on purpose: other registrars, or read the old records itself: it
65 keeps only what you wrote down (#11719). This closes #11700 as a runbook over a feature that already ships,
3966 not as new code.
4067
4168 ## What keeps these claims true
src/actions/domain_dns.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * Edit an app's own domain at the registrar: publish the TXT proof, cut the
3 * name over to the app, or put the owner's old records back. The credential
4 * is a secret held by name (`DNS_API_TOKEN`, the repository's own) and opened
5 * through `resolveScoped`, which audits the read. Admin of the repository,
6 * like every domain action. Cut and revert replace what is at the name, so
7 * both say what they removed: the way back, in the answer.
8 */
9
10 import type { App } from "../app.ts";
11 import {
12 cloudflare,
13 type DnsProvider,
14 type EditableType,
15 type ZoneRecord,
16 } from "../apps/dns_provider.ts";
17 import { verifyName, verifyValue } from "../apps/domains.ts";
18 import { appsRuntime } from "../apps/runtime.ts";
19 import { getAppByRepo } from "../core/apps.ts";
20 import { getDomain, normalizeHost } from "../core/domains.ts";
21 import { resolveScoped } from "../core/secret_store.ts";
22 import type { Repo } from "../core/repos.ts";
23 import type { User } from "../core/users.ts";
24 import { err, ok, type Result } from "../kernel/result.ts";
25 import { requireRepoAdmin } from "./access.ts";
26
27 export const DNS_SECRET = "DNS_API_TOKEN";
28 export type DnsStep = "proof" | "cut" | "revert";
29 export const DNS_STEPS: readonly DnsStep[] = ["proof", "cut", "revert"];
30
31 /** An edit that failed and could not put every removed record back. */
32 export interface DnsLost {
33 readonly says: string;
34 readonly unrestored: readonly ZoneRecord[];
35 }
36
37 export type DnsEditError =
38 | "not_found"

Non-blocking: left_half is dead; the half-done case is the DnsLost object.

39 | "no_runtime"
40 | "no_such_domain"
41 | "no_credential"
42 | "not_verified"
43 | "invalid_records"
44 | "refused";
45
46 export interface DnsEdit {
47 readonly says: string;
48 readonly removed: readonly ZoneRecord[];
49 readonly put: readonly ZoneRecord[];
50 }
51
52 const MAX_RECORDS = 10;
53 const TTL_DEFAULT = 300;
54
55 /** The owner's step-1 list, validated: address records only, at the domain. */
56 export function parseOldRecords(host: string, input: unknown): ZoneRecord[] | undefined {
57 if (!Array.isArray(input) || input.length === 0 || input.length > MAX_RECORDS) return undefined;
58 const out: ZoneRecord[] = [];
59 for (const item of input) {
60 if (typeof item !== "object" || item === null) return undefined;
61 const { type, value, ttl } = item as Record<string, unknown>;
62 if (type !== "A" && type !== "AAAA" && type !== "CNAME") return undefined;
63 if (typeof value !== "string" || !/^[A-Za-z0-9.:_-]{1,253}$/.test(value)) return undefined;
64 const seconds = ttl === undefined ? TTL_DEFAULT : ttl;
65 if (typeof seconds !== "number" || !Number.isInteger(seconds) || seconds < 1 || seconds > 86400)
66 return undefined;
67 out.push({ type, name: host, value, ttl: seconds });
68 }
69 // A CNAME stands alone at a name.
70 if (out.some((r) => r.type === "CNAME") && out.length > 1) return undefined;
71 return out;
72 }
73
74 export async function editAppDomainDns(
75 app: App,
76 actor: User | undefined,
77 repo: Repo,
78 hostInput: string,
79 step: DnsStep,
80 oldRecords?: unknown,
81 provider?: DnsProvider,
82 ): Promise<Result<DnsEdit, DnsEditError | DnsLost>> {
83 const gate = requireRepoAdmin(app, actor, repo);
84 if (!gate.ok) return gate;
85 const record = getAppByRepo(app.db, repo.id);
86 if (!record) return err("not_found");
87 const runtime = appsRuntime(app);
88 if (runtime === undefined) return err("no_runtime");
89 const domain = getDomain(app.db, normalizeHost(hostInput));
90 if (domain === undefined || domain.appId !== record.id) return err("no_such_domain");
91 // Both replace what is at the name: only a name the forge verified is the owner's to edit.
92 if ((step === "cut" || step === "revert") && domain.verifiedAt === null) return err("not_verified");
93 let put: ZoneRecord[];
94 let replace: EditableType[];
95 let at = domain.host;
96 if (step === "proof") {
97 at = verifyName(domain.host);
98 replace = ["TXT"];
99 put = [{ type: "TXT", name: at, value: verifyValue(domain.token), ttl: TTL_DEFAULT }];
100 } else if (step === "cut") {
101 replace = ["A", "AAAA", "CNAME"];
102 put = [
103 {
104 type: "CNAME",
105 name: domain.host,
106 value: runtime.hostFor(record.slug, domain.env),
107 ttl: TTL_DEFAULT,
108 },
109 ];
110 } else {
111 replace = ["A", "AAAA", "CNAME"];
112 const parsed = parseOldRecords(domain.host, oldRecords);
113 if (parsed === undefined) return err("invalid_records");
114 put = parsed;
115 }
116 let api = provider;
117 if (api === undefined) {
118 // Repo scope only: a forge-wide token reaches other tenants' zones.
119 const found = resolveScoped(app.db, app.config.dataDir, app.bus, [DNS_SECRET], {
120 repo: repo.id,
121 actor: gate.value.id,
122 })[DNS_SECRET];
123 if (found === undefined || found.scope !== "repo") return err("no_credential");
124 api = cloudflare(found.value, runtime.options.dnsFetch);
125 }
126 const done = await api.replace(at, replace, put);
127 if (!done.ok)
128 return done.unrestored.length === 0
129 ? err("refused")
130 : err({ says: done.says, unrestored: done.unrestored });
131 app.bus.append({
132 kind: "app.domain.dns",
133 repoId: repo.id,
134 actorId: gate.value.id,
135 payload: { host: domain.host, step, removed: done.removed.length },
136 });
137 return ok({ says: dnsSaid(step, domain.host), removed: done.removed, put });
138 }
139
140 export function recordsInWords(records: readonly ZoneRecord[]): string {
141 return records.map((r) => `${r.type} ${r.value} ttl ${r.ttl}`).join("; ");
142 }
143
144 /** The words for any failure of an edit; a half-done one lists what to put back by hand. */
145 export function dnsRefusal(error: DnsEditError | DnsLost): string {
146 return typeof error === "string"
147 ? DNS_REFUSALS[error]
148 : `The registrar failed (${error.says}) and these removed records were not put back: ${recordsInWords(error.unrestored)}. Add them again by hand.`;
149 }
150
151 export function dnsSaid(step: DnsStep, host: string): string {
152 return step === "proof"
153 ? `Published the TXT proof for ${host}. Check it next.`
154 : step === "cut"
155 ? `${host} now points at the app. The records it replaced are listed: keep them, they are the way back.`
156 : `The old records are back at ${host}; traffic returns as caches expire.`;
157 }
158
159 export const DNS_REFUSALS: Readonly<Record<DnsEditError, string>> = {
160 not_found: "no such app or repository (owner/name)",
161 no_runtime: "Nothing is serving apps on this forge.",
162 no_such_domain: "No domain by that name.",
163 no_credential: `No registrar credential: set the secret ${DNS_SECRET} (a Cloudflare API token that edits DNS) for this repository.`,
164 not_verified: "Check the domain until it is verified before cutting it over or reverting it.",
165 invalid_records:
166 "revert needs `records`: the old A, AAAA or CNAME records, each { type, value, ttl? } (a CNAME alone).",
167 refused: "The registrar refused the edit; nothing was changed.",
168 };
src/apps/dns_provider.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * Editing DNS at the registrar: one adapter (Cloudflare), one operation —
3 * replace the address records at a name, or set its TXT — and every record
4 * it removed handed back, so the way back is never lost. The credential is
5 * not here: the caller opens it by name (`actions/domain_dns.ts`). Every
6 * failure is a sentence, never a throw. Plain data in, plain data out.
7 */
8
9 export type EditableType = "A" | "AAAA" | "CNAME" | "TXT";
10 export const EDITABLE_TYPES: readonly EditableType[] = ["A", "AAAA", "CNAME", "TXT"];
11
12 export interface ZoneRecord {
13 readonly type: EditableType;
14 readonly name: string;
15 readonly value: string;
16 /** Seconds; 1 means the provider's automatic TTL. */
17 readonly ttl: number;
18 }
19
20 export interface DnsProvider {
21 /**
22 * Remove every record of `replace` types at `name` and put `put` there.
23 * Returns what was removed. On failure the records already put are deleted
24 * and the removed ones put back; any that could not be are returned.
25 */
26 replace(
27 name: string,
28 replace: readonly EditableType[],
29 put: readonly ZoneRecord[],
30 ): Promise<
31 | { ok: true; removed: ZoneRecord[] }
32 // `unrestored`: removed records that could not be put back, the way back.
33 | { ok: false; says: string; unrestored: ZoneRecord[] }
34 >;
35 }
36
37 const API = "https://api.cloudflare.com/client/v4";
38
39 interface Envelope<T> {
40 success?: boolean;
41 result?: T;
42 errors?: { message?: string }[];
43 }
44 interface CfRecord {
45 id: string;
46 type: string;
47 name: string;
48 content: string;
49 ttl: number;
50 }
51
52 export function cloudflare(token: string, doFetch: typeof fetch = fetch): DnsProvider {
53 const call = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
54 const res = await doFetch(`${API}${path}`, {
55 method,
56 headers: {
57 authorization: `Bearer ${token}`,
58 "content-type": "application/json",
59 },
60 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
61 signal: AbortSignal.timeout(10_000),
62 });
63 const json = (await res.json().catch(() => ({}))) as Envelope<T>;
64 if (!res.ok || json.success === false || json.result === undefined)
65 throw new Error(json.errors?.[0]?.message ?? `the registrar answered ${res.status}`);
66 return json.result;
67 };
68
69 const zoneOf = async (name: string): Promise<string> => {
70 const labels = name.split(".");
71 for (let at = 0; at < labels.length - 1; at++) {
72 const zones = await call<{ id: string }[]>(
73 "GET",
74 `/zones?name=${encodeURIComponent(labels.slice(at).join("."))}`,
75 );
76 if (zones[0] !== undefined) return zones[0].id;
77 }
78 throw new Error("the credential reaches no zone that holds this name");
79 };
80
81 const create = (zone: string, r: ZoneRecord) =>
82 call<{ id?: string }>("POST", `/zones/${zone}/dns_records`, {
83 type: r.type,
84 name: r.name,
85 content: r.value,
86 ttl: r.ttl,
87 });
88
89 return {
90 async replace(name, replace, put) {
91 let zone = "";
92 const removed: ZoneRecord[] = [];
93 const made: string[] = [];
94 try {
95 zone = await zoneOf(name);
96 const found = await call<CfRecord[]>(
97 "GET",
98 `/zones/${zone}/dns_records?name=${encodeURIComponent(name)}&per_page=100`,
99 );
100 const doomed = found.filter(
101 (r) => r.name === name && (replace as readonly string[]).includes(r.type),
102 );
103 for (const r of doomed) {
104 await call("DELETE", `/zones/${zone}/dns_records/${r.id}`);
105 removed.push({ type: r.type as EditableType, name: r.name, value: r.content, ttl: r.ttl });
106 }
107 for (const r of put) {
108 const c = await create(zone, r);
109 if (c.id !== undefined) made.push(c.id);
110 }
111 return { ok: true, removed };
112 } catch (error) {
113 const raw = error instanceof Error ? error.message : "the registrar did not answer";
114 // A registrar may echo the credential back; it never leaves here.
115 const says = token === "" ? raw : raw.split(token).join("…");
116 // Undo, best effort: drop what was put, put back what was taken.
117 const unrestored: ZoneRecord[] = [];
118 if (zone !== "") {
119 for (const id of made)
120 await call("DELETE", `/zones/${zone}/dns_records/${id}`).catch(() => undefined);
121 for (const r of removed) await create(zone, r).catch(() => unrestored.push(r));
122 }
123 return {
124 ok: false,
125 says: unrestored.length === 0 ? `DNS was not changed: ${says}.` : `${says}.`,
126 unrestored,
127 };
128 }
129 },
130 };
131 }
src/apps/runtime.ts +2
@@ -172,6 +172,8 @@ export interface RuntimeOptions {
172172 * its own, so proving `app.example.com` needs no network in a test.
173173 */
174174 readonly dns?: DomainResolver | undefined;
175 /** What the registrar adapter calls (`apps/dns_provider.ts`); the global `fetch` unless a test hands in its own. */
176 readonly dnsFetch?: typeof fetch | undefined;
175177 /**
176178 * What a check asks of a verified domain to read its certificate's expiry:
177179 * a TLS handshake unless a test hands in its own.
src/http/mcp/tools/app_domains.ts +19 −5
@@ -5,6 +5,11 @@ import {
55 listAppDomains,
66 removeAppDomain,
77 } from "../../../actions/domains.ts";
8 import {
9 dnsRefusal,
10 DNS_STEPS,
11 editAppDomainDns,
12 } from "../../../actions/domain_dns.ts";
813 import {
914 NO_SUCH_DOMAIN,
1015 domainAddedText,
@@ -16,15 +21,15 @@ import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer, str } f
1621
1722 export const tool: Tool = {
1823 name: "app_domains",
19 doc: "`{ repo, action, host?, records? }`: `list`, `add`, `check` (looks the DNS proof up now), `keep` (`records`: the old DNS records `[{type,name,value,ttl?}]` written down before a cut-over, shown back as `oldRecords` on `list`; `add` takes them too) or `remove` an app's own domains, with the records to publish, as `…/app/domains` answers; an admin of the repository with an admin-scope token",
20 description: "App domains: list, add, check, keep, remove.",
24 doc: "`{ repo, action, host?, records? }`: `list`, `add`, `check` (looks the DNS proof up now), `keep` (`records`: the old DNS records `[{type,name,value,ttl?}]` written down before a cut-over, shown back as `oldRecords` on `list`; `add` takes them too) or `remove` an app's own domains, with the records to publish, as `…/app/domains` answers; or edit its DNS with `dns_proof`, `dns_cut` (answers the records it replaced) or `dns_revert` (`records` `[{type,value,ttl?}]`; verified names only), using the repo's secret DNS_API_TOKEN; an admin of the repository with an admin-scope token",
25 description: "App domains: list, add, check, keep, remove; dns_proof, dns_cut, dns_revert.",
2126 inputSchema: {
2227 type: "object",
2328 properties: {
2429 repo: REPO_ARG,
2530 action: { type: "string" },
2631 host: { type: "string" },
27 records: { type: "array" },
32 records: { type: "array", maxItems: 50 },
2833 },
2934 },
3035 writes: true,
@@ -33,9 +38,18 @@ export const tool: Tool = {
3338 const repo = repoArg(ctx, user, args);
3439 if (isAnswer(repo)) return repo;
3540 const action = args["action"];
36 if (action !== "list" && action !== "add" && action !== "check" && action !== "keep" && action !== "remove")
37 return bad("invalid", "action is list, add, check, keep or remove");
41 const dnsStep = DNS_STEPS.find((step) => `dns_${step}` === action);
42 const plain = ["list", "add", "check", "keep", "remove"].includes(String(action));
43 if (dnsStep === undefined && !plain)
44 return bad("invalid", "action is list, add, check, keep, remove, dns_proof, dns_cut or dns_revert");
3845 const host = str(args["host"]);
46 if (dnsStep !== undefined) {
47 if (host === "") return bad("invalid", "host is the domain name");
48 const edited = await editAppDomainDns(ctx.app, user, repo, host, dnsStep, args["records"]);
49 return edited.ok
50 ? good({ says: edited.value.says, removed: edited.value.removed, put: edited.value.put })
51 : bad(typeof edited.error === "string" ? edited.error : "left_half", dnsRefusal(edited.error));
52 }
3953 if (action !== "list" && host === "") return bad("invalid", "host is the domain name");
4054 const refused = (error: string, fallback: string): Answer =>
4155 error === "not_found"
src/tui/screens/deploys.ts +75 −4
@@ -50,8 +50,11 @@
5050 * cut-over (`keepOldRecords`) and shows them under the list, and `-` removes
5151 * it after `remove <host>? y/n`
5252 * — `addAppDomain`, `checkAppDomain` and `removeAppDomain`, the page's and
53 * MCP `app_domains`' actions. `listAppDomains` is the admin gate: anyone it
54 * refuses sees neither the domains nor the key.
53 * MCP `app_domains`' actions. With the repository's `DNS_API_TOKEN` it also
54 * edits the registrar (`domain_dns.ts`): `p` publishes the TXT proof, `x`
55 * cuts the name over after `cut <host> over? y/n` and keeps the records it
56 * removed on screen, `v` puts typed records back. `listAppDomains` is the
57 * admin gate: anyone it refuses sees neither the domains nor the key.
5558 *
5659 * The admin who is a person also gets `i`: the app's prompt source, the
5760 * Instagram account whose comments become the crew's tell (`prompts.ts`).
@@ -133,6 +136,8 @@ import { pullScreen } from "./pull.ts";
133136 import { local } from "./where.ts";
134137 import { appMembersScreen } from "./appmembers.ts";
135138 import { mayManagePrompts, promptsScreen } from "./prompts.ts";
139 import { editDnsFromTerminal } from "./domain_dns.ts";
140 import type { DnsStep } from "../../actions/domain_dns.ts";
136141
137142 const LIMIT = 50;
138143
@@ -733,6 +738,8 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
733738 let domains: AppDomainView[] = [];
734739 let refused: string | undefined;
735740 let loaded = false;
741 /** The last registrar edit's words, kept on the foot: a cut's removed records are the way back. */
742 let told = "";
736743
737744 const refusal = (code: string, fallback: string): string =>
738745 code === "not_found" ? NOT_ADMIN : domainRefusalText(code, fallback);
@@ -777,6 +784,49 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
777784 after();
778785 });
779786
787 const registrar = (d: AppDomainView, step: DnsStep, recordsText?: string): void =>
788 runAsync(ctx, async () => {
789 const said = await editDnsFromTerminal(f, repo, d.host, step, recordsText);
790 told = said.text;
791 ctx.toast(said.text, said.ok ? "success" : "danger");
792 after();
793 });
794
795 const cut = (d: AppDomainView): void => {
796 const choose = (choice: number): void => {
797 if (choice === 0) registrar(d, "cut");
798 };
799 ctx.push(
800 yesNo(
801 ctx,
802 confirmScreen(ctx, self, {
803 title: "Cut over at the registrar",
804 body: [`cut ${d.host} over to the app? y/n`, "Its A, AAAA and CNAME are replaced; the removed ones are shown."],
805 actions: [{ label: "Cut over" }, { label: "Cancel", variant: "ghost" }],
806 selected: 1,
807 onChoose: choose,
808 }),
809 choose,
810 ),
811 );
812 };
813
814 const revert = (d: AppDomainView): void => {
815 ctx.push(
816 promptScreen(ctx, self, {
817 title: `Put ${d.host}'s old records back`,
818 label: "Records",
819 placeholder: "A 203.0.113.5 ttl 60; AAAA 2001:db8::1",
820 hint: "The records the cut removed, as it listed them.",
821 onSubmit: (text) => {
822 if (text.trim() === "") return "Type the old records, as the cut listed them.";
823 registrar(d, "revert", text);
824 return undefined;
825 },
826 }),
827 );
828 };
829
780830 /** Write down what the name carries today, before the cut: `keepOldRecords`, as the API's PUT and MCP `keep`. */
781831 const keep = (d: AppDomainView): void => {
782832 ctx.push(
@@ -827,7 +877,16 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
827877 : [
828878 binding(["j", "k"], "move"),
829879 binding(["+"], "add a domain"),
830 ...(domains.length > 0 ? [binding(["c"], "check its DNS now"), binding(["o"], "old records"), binding(["-"], "remove it")] : []),
880 ...(domains.length > 0
881 ? [
882 binding(["c"], "check its DNS now"),
883 binding(["o"], "old records"),
884 binding(["-"], "remove it"),
885 binding(["p"], "publish its proof at the registrar"),
886 binding(["x"], "cut it over at the registrar"),
887 binding(["v"], "put old records back"),
888 ]
889 : []),
831890 binding(["r"], "refresh"),
832891 binding(["esc"], "back"),
833892 ];
@@ -855,7 +914,7 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
855914 hitList(ctx, body!, state, rows.length, {});
856915 const selected = domains[state.index];
857916 if (selected !== undefined)
858 [stillNeeded(selected), oldLine(selected)].forEach((text, row) =>
917 [told !== "" ? told : stillNeeded(selected), oldLine(selected)].forEach((text, row) =>
859918 s.text(foot!.x, foot!.y + row, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w),
860919 );
861920 },
@@ -879,6 +938,18 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
879938 remove(selected);
880939 return true;
881940 }
941 if (e.key === "p" && selected !== undefined) {
942 registrar(selected, "proof");
943 return true;
944 }
945 if (e.key === "x" && selected !== undefined) {
946 cut(selected);
947 return true;
948 }
949 if (e.key === "v" && selected !== undefined) {
950 revert(selected);
951 return true;
952 }
882953 if (e.key === "r") {
883954 read();
884955 return true;
src/tui/screens/domain_dns.ts added +62
@@ -0,0 +1,62 @@
1 /**
2 * The registrar keys of the terminal's domains screen (`n` on deploys): the
3 * terminal door of MCP `app_domains` dns_proof, dns_cut and dns_revert. A thin
4 * layer over `editAppDomainDns`, which gates (repository admin), validates and
5 * audits; this module only reads the owner's typed records and words answers.
6 */
7
8 import {
9 dnsRefusal,
10 editAppDomainDns,
11 recordsInWords,
12 type DnsStep,
13 } from "../../actions/domain_dns.ts";
14 import type { Repo } from "../../core/repos.ts";
15 import type { Session } from "../session.ts";
16
17 /** What an edit said, and whether it went through. */
18 export interface DnsTold {
19 readonly text: string;
20 readonly ok: boolean;
21 }
22
23 /**
24 * `A 203.0.113.5 ttl 60; AAAA 2001:db8::1` — the words a cut answers with —
25 * as the `{ type, value, ttl? }` list the action validates. Nothing is checked
26 * here beyond the shape of the words: `parseOldRecords` decides.
27 */
28 export function recordsFromWords(text: string): unknown[] {
29 return text
30 .split(/[;,\n]/)
31 .map((part) => part.trim().split(/\s+/).filter((w) => w !== "" && w.toLowerCase() !== "ttl"))
32 .filter((words) => words.length > 0)
33 .map(([type, value, ttl]) => ({
34 type: type?.toUpperCase(),
35 value,
36 ...(ttl === undefined ? {} : { ttl: /^\d+$/.test(ttl) ? Number(ttl) : ttl }),
37 }));
38 }
39
40 /** Run one registrar step for the session's user and say how it went. */
41 export async function editDnsFromTerminal(
42 f: Session,
43 repo: Repo,
44 host: string,
45 step: DnsStep,
46 recordsText?: string,
47 ): Promise<DnsTold> {
48 const records = recordsText === undefined ? undefined : recordsFromWords(recordsText);
49 const done = await editAppDomainDns(f.app, f.user, repo, host, step, records);
50 if (!done.ok) {
51 const text =
52 done.error === "not_found" ? "Only the app's admin edits its DNS." : dnsRefusal(done.error);
53 return { text, ok: false };
54 }
55 if (step !== "cut") return { text: done.value.says, ok: true };
56 // The removed records lead: the foot is one line, and they are the way back.
57 const removed =
58 done.value.removed.length === 0
59 ? "Nothing was removed."
60 : `Removed: ${recordsInWords(done.value.removed)}.`;
61 return { text: `${removed} ${done.value.says}`, ok: true };
62 }
test/domain-dns.test.ts added +284
@@ -0,0 +1,284 @@
1 /**
2 * Editing an own domain's DNS at the registrar (`app_domains` dns_proof,
3 * dns_cut, dns_revert): the credential is the secret DNS_API_TOKEN, the
4 * registrar is a stand-in `fetch`, and every cut says what it removed.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { newApp } from "../src/actions/apps.ts";
12 import { editAppDomainDns, parseOldRecords } from "../src/actions/domain_dns.ts";
13 import { addAppDomain } from "../src/actions/domains.ts";
14 import { AppsRuntime } from "../src/apps/runtime.ts";
15 import { getRepo } from "../src/core/repos.ts";
16 import { setSecret } from "../src/core/secret_store.ts";
17 import { recordCheck } from "../src/core/domains.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import { domainsScreen } from "../src/tui/screens/deploys.ts";
22 import { recordsFromWords } from "../src/tui/screens/domain_dns.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { settleApp } from "./helpers/tui.ts";
25
26 quietLogs();
27
28 interface Rec { id: string; type: string; name: string; content: string; ttl: number }
29
30 describe("DNS at the registrar", () => {
31 let dataDir: string;
32 let app: App;
33 let runtime: AppsRuntime;
34 let alice: User;
35 let bob: User;
36 let records: Rec[] = [];
37 let calls: { method: string; url: string; auth: string | null; body: any }[] = [];
38 let failCreate = false;
39 let failAll = false;
40 let n = 0;
41
42 const ok = (result: unknown) => Response.json({ success: true, result });
43 const fake = (async (url: string, init: RequestInit) => {
44 const u = new URL(url);
45 const method = init.method ?? "GET";
46 const body = init.body ? JSON.parse(String(init.body)) : undefined;
47 calls.push({ method, url, auth: new Headers(init.headers).get("authorization"), body });
48 if (u.pathname === "/client/v4/zones")
49 return ok(u.searchParams.get("name") === "example.com" ? [{ id: "z1" }] : []);
50 if (method === "GET") return ok(records.filter((r) => r.name === u.searchParams.get("name")));
51 if (method === "DELETE") {
52 records = records.filter((r) => !u.pathname.endsWith(r.id));
53 return ok({});
54 }
55 if (failAll && body.type === "A")
56 return Response.json({ success: false, errors: [{ message: "bad tok-123 echoed" }] }, { status: 400 });
57 if (failCreate && body.type === "CNAME" && body.content.endsWith("apps.test"))
58 return Response.json({ success: false, errors: [{ message: "nope" }] }, { status: 400 });
59 const id = `r${n++}`;
60 records.push({ id, type: body.type, name: body.name, content: body.content, ttl: body.ttl });
61 return ok({ id });
62 }) as unknown as typeof fetch;
63
64 beforeAll(async () => {
65 dataDir = await mkdtemp(join(tmpdir(), "forge-dns-"));
66 app = createApp({ dataDir, port: 0, host: "forge.test" });
67 runtime = new AppsRuntime(app, {
68 domain: "apps.test", scheme: "http", publicPort: 1, forgeUrl: "http://forge.test",
69 concurrency: 1, dnsFetch: fake,
70 });
71 await runtime.start();
72 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
73 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
74 if (!a.ok || !b.ok) throw new Error("users");
75 alice = a.value; bob = b.value;
76 const made = await newApp(app, alice, { name: "notes", description: "x", visibility: "public" });
77 if (!made.ok) throw new Error(String(made.error));
78 });
79 afterAll(async () => {
80 await runtime.stop();
81 await rm(dataDir, { recursive: true, force: true });
82 });
83
84 const repo = () => getRepo(app.db, "alice", "notes")!;
85 const reset = () => { calls = []; failCreate = false; failAll = false; records = []; };
86
87 test("without the credential nothing is called", async () => {
88 reset();
89 addAppDomain(app, alice, repo(), "www.example.com");
90 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "proof");
91 expect(r).toEqual({ ok: false, error: "no_credential" });
92 expect(calls.length).toBe(0);
93 });
94
95 test("proof publishes the TXT with the credential from the secret store", async () => {
96 reset();
97 setSecret(app.db, dataDir, "repo", repo().id, "DNS_API_TOKEN", "tok-123");
98 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "proof");
99 expect(r.ok).toBe(true);
100 expect(records.map((x) => [x.type, x.name])).toEqual([["TXT", "_forge-verify.www.example.com"]]);
101 expect(records[0]!.content).toMatch(/^forge-verify=/);
102 expect(calls.every((c) => c.auth === "Bearer tok-123")).toBe(true);
103 });
104
105 test("cut needs a verified domain, then replaces the old records and lists them", async () => {
106 reset();
107 records = [
108 { id: "o1", type: "A", name: "www.example.com", content: "203.0.113.5", ttl: 60 },
109 { id: "o2", type: "TXT", name: "www.example.com", content: "keep", ttl: 60 },
110 ];
111 expect(await editAppDomainDns(app, alice, repo(), "www.example.com", "cut")).toEqual({ ok: false, error: "not_verified" });
112 expect(records.length).toBe(2);
113 recordCheck(app.db, "www.example.com", { verified: true, said: "ok" });
114 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "cut");
115 if (!r.ok) throw new Error(String(r.error));
116 expect(r.value.removed).toEqual([{ type: "A", name: "www.example.com", value: "203.0.113.5", ttl: 60 }]);
117 expect(records[0]!.content).toBe("keep");
118 expect(records[1]!.type).toBe("CNAME");
119 });
120
121 test("a failed cut puts the removed records back", async () => {
122 reset();
123 records = [{ id: "o1", type: "A", name: "www.example.com", content: "203.0.113.5", ttl: 60 }];
124 failCreate = true;
125 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "cut");
126 expect(r).toEqual({ ok: false, error: "refused" });
127 expect(records.map((x) => [x.type, x.content])).toEqual([["A", "203.0.113.5"]]);
128 });
129
130 test("revert puts the owner's list back, validated", async () => {
131 reset();
132 const bad = await editAppDomainDns(app, alice, repo(), "www.example.com", "revert", [{ type: "MX", value: "x" }]);
133 expect(bad).toEqual({ ok: false, error: "invalid_records" });
134 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "revert", [{ type: "A", value: "203.0.113.5", ttl: 60 }]);
135 expect(r.ok).toBe(true);
136 expect(records.map((x) => [x.type, x.content, x.ttl])).toEqual([["A", "203.0.113.5", 60]]);
137 expect(parseOldRecords("h.example.com", [{ type: "CNAME", value: "a.b" }, { type: "A", value: "1.2.3.4" }])).toBeUndefined();
138 });
139
140 test("a non-admin and an unknown domain are refused before the registrar", async () => {
141 reset();
142 expect((await editAppDomainDns(app, bob, repo(), "www.example.com", "proof")).ok).toBe(false);
143 expect(await editAppDomainDns(app, alice, repo(), "nope.example.com", "proof")).toEqual({ ok: false, error: "no_such_domain" });
144 expect(calls.length).toBe(0);
145 });
146
147 test("an unverified domain cannot be reverted, the registrar is not called", async () => {
148 reset();
149 addAppDomain(app, alice, repo(), "victim.example.com");
150 records = [{ id: "v1", type: "A", name: "victim.example.com", content: "198.51.100.1", ttl: 60 }];
151 const r = await editAppDomainDns(app, alice, repo(), "victim.example.com", "revert", [{ type: "A", value: "203.0.113.66" }]);
152 expect(r).toEqual({ ok: false, error: "not_verified" });
153 expect(calls.length).toBe(0);
154 expect(records.map((x) => x.content)).toEqual(["198.51.100.1"]);
155 });
156
157 test("a forge-scope token is not used: only the repository's own", async () => {
158 reset();
159 const other = await newApp(app, alice, { name: "other", description: "x", visibility: "public" });
160 if (!other.ok) throw new Error(String(other.error));
161 const o = getRepo(app.db, "alice", "other")!;
162 addAppDomain(app, alice, o, "o.example.com");
163 recordCheck(app.db, "o.example.com", { verified: true, said: "ok" });
164 setSecret(app.db, dataDir, "forge", "", "DNS_API_TOKEN", "forge-wide");
165 const r = await editAppDomainDns(app, alice, o, "o.example.com", "cut");
166 expect(r).toEqual({ ok: false, error: "no_credential" });
167 expect(calls.length).toBe(0);
168 });
169
170 test("a failure after a partial create deletes what was put and restores what was removed", async () => {
171 reset();
172 records = [{ id: "o1", type: "A", name: "www.example.com", content: "203.0.113.5", ttl: 60 }];
173 const { cloudflare } = await import("../src/apps/dns_provider.ts");
174 let creates = 0;
175 const flaky = (async (url: string, init: RequestInit) => {
176 if ((init.method ?? "GET") === "POST" && ++creates === 2)
177 return Response.json({ success: false, errors: [{ message: "boom" }] }, { status: 500 });
178 return fake(url, init);
179 }) as unknown as typeof fetch;
180 const done = await cloudflare("t", flaky).replace(
181 "www.example.com", ["A"],
182 [
183 { type: "A", name: "www.example.com", value: "1.1.1.1", ttl: 60 },
184 { type: "A", name: "www.example.com", value: "2.2.2.2", ttl: 60 },
185 ],
186 );
187 expect(done.ok).toBe(false);
188 expect(records.map((x) => x.content)).toEqual(["203.0.113.5"]);
189 });
190
191 test("a failed rollback hands the removed records back in the answer, token never shown", async () => {
192 reset();
193 records = [{ id: "o1", type: "A", name: "www.example.com", content: "203.0.113.5", ttl: 60 }];
194 failAll = true; // every A create fails: the put and the rollback
195 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "revert", [{ type: "A", value: "203.0.113.9" }]);
196 expect(r.ok).toBe(false);
197 if (r.ok || typeof r.error === "string") throw new Error("expected a lost answer");
198 expect(r.error.unrestored).toEqual([{ type: "A", name: "www.example.com", value: "203.0.113.5", ttl: 60 }]);
199 const { dnsRefusal } = await import("../src/actions/domain_dns.ts");
200 expect(dnsRefusal(r.error)).toContain("203.0.113.5");
201 expect(JSON.stringify(r)).not.toContain("tok-123");
202 });
203
204 test("a success is audited as app.domain.dns and no answer holds the token", async () => {
205 reset();
206 const r = await editAppDomainDns(app, alice, repo(), "www.example.com", "proof");
207 expect(JSON.stringify(r)).not.toContain("tok-123");
208 const row = app.db
209 .query<{ payload: string; actor_id: string }, []>("SELECT payload, actor_id FROM event WHERE kind = 'app.domain.dns' ORDER BY id DESC LIMIT 1")
210 .get();
211 expect(JSON.parse(row!.payload)).toMatchObject({ host: "www.example.com", step: "proof" });
212 expect(row!.actor_id).toBe(alice.id);
213 });
214
215 /** The terminal door: the domains screen (`n` on deploys) after `keys`. */
216 const drive = async (who: User, of: ReturnType<typeof repo>, keys: string[]): Promise<string> => {
217 const terminal = headlessTerminal(200, 30);
218 const { app: tui, session } = createForgeApp(
219 app,
220 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
221 { terminal, exitHooks: false },
222 );
223 tui.start();
224 await settleApp(tui);
225 tui.push(domainsScreen(session, tui.ctx, of));
226 await settleApp(tui);
227 for (const k of keys) {
228 terminal.feed(k);
229 await settleApp(tui);
230 }
231 const text = tui.screen.toText();
232 tui.stop();
233 return text;
234 };
235
236 test("the terminal publishes, cuts over showing what it removed, and puts records back", async () => {
237 reset();
238 const made = await newApp(app, alice, { name: "shop", description: "x", visibility: "public" });
239 if (!made.ok) throw new Error(String(made.error));
240 const shop = getRepo(app.db, "alice", "shop")!;
241 addAppDomain(app, alice, shop, "shop.example.com");
242 setSecret(app.db, dataDir, "repo", shop.id, "DNS_API_TOKEN", "tok-shop");
243
244 expect(await drive(alice, shop, ["p"])).toContain("Published the TXT proof for shop.example.com");
245 expect(records.map((x) => [x.type, x.name])).toEqual([["TXT", "_forge-verify.shop.example.com"]]);
246
247 // Unverified: the cut is refused before the registrar.
248 records = [{ id: "s1", type: "A", name: "shop.example.com", content: "203.0.113.5", ttl: 60 }];
249 calls = [];
250 expect(await drive(alice, shop, ["x", "y"])).toContain("Check the domain until it is verified");
251 expect(calls.length).toBe(0);
252
253 recordCheck(app.db, "shop.example.com", { verified: true, said: "ok" });
254 expect(await drive(alice, shop, ["x"])).toContain("cut shop.example.com over to the app? y/n");
255 await drive(alice, shop, ["x", "n"]);
256 expect(records.map((x) => x.type)).toEqual(["A"]);
257 const cut = await drive(alice, shop, ["x", "y"]);
258 expect(cut).toContain("Removed: A 203.0.113.5 ttl 60.");
259 expect(cut).not.toContain("tok-shop");
260 expect(records.map((x) => x.type)).toEqual(["CNAME"]);
261
262 // The cut's own words, typed back, are the way back.
263 await drive(alice, shop, ["v", ..."A 203.0.113.5 ttl 60", "\r"]);
264 expect(records.map((x) => [x.type, x.content, x.ttl])).toEqual([["A", "203.0.113.5", 60]]);
265 expect(await drive(alice, shop, ["v", ..."MX mail.example.com", "\r"])).toContain("revert needs `records`");
266 });
267
268 test("the terminal's registrar keys are not a non-admin's", async () => {
269 reset();
270 const shop = getRepo(app.db, "alice", "shop")!;
271 const seen = await drive(bob, shop, ["x", "y", "p"]);
272 expect(seen).toContain("Only the app's admin manages its domains.");
273 expect(seen).not.toContain("cut it over");
274 expect(calls.length).toBe(0);
275 });
276
277 test("typed records become the list the action validates", () => {
278 expect(recordsFromWords("A 203.0.113.5 ttl 60; aaaa 2001:db8::1")).toEqual([
279 { type: "A", value: "203.0.113.5", ttl: 60 },
280 { type: "AAAA", value: "2001:db8::1" },
281 ]);
282 expect(parseOldRecords("h.example.com", recordsFromWords("A 1.2.3.4 ttl soon"))).toBeUndefined();
283 });
284 });
test/mcp-app-domains.test.ts +9
@@ -170,4 +170,13 @@ describe("MCP app_domains", () => {
170170 expect(again.error).toContain(domainRefusalText("taken", ""));
171171 await tool(admin, { repo, action: "remove", host: "dup.example.com" });
172172 });
173
174 test("dns_proof is reachable and says when no registrar credential is held", async () => {
175 await tool(admin, { repo, action: "add", host: "shop.example.com" });
176 const r = await tool(admin, { repo, action: "dns_proof", host: "shop.example.com" });
177 expect(r.error).toContain("DNS_API_TOKEN");
178 const bad = await tool(admin, { repo, action: "dns_cut" });
179 expect(bad.error).toContain("host");
180 expect((await tool(bob, { repo, action: "dns_proof", host: "shop.example.com" })).error).toContain("no such app");
181 });
173182 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs