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

Platform app move gap: store the old DNS records before an own-domain cut-over #11740

Merged opened this

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

Merged

mason/forge:mason/11719-platform-app-move-gap-store-the-old-dns → main 9 commits fork

  • Merged. mason/11719-platform-app-move-gap-store-the-old-dns was merged into main as 1c4f3fc.

Review

Sign in to review this change.
17 files changed +330 −20
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, remove | 201 |
55 | `app_domains` | — | App domains: list, add, check, keep, remove | 234 |
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 +11 −5
@@ -7,8 +7,11 @@ 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,
99 CNAME, ALIAS, and the TTL of each) as the old host serves them today,
10 in the migration issue. This list is the way back; the forge cannot read
11 it for you after the cut.
10 in the migration issue **and with the domain**: pass them as `old_records`
11 (`[{type, name, value, ttl?}]`) when adding, or later `PUT
12 …/app/domains/<host>/old-records` with `{records}` (MCP `app_domains`
13 `keep`, with `records`; in the terminal, `o` on the domain's screen, `type name value [ttl]; …`), up to 50, replaced whole. Types: A, AAAA, CNAME, ALIAS, TXT, MX, NS, CAA, SRV. `list` then shows them
14 as `oldRecords`. The forge cannot read them for you after the cut.
1215 2. **Add the name** (Settings → App → Domains, `app_domains` `add`, or
1316 `POST …/app/domains`). The answer lists the TXT proof and the CNAME or
1417 ALIAS to publish. Publish the **TXT only**; the old A/CNAME stays.
@@ -23,7 +26,7 @@ the owner's records, put back by hand in one edit. In order:
2326 4. **Cut:** replace the old A/CNAME with the CNAME/ALIAS the forge gave. The
2427 first request to the new name pays for issuing the certificate (a second
2528 or two); request it once yourself, over https, before telling anyone.
26 5. **Revert, one step:** put the step-1 records back. Traffic returns to the old
29 5. **Revert, one step:** read `oldRecords` from the domain (`list`) and put those records back. Traffic returns to the old
2730 host as each resolver's cache of the new record expires, so keep the TTL
2831 low (`platform-app-move.md`, §6) until the shop is settled.
2932 The forge keeps answering at the name until the domain is removed
@@ -31,8 +34,8 @@ the owner's records, put back by hand in one edit. In order:
3134 and the old host's certificate was never touched.
3235
3336 Left out, on purpose: the forge does not edit DNS at a registrar (#11717),
34 or store the old records for you
35 (#11719). This closes #11700 as a runbook over a feature that already ships,
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,
3639 not as new code.
3740
3841 ## What keeps these claims true
@@ -43,5 +46,8 @@ All in `test/app-domains.test.ts`:
4346 app at the host and every URL follows";
4447 - before verification the forge answers for itself and the ask says 404:
4548 "before verification the forge answers for itself at the host";
49 - the old records are checked, kept with the row, listed back, replaced whole
50 and admin-only (also `test/core-domains.test.ts`): "the old records are kept
51 with the domain";
4652 - removal makes the host unknown again and the ask 404: "removal makes the
4753 host unknown again".
src/actions/domain_words.ts +2
@@ -12,6 +12,8 @@ const REFUSALS: Readonly<Record<string, string>> = {
1212 "A domain is a hostname: letters, digits and hyphens in at least two labels, like app.example.com.",
1313 reserved_host: "That name is the forge's own to hand out.",
1414 taken: "Another app already answers to that domain.",
15 invalid_records:
16 "Old records are a list of up to 50, each with a type (A, AAAA, CNAME, ALIAS, TXT, MX, NS, CAA or SRV), a name, a value and, if known, a ttl in seconds.",
1517 no_runtime: "Nothing is serving apps on this forge.",
1618 };
1719
src/actions/domains.ts +42 −2
@@ -15,10 +15,13 @@ import {
1515 addDomain,
1616 getDomain,
1717 listDomains,
18 parseRecordsInput,
19 setOldRecords,
1820 recordCheck,
1921 removeDomain,
2022 type AddDomainError,
2123 type AppDomain,
24 type OldRecord,
2225 } from "../core/domains.ts";
2326 import type { Repo } from "../core/repos.ts";
2427 import type { User } from "../core/users.ts";
@@ -34,6 +37,8 @@ export interface AppDomainView {
3437 readonly said: string;
3538 /** The proof, and where the traffic goes. */
3639 readonly records: DnsRecord[];
40 /** What the name carried before the cut-over: put these back to revert. */
41 readonly oldRecords: readonly OldRecord[];
3742 }
3843
3944 export type DomainError = "not_found" | "no_runtime";
@@ -85,6 +90,7 @@ function view(
8590 verifiedAt: domain.verifiedAt,
8691 checkedAt: domain.checkedAt,
8792 said: domain.said,
93 oldRecords: domain.oldRecords,
8894 records: recordsFor(
8995 domain.host,
9096 domain.token,
@@ -115,12 +121,15 @@ export function addAppDomain(
115121 actor: User | undefined,
116122 repo: Repo,
117123 host: string,
118 ): Result<AppDomainView, DomainError | AddDomainError> {
124 oldRecords?: unknown,
125 ): Result<AppDomainView, DomainError | AddDomainError | "invalid_records"> {
119126 const gate = admitted(app, actor, repo);
120127 if (!gate.ok) return gate;
121128 const runtime = serving(app);
122129 if (!runtime.ok) return runtime;
123130 const { admin, record } = gate.value;
131 const kept = oldRecords === undefined ? ok<OldRecord[]>([]) : parseRecordsInput(oldRecords);
132 if (!kept.ok) return kept;
124133 const added = addDomain(
125134 app.db,
126135 { host, appId: record.id, createdBy: admin.id },
@@ -132,13 +141,44 @@ export function addAppDomain(
132141 },
133142 );
134143 if (!added.ok) return added;
144 if (kept.value.length > 0) setOldRecords(app.db, added.value.host, kept.value);
135145 app.bus.append({
136146 kind: "app.domain.added",
137147 repoId: repo.id,
138148 actorId: admin.id,
139149 payload: { host: added.value.host, slug: record.slug },
140150 });
141 return ok(view(runtime.value, record, added.value));
151 return ok(view(runtime.value, record, getDomain(app.db, added.value.host) ?? added.value));
152 }
153
154 /**
155 * Write down what the name carries today, before the cut, replacing what
156 * was kept. Reading the domain back (`listAppDomains`) is the way back.
157 */
158 export function keepOldRecords(
159 app: App,
160 actor: User | undefined,
161 repo: Repo,
162 host: string,
163 records: unknown,
164 ): Result<AppDomainView, DomainError | "no_such_domain" | "invalid_records"> {
165 const gate = admitted(app, actor, repo);
166 if (!gate.ok) return gate;
167 const runtime = serving(app);
168 if (!runtime.ok) return runtime;
169 const { admin, record } = gate.value;
170 const domain = ownedDomain(app, record, host);
171 if (domain === undefined) return err("no_such_domain");
172 const parsed = parseRecordsInput(records);
173 if (!parsed.ok) return parsed;
174 setOldRecords(app.db, domain.host, parsed.value);
175 app.bus.append({
176 kind: "app.domain.old_records",
177 repoId: repo.id,
178 actorId: admin.id,
179 payload: { host: domain.host, slug: record.slug, count: parsed.value.length },
180 });
181 return ok(view(runtime.value, record, { ...domain, oldRecords: parsed.value }));
142182 }
143183
144184 /**
src/contract/openapi/routes/index.ts +1
@@ -412,6 +412,7 @@ export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts
412412 export { route as putApiV1ForgesNamePlan } from "./putApiV1ForgesNamePlan.ts";
413413 export { route as putApiV1Front } from "./putApiV1Front.ts";
414414 export { route as putApiV1PacksName } from "./putApiV1PacksName.ts";
415 export { route as putApiV1ReposOwnerNameAppDomainsHostOldRecords } from "./putApiV1ReposOwnerNameAppDomainsHostOldRecords.ts";
415416 export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts";
416417 export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts";
417418 export { route as putApiV1ReposOwnerNameCrewUsername } from "./putApiV1ReposOwnerNameCrewUsername.ts";
src/contract/openapi/routes/putApiV1ReposOwnerNameAppDomainsHostOldRecords.ts added +3
@@ -0,0 +1,3 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = { method: "put", path: `${REPO}/app/domains/{host}/old-records`, summary: "Keep the domain's old DNS records with its row, replacing any kept before (admin scope); answers the domain", auth: true, role: "member" };
src/core/domains.ts +67
@@ -24,6 +24,17 @@ export interface AppDomain {
2424 readonly checkedAt: string | null;
2525 /** What the last check said, one sentence for a person. */
2626 readonly said: string;
27 /** The records the name carried before the cut-over: the way back. */
28 readonly oldRecords: readonly OldRecord[];
29 }
30
31 /** One DNS record as the old host served it, written down before the cut. */
32 export interface OldRecord {
33 readonly type: string;
34 readonly name: string;
35 readonly value: string;
36 /** Seconds, when the old zone said. */
37 readonly ttl?: number;
2738 }
2839
2940 interface DomainRow {
@@ -36,6 +47,61 @@ interface DomainRow {
3647 verified_at: string | null;
3748 checked_at: string | null;
3849 said: string;
50 old_records: string;
51 }
52
53 function parseOldRecords(text: string): OldRecord[] {
54 try {
55 const kept = parseRecordsInput(JSON.parse(text) as unknown);
56 return kept.ok ? kept.value : [];
57 } catch {
58 return [];
59 }
60 }
61
62 const RECORD_TYPES = new Set(["A", "AAAA", "CNAME", "ALIAS", "TXT", "MX", "NS", "CAA", "SRV"]);
63 export const MAX_OLD_RECORDS = 50;
64
65 /**
66 * Records written down by hand, checked at the boundary: a known type, a
67 * name and a value of plain length, a TTL that is whole seconds. Anything
68 * else is refused whole, so what is stored is what the owner meant.
69 */
70 export function parseRecordsInput(input: unknown): Result<OldRecord[], "invalid_records"> {
71 if (!Array.isArray(input) || input.length > MAX_OLD_RECORDS) return err("invalid_records");
72 const out: OldRecord[] = [];
73 for (const item of input) {
74 if (typeof item !== "object" || item === null) return err("invalid_records");
75 const r = item as Record<string, unknown>;
76 const type = typeof r["type"] === "string" ? r["type"].trim().toUpperCase() : "";
77 const name = typeof r["name"] === "string" ? r["name"].trim() : "";
78 const value = typeof r["value"] === "string" ? r["value"].trim() : "";
79 const ttl = r["ttl"];
80 if (!RECORD_TYPES.has(type)) return err("invalid_records");
81 if (name === "" || name.length > 253 || value === "" || value.length > 2048) return err("invalid_records");
82 if (ttl !== undefined && (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < 0 || ttl > 2_147_483_647))
83 return err("invalid_records");
84 out.push(ttl === undefined ? { type, name, value } : { type, name, value, ttl });
85 }
86 return ok(out);
87 }
88
89 /** `A app.example.com 203.0.113.7 300; CNAME www.example.com host.example.net`: the terminal's way to type records. */
90 export function recordsFromLine(line: string): unknown[] {
91 return line
92 .split(";")
93 .map((part) => part.trim().split(/\s+/))
94 .filter((f) => f[0] !== "")
95 .map(([type, name, value, ttl]) => ({ type, name, value, ...(ttl === undefined ? {} : { ttl: Number(ttl) }) }));
96 }
97
98 /** Keep the old records with the row, replacing what was kept. False when no such host. */
99 export function setOldRecords(db: Database, host: string, records: readonly OldRecord[]): boolean {
100 return (
101 db
102 .query("UPDATE app_domain SET old_records = ?2 WHERE host = ?1")
103 .run(normalizeHost(host), JSON.stringify(records)).changes > 0
104 );
39105 }
40106
41107 const toDomain = (r: DomainRow): AppDomain => ({
@@ -48,6 +114,7 @@ const toDomain = (r: DomainRow): AppDomain => ({
48114 verifiedAt: r.verified_at,
49115 checkedAt: r.checked_at,
50116 said: r.said,
117 oldRecords: parseOldRecords(r.old_records),
51118 });
52119
53120 const now = (): string => new Date().toISOString();
src/core/migration-pins/20261112140000-domain-old-records.pin added +1
@@ -0,0 +1,1 @@
1 6e7fa27f022371264468b4eda2c33635ee8c27e699a33fae9859c1ac088970ea
src/core/migrations/20261112140000-domain-old-records.ts added +6
@@ -0,0 +1,6 @@
1 // `app_domain.old_records` (#11719): the DNS records the name carried before an own-domain
2 // cut-over, as a JSON array a person wrote down, kept with the row so the way back is one read.
3 // Additive: one column, empty until someone records them.
4 export default `
5 ALTER TABLE app_domain ADD COLUMN old_records TEXT NOT NULL DEFAULT '[]';
6 `;
src/design/activity.ts +1
@@ -87,6 +87,7 @@ export function eventVerb(event: ForgeEvent): string {
8787 const which = typeof host === "string" ? host : "a domain";
8888 if (kind === "app.domain.added") return `gave the app the domain ${which}`;
8989 if (kind === "app.domain.verified") return `verified the domain ${which}`;
90 if (kind === "app.domain.old_records") return `wrote down the old DNS records of ${which}`;
9091 return `removed the domain ${which}`;
9192 }
9293 if (kind === "build.shots") {
src/http/api/app.ts +13 −2
@@ -9,7 +9,7 @@
99 import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts";
1010 import { appView, appVisitors, deleteAppEnvVar, disableApp, enableApp, listAppEnv, openEnvUrl, redeployEnv, setAppEnvVar, stopEnv, updateAppSettings, type AppSettingsInput } from "../../actions/apps.ts";
1111 import { pullNow, PULL_NOW_REFUSALS } from "../../actions/app_pull.ts";
12 import { addAppDomain, checkAppDomain, listAppDomains, removeAppDomain } from "../../actions/domains.ts";
12 import { addAppDomain, checkAppDomain, keepOldRecords, listAppDomains, removeAppDomain } from "../../actions/domains.ts";
1313 import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts";
1414 import { SETTING_WORDS } from "../../actions/setting_words.ts";
1515 import { appsRuntime } from "../../apps/runtime.ts";
@@ -175,7 +175,7 @@ export function appRoutes(router: Router<Handler>): void {
175175 const repo = loadRepo(ctx);
176176 if (!repo) return notFound();
177177 const input = await body(ctx.request);
178 const added = addAppDomain(ctx.app, ctx.auth.user, repo, str(input.host));
178 const added = addAppDomain(ctx.app, ctx.auth.user, repo, str(input.host), input.old_records);
179179 if (!added.ok) return respondAccess(added.error, 422);
180180 return json(added.value, 201);
181181 });
@@ -199,6 +199,17 @@ export function appRoutes(router: Router<Handler>): void {
199199 },
200200 );
201201
202 router.add("PUT", "/api/v1/repos/:owner/:name/app/domains/:host/old-records", async (ctx) => {
203 const denied = requireScope(ctx, "admin");
204 if (denied) return denied;
205 const repo = loadRepo(ctx);
206 if (!repo) return notFound();
207 const input = await body(ctx.request);
208 const kept = keepOldRecords(ctx.app, ctx.auth.user, repo, ctx.params.host ?? "", input.records);
209 if (!kept.ok) return respondAccess(kept.error, 422);
210 return json(kept.value);
211 });
212
202213 router.add(
203214 "DELETE",
204215 "/api/v1/repos/:owner/:name/app/domains/:host",
src/http/mcp/tools/app_domains.ts +11 −5
@@ -1,6 +1,7 @@
11 import {
22 addAppDomain,
33 checkAppDomain,
4 keepOldRecords,
45 listAppDomains,
56 removeAppDomain,
67 } from "../../../actions/domains.ts";
@@ -15,14 +16,15 @@ import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer, str } f
1516
1617 export const tool: Tool = {
1718 name: "app_domains",
18 doc: "`{ repo, action, host? }`: `list`, `add`, `check` (looks the DNS proof up now) 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",
19 description: "App domains: list, add, check, remove.",
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.",
2021 inputSchema: {
2122 type: "object",
2223 properties: {
2324 repo: REPO_ARG,
2425 action: { type: "string" },
2526 host: { type: "string" },
27 records: { type: "array" },
2628 },
2729 },
2830 writes: true,
@@ -31,8 +33,8 @@ export const tool: Tool = {
3133 const repo = repoArg(ctx, user, args);
3234 if (isAnswer(repo)) return repo;
3335 const action = args["action"];
34 if (action !== "list" && action !== "add" && action !== "check" && action !== "remove")
35 return bad("invalid", "action is list, add, check or remove");
36 if (action !== "list" && action !== "add" && action !== "check" && action !== "keep" && action !== "remove")
37 return bad("invalid", "action is list, add, check, keep or remove");
3638 const host = str(args["host"]);
3739 if (action !== "list" && host === "") return bad("invalid", "host is the domain name");
3840 const refused = (error: string, fallback: string): Answer =>
@@ -46,11 +48,15 @@ export const tool: Tool = {
4648 : refused(listed.error, NO_SUCH_DOMAIN);
4749 }
4850 if (action === "add") {
49 const added = addAppDomain(ctx.app, user, repo, host);
51 const added = addAppDomain(ctx.app, user, repo, host, args["records"]);
5052 return added.ok
5153 ? good({ ...added.value, says: domainAddedText(added.value) })
5254 : refused(added.error, "That domain could not be added.");
5355 }
56 if (action === "keep") {
57 const kept = keepOldRecords(ctx.app, user, repo, host, args["records"]);
58 return kept.ok ? good(kept.value) : refused(kept.error, NO_SUCH_DOMAIN);
59 }
5460 if (action === "check") {
5561 const checked = await checkAppDomain(ctx.app, user, repo, host);
5662 return checked.ok
src/tui/screens/deploys.ts +40 −4
@@ -46,7 +46,9 @@
4646 * The app's admin sees its own domains under the head, each with its state
4747 * in the settings page's words (`domain_words.ts`), and gets `n`: a domains
4848 * screen where `+` adds one, `c` checks the selected one's DNS proof now and
49 * says the record still needed, and `-` removes it after `remove <host>? y/n`
49 * says the record still needed, `o` writes down the old DNS records before a
50 * cut-over (`keepOldRecords`) and shows them under the list, and `-` removes
51 * it after `remove <host>? y/n`
5052 * — `addAppDomain`, `checkAppDomain` and `removeAppDomain`, the page's and
5153 * MCP `app_domains`' actions. `listAppDomains` is the admin gate: anyone it
5254 * refuses sees neither the domains nor the key.
@@ -75,9 +77,11 @@ import {
7577 import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts";
7678 import { STOP_APP } from "../../web/danger_words.ts";
7779 import { envPhrase } from "../../apps/env_phrase.ts";
80 import { recordsFromLine, type OldRecord } from "../../core/domains.ts";
7881 import {
7982 addAppDomain,
8083 checkAppDomain,
84 keepOldRecords,
8185 listAppDomains,
8286 removeAppDomain,
8387 type AppDomainView,
@@ -713,6 +717,12 @@ function stillNeeded(d: AppDomainView): string {
713717 return d.verified ? "" : `Still needed: ${recordsInWords(d.records)}.`;
714718 }
715719
720 const recordLine = (r: OldRecord): string => `${r.type} ${r.name} ${r.value}${r.ttl === undefined ? "" : ` ${r.ttl}`}`;
721
722 /** `Old records: A app.example.com 203.0.113.7 300`, or "" with none kept. */
723 const oldLine = (d: AppDomainView): string =>
724 d.oldRecords.length === 0 ? "" : `Old records: ${d.oldRecords.map(recordLine).join("; ")}`;
725
716726 /**
717727 * The app's domains, for its admin. Every key goes through the action, which
718728 * gates again; a refusal is said in the page's words. `changed` re-reads the
@@ -767,6 +777,26 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
767777 after();
768778 });
769779
780 /** Write down what the name carries today, before the cut: `keepOldRecords`, as the API's PUT and MCP `keep`. */
781 const keep = (d: AppDomainView): void => {
782 ctx.push(
783 promptScreen(ctx, self, {
784 title: `Old records of ${d.host}`,
785 label: "Records",
786 placeholder: "A app.example.com 203.0.113.7 300; CNAME www.example.com host.example.net",
787 value: d.oldRecords.map(recordLine).join("; "),
788 hint: "type name value [ttl], separated by ;. Replaces what was kept.",
789 onSubmit: (line) => {
790 const kept = keepOldRecords(f.app, f.user, repo, d.host, recordsFromLine(line));
791 if (!kept.ok) return refusal(kept.error, NO_SUCH_DOMAIN);
792 ctx.toast(`Kept ${kept.value.oldRecords.length} old record${kept.value.oldRecords.length === 1 ? "" : "s"} of ${d.host}.`);
793 after();
794 return undefined;
795 },
796 }),
797 );
798 };
799
770800 const remove = (d: AppDomainView): void => {
771801 const choose = (choice: number): void => {
772802 if (choice !== 0) return;
@@ -797,7 +827,7 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
797827 : [
798828 binding(["j", "k"], "move"),
799829 binding(["+"], "add a domain"),
800 ...(domains.length > 0 ? [binding(["c"], "check its DNS now"), binding(["-"], "remove it")] : []),
830 ...(domains.length > 0 ? [binding(["c"], "check its DNS now"), binding(["o"], "old records"), binding(["-"], "remove it")] : []),
801831 binding(["r"], "refresh"),
802832 binding(["esc"], "back"),
803833 ];
@@ -805,7 +835,7 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
805835 load: async () => read(),
806836 render(area, s) {
807837 const c = ctx.theme.c;
808 const [head, body, foot] = split(area, "v", [len(1), fill(1), len(1)]);
838 const [head, body, foot] = split(area, "v", [len(1), fill(1), len(2)]);
809839 s.text(
810840 head!.x,
811841 head!.y,
@@ -825,7 +855,9 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
825855 hitList(ctx, body!, state, rows.length, {});
826856 const selected = domains[state.index];
827857 if (selected !== undefined)
828 s.text(foot!.x, foot!.y, truncate(stillNeeded(selected), foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w);
858 [stillNeeded(selected), oldLine(selected)].forEach((text, row) =>
859 s.text(foot!.x, foot!.y + row, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w),
860 );
829861 },
830862 key(e) {
831863 if (refused !== undefined) return false;
@@ -839,6 +871,10 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v
839871 check(selected);
840872 return true;
841873 }
874 if (e.key === "o" && selected !== undefined) {
875 keep(selected);
876 return true;
877 }
842878 if (e.key === "-" && selected !== undefined) {
843879 remove(selected);
844880 return true;
test/app-domains.test.ts +77
@@ -28,6 +28,7 @@ import { certificateSaid, probeCertificate } from "../src/apps/certificate.ts";
2828 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
2929 import { addDomain, getDomain, TOKEN_LENGTH } from "../src/core/domains.ts";
3030 import { listEvents } from "../src/core/events.ts";
31 import { eventVerb } from "../src/design/activity.ts";
3132 import type { Repo } from "../src/core/repos.ts";
3233 import { listRuns, type Run } from "../src/core/runs.ts";
3334 import { readRunLogTail } from "../src/runs/log.ts";
@@ -794,6 +795,82 @@ describe("an app gets its own domain", () => {
794795 expect(getDomain(app.db, "api.example.com")).toBeUndefined();
795796 });
796797
798 test("the old records are kept with the domain: written before the cut, read back in one list", async () => {
799 const old = [
800 { type: "a", name: "shop.example.net", value: "203.0.113.7", ttl: 300 },
801 { type: "CNAME", name: "www.shop.example.net", value: "shop.example.net" },
802 ];
803 const NEW = "shop.example.net";
804 const added = await api(DOMAINS, {
805 method: "POST",
806 body: JSON.stringify({ host: NEW, old_records: old }),
807 });
808 expect(added.status).toBe(201);
809 const listed = (await (await api(DOMAINS)).json()) as {
810 items: { host: string; oldRecords: unknown[] }[];
811 };
812 expect(listed.items.find((d) => d.host === NEW)?.oldRecords).toEqual([
813 { type: "A", name: NEW, value: "203.0.113.7", ttl: 300 },
814 { type: "CNAME", name: "www.shop.example.net", value: NEW },
815 ]);
816 // Replaced whole, by admins only, and bad input changes nothing.
817 const KEEP = `${DOMAINS}/${NEW}/old-records`;
818 const put = (token: string | null, records: unknown) =>
819 api(KEEP, { method: "PUT", token, body: JSON.stringify({ records }) });
820 expect((await put(rwToken, [])).status).toBe(403);
821 expect((await put(bobToken, [])).status).toBe(404);
822 expect((await put(adminToken, [{ type: "BOGUS", name: "x", value: "y" }])).status).toBe(422);
823 expect((await put(adminToken, [{ type: "A", name: "x", value: "y", ttl: -1 }])).status).toBe(422);
824 expect(getDomain(app.db, NEW)?.oldRecords).toHaveLength(2);
825 expect((await put(adminToken, [{ type: "A", name: NEW, value: "198.51.100.2" }])).status).toBe(200);
826 expect(getDomain(app.db, NEW)?.oldRecords).toEqual([
827 { type: "A", name: NEW, value: "198.51.100.2" },
828 ]);
829 expect(
830 (await api(`${DOMAINS}/nobody.example.net/old-records`, {
831 method: "PUT",
832 body: JSON.stringify({ records: [] }),
833 })).status,
834 ).toBe(422);
835 app.db.query("DELETE FROM app_domain WHERE host = ?1").run(NEW);
836 });
837
838 test("bad old records on add are refused and no domain is created; a non-admin is never shown them; the activity says it", async () => {
839 const HOST2 = "bad-records.example.net";
840 const bad = await api(DOMAINS, {
841 method: "POST",
842 body: JSON.stringify({ host: HOST2, old_records: [{ type: "BOGUS", name: "x", value: "y" }] }),
843 });
844 expect(bad.status).toBe(422);
845 expect(getDomain(app.db, HOST2)).toBeUndefined();
846 const notList = await api(DOMAINS, {
847 method: "POST",
848 body: JSON.stringify({ host: HOST2, old_records: "A 1.2.3.4" }),
849 });
850 expect(notList.status).toBe(422);
851 expect(getDomain(app.db, HOST2)).toBeUndefined();
852
853 const KEPT = "kept-records.example.net";
854 expect(
855 (await api(DOMAINS, { method: "POST", body: JSON.stringify({ host: KEPT }) })).status,
856 ).toBe(201);
857 const keep = await api(`${DOMAINS}/${KEPT}/old-records`, {
858 method: "PUT",
859 body: JSON.stringify({ records: [{ type: "A", name: KEPT, value: "203.0.113.9" }] }),
860 });
861 expect(keep.status).toBe(200);
862 // Neither the list nor the text of an answer reaches anyone who is not the app's admin.
863 for (const token of [rwToken, bobToken, null]) {
864 const res = await api(DOMAINS, { token });
865 expect(await res.text()).not.toContain("203.0.113.9");
866 }
867 const verb = listEvents(app.db, { limit: 50 })
868 .filter((e) => e.kind === "app.domain.old_records")
869 .map((e) => eventVerb(e));
870 expect(verb).toContain(`wrote down the old DNS records of ${KEPT}`);
871 app.db.query("DELETE FROM app_domain WHERE host = ?1").run(KEPT);
872 });
873
797874 test("removal makes the host unknown again", async () => {
798875 const removed = removeAppDomain(app, alice, repo, HOST);
799876 expect(removed.ok).toBe(true);
test/core-domains.test.ts +17
@@ -7,7 +7,10 @@ import { expect, test } from "bun:test";
77 import {
88 addDomain,
99 getVerifiedDomain,
10 getDomain,
1011 listDomains,
12 parseRecordsInput,
13 setOldRecords,
1114 primaryDomain,
1215 recordCheck,
1316 removeDomain,
@@ -81,3 +84,17 @@ test("primaryDomain names the verified one and removeDomain unserves it", () =>
8184 expect(listDomains(db, "a1")).toEqual([]);
8285 expect(removeDomain(db, "app.example.com")).toBe(false);
8386 });
87
88 test("old records are checked at the boundary, kept with the row, and replaced whole", () => {
89 const db = fresh();
90 add(db, "shop.example.net");
91 expect(getDomain(db, "shop.example.net")?.oldRecords).toEqual([]);
92 const parsed = parseRecordsInput([{ type: "aaaa", name: " shop.example.net ", value: "2001:db8::1", ttl: 60 }]);
93 expect(parsed).toEqual({ ok: true, value: [{ type: "AAAA", name: "shop.example.net", value: "2001:db8::1", ttl: 60 }] });
94 for (const bad of ["x", [1], [{ type: "A", name: "", value: "v" }], [{ type: "A", name: "n", value: "v", ttl: 1.5 }], Array(51).fill({ type: "A", name: "n", value: "v" })])
95 expect(parseRecordsInput(bad)).toEqual({ ok: false, error: "invalid_records" });
96 if (!parsed.ok) throw new Error("unreachable");
97 expect(setOldRecords(db, "Shop.Example.Net.", parsed.value)).toBe(true);
98 expect(getDomain(db, "shop.example.net")?.oldRecords).toEqual(parsed.value);
99 expect(setOldRecords(db, "none.example.net", parsed.value)).toBe(false);
100 });
test/mcp-app-domains.test.ts +24
@@ -120,6 +120,30 @@ describe("MCP app_domains", () => {
120120 expect((await tool(admin, { repo, action: "check", host: "app.example.com" })).error).toContain("no_such_domain");
121121 });
122122
123 test("keep writes the old records down, add takes them too, and bad ones create nothing", async () => {
124 const records = [{ type: "a", name: "old.example.com", value: "203.0.113.7", ttl: 300 }];
125 const added = await tool(admin, { repo, action: "add", host: "old.example.com", records });
126 expect(added.value.oldRecords).toEqual([{ type: "A", name: "old.example.com", value: "203.0.113.7", ttl: 300 }]);
127 const kept = await tool(admin, { repo, action: "keep", host: "old.example.com", records: [] });
128 expect(kept.value.oldRecords).toEqual([]);
129 const again = await tool(admin, { repo, action: "keep", host: "old.example.com", records });
130 expect(again.value.oldRecords).toHaveLength(1);
131 const listed = await tool(admin, { repo, action: "list" });
132 expect(listed.value.items[0].oldRecords).toHaveLength(1);
133 expect((await tool(admin, { repo, action: "keep", host: "old.example.com", records: [{ type: "X" }] })).error).toContain(
134 "invalid_records",
135 );
136 expect((await tool(admin, { repo, action: "keep", host: "nobody.example.com", records })).error).toContain("no_such_domain");
137 expect((await tool(bob, { repo, action: "keep", host: "old.example.com", records })).error).toContain("not_found");
138 expect((await tool(rw, { repo, action: "keep", host: "old.example.com", records })).error).toBeDefined();
139 expect(
140 (await tool(admin, { repo, action: "add", host: "never.example.com", records: [{ type: "X" }] })).error,
141 ).toContain("invalid_records");
142 const after = await tool(admin, { repo, action: "list" });
143 expect(after.value.items.map((d: any) => d.host)).toEqual(["old.example.com"]);
144 await tool(admin, { repo, action: "remove", host: "old.example.com" });
145 });
146
123147 test("a non-admin is refused", async () => {
124148 expect((await tool(bob, { repo, action: "list" })).error).toContain("not_found");
125149 expect((await tool(bob, { repo, action: "add", host: "bob.example.com" })).error).toContain("not_found");
test/tui-deploys.test.ts +13 −1
@@ -266,6 +266,18 @@ test("the app's admin lists, adds, checks and removes a domain; a reader is offe
266266 expect(checked).toContain("No TXT record at _forge-verify.parlour.example.com yet.");
267267 expect(checked).toContain("Still needed: a TXT record at _forge-verify.parlour.example.com");
268268
269 // `o` writes the old records down, replacing what was kept; a bad line is refused and keeps what was.
270 const line = "A parlour.example.com 203.0.113.7 300; CNAME www.parlour.example.com parlour.example.net";
271 const kept = await drive(alice, ["n", "o", ...line, "enter"]);
272 expect(kept).toContain("Old records: A parlour.example.com 203.0.113.7 300; CNAME www.parlour.example.com");
273 const oldOf = () => {
274 const l = listAppDomains(forge, alice, r.value);
275 return l.ok ? l.value[0]!.oldRecords.length : -1;
276 };
277 expect(oldOf()).toBe(2);
278 expect(await drive(alice, ["n", "o", ..."; BOGUS x y", "enter"])).toContain("Old records are a list of up to 50");
279 expect(oldOf()).toBe(2);
280
269281 // A reader sees neither the domains nor the key, and the screen refuses them in words.
270282 const d = await signUp(forge, {
271283 username: "dana",
@@ -303,4 +315,4 @@ test("the app's admin lists, adds, checks and removes a domain; a reader is offe
303315 } finally {
304316 await runtime.stop();
305317 }
306 });
318 }, 20_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs