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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11719-platform-app-move-gap-store-the-old-dns → main 9 commits fork
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`; | ||
| 52 | 52 | | `agents` | — | Agents you may see, as GET /api/v1/agents | 160 | |
| 53 | 53 | | `app_data_import` | — | Load a SQL dump into an app's database and report per-table counts and checksums; admins only | 339 | |
| 54 | 54 | | `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 | | |
| 56 | 56 | | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 | |
| 57 | 57 | | `app_members` | — | People a private app names, who may enter it and its previews | 297 | |
| 58 | 58 | | `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: | ||
| 7 | 7 | |
| 8 | 8 | 1. **Write the old records down first.** Every record at the name (A, AAAA, |
| 9 | 9 | 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. | |
| 12 | 15 | 2. **Add the name** (Settings → App → Domains, `app_domains` `add`, or |
| 13 | 16 | `POST …/app/domains`). The answer lists the TXT proof and the CNAME or |
| 14 | 17 | 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: | ||
| 23 | 26 | 4. **Cut:** replace the old A/CNAME with the CNAME/ALIAS the forge gave. The |
| 24 | 27 | first request to the new name pays for issuing the certificate (a second |
| 25 | 28 | 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 | |
| 27 | 30 | host as each resolver's cache of the new record expires, so keep the TTL |
| 28 | 31 | low (`platform-app-move.md`, §6) until the shop is settled. |
| 29 | 32 | 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: | ||
| 31 | 34 | and the old host's certificate was never touched. |
| 32 | 35 | |
| 33 | 36 | 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, | |
| 36 | 39 | not as new code. |
| 37 | 40 | |
| 38 | 41 | ## What keeps these claims true |
| @@ -43,5 +46,8 @@ All in `test/app-domains.test.ts`: | ||
| 43 | 46 | app at the host and every URL follows"; |
| 44 | 47 | - before verification the forge answers for itself and the ask says 404: |
| 45 | 48 | "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"; | |
| 46 | 52 | - removal makes the host unknown again and the ask 404: "removal makes the |
| 47 | 53 | host unknown again". |
src/actions/domain_words.ts +2
| @@ -12,6 +12,8 @@ const REFUSALS: Readonly<Record<string, string>> = { | ||
| 12 | 12 | "A domain is a hostname: letters, digits and hyphens in at least two labels, like app.example.com.", |
| 13 | 13 | reserved_host: "That name is the forge's own to hand out.", |
| 14 | 14 | 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.", | |
| 15 | 17 | no_runtime: "Nothing is serving apps on this forge.", |
| 16 | 18 | }; |
| 17 | 19 |
src/actions/domains.ts +42 −2
| @@ -15,10 +15,13 @@ import { | ||
| 15 | 15 | addDomain, |
| 16 | 16 | getDomain, |
| 17 | 17 | listDomains, |
| 18 | parseRecordsInput, | |
| 19 | setOldRecords, | |
| 18 | 20 | recordCheck, |
| 19 | 21 | removeDomain, |
| 20 | 22 | type AddDomainError, |
| 21 | 23 | type AppDomain, |
| 24 | type OldRecord, | |
| 22 | 25 | } from "../core/domains.ts"; |
| 23 | 26 | import type { Repo } from "../core/repos.ts"; |
| 24 | 27 | import type { User } from "../core/users.ts"; |
| @@ -34,6 +37,8 @@ export interface AppDomainView { | ||
| 34 | 37 | readonly said: string; |
| 35 | 38 | /** The proof, and where the traffic goes. */ |
| 36 | 39 | readonly records: DnsRecord[]; |
| 40 | /** What the name carried before the cut-over: put these back to revert. */ | |
| 41 | readonly oldRecords: readonly OldRecord[]; | |
| 37 | 42 | } |
| 38 | 43 | |
| 39 | 44 | export type DomainError = "not_found" | "no_runtime"; |
| @@ -85,6 +90,7 @@ function view( | ||
| 85 | 90 | verifiedAt: domain.verifiedAt, |
| 86 | 91 | checkedAt: domain.checkedAt, |
| 87 | 92 | said: domain.said, |
| 93 | oldRecords: domain.oldRecords, | |
| 88 | 94 | records: recordsFor( |
| 89 | 95 | domain.host, |
| 90 | 96 | domain.token, |
| @@ -115,12 +121,15 @@ export function addAppDomain( | ||
| 115 | 121 | actor: User | undefined, |
| 116 | 122 | repo: Repo, |
| 117 | 123 | host: string, |
| 118 | ): Result<AppDomainView, DomainError | AddDomainError> { | |
| 124 | oldRecords?: unknown, | |
| 125 | ): Result<AppDomainView, DomainError | AddDomainError | "invalid_records"> { | |
| 119 | 126 | const gate = admitted(app, actor, repo); |
| 120 | 127 | if (!gate.ok) return gate; |
| 121 | 128 | const runtime = serving(app); |
| 122 | 129 | if (!runtime.ok) return runtime; |
| 123 | 130 | const { admin, record } = gate.value; |
| 131 | const kept = oldRecords === undefined ? ok<OldRecord[]>([]) : parseRecordsInput(oldRecords); | |
| 132 | if (!kept.ok) return kept; | |
| 124 | 133 | const added = addDomain( |
| 125 | 134 | app.db, |
| 126 | 135 | { host, appId: record.id, createdBy: admin.id }, |
| @@ -132,13 +141,44 @@ export function addAppDomain( | ||
| 132 | 141 | }, |
| 133 | 142 | ); |
| 134 | 143 | if (!added.ok) return added; |
| 144 | if (kept.value.length > 0) setOldRecords(app.db, added.value.host, kept.value); | |
| 135 | 145 | app.bus.append({ |
| 136 | 146 | kind: "app.domain.added", |
| 137 | 147 | repoId: repo.id, |
| 138 | 148 | actorId: admin.id, |
| 139 | 149 | payload: { host: added.value.host, slug: record.slug }, |
| 140 | 150 | }); |
| 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 })); | |
| 142 | 182 | } |
| 143 | 183 | |
| 144 | 184 | /** |
src/contract/openapi/routes/index.ts +1
| @@ -412,6 +412,7 @@ export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts | ||
| 412 | 412 | export { route as putApiV1ForgesNamePlan } from "./putApiV1ForgesNamePlan.ts"; |
| 413 | 413 | export { route as putApiV1Front } from "./putApiV1Front.ts"; |
| 414 | 414 | export { route as putApiV1PacksName } from "./putApiV1PacksName.ts"; |
| 415 | export { route as putApiV1ReposOwnerNameAppDomainsHostOldRecords } from "./putApiV1ReposOwnerNameAppDomainsHostOldRecords.ts"; | |
| 415 | 416 | export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts"; |
| 416 | 417 | export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts"; |
| 417 | 418 | 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 { | ||
| 24 | 24 | readonly checkedAt: string | null; |
| 25 | 25 | /** What the last check said, one sentence for a person. */ |
| 26 | 26 | 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; | |
| 27 | 38 | } |
| 28 | 39 | |
| 29 | 40 | interface DomainRow { |
| @@ -36,6 +47,61 @@ interface DomainRow { | ||
| 36 | 47 | verified_at: string | null; |
| 37 | 48 | checked_at: string | null; |
| 38 | 49 | 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 | ); | |
| 39 | 105 | } |
| 40 | 106 | |
| 41 | 107 | const toDomain = (r: DomainRow): AppDomain => ({ |
| @@ -48,6 +114,7 @@ const toDomain = (r: DomainRow): AppDomain => ({ | ||
| 48 | 114 | verifiedAt: r.verified_at, |
| 49 | 115 | checkedAt: r.checked_at, |
| 50 | 116 | said: r.said, |
| 117 | oldRecords: parseOldRecords(r.old_records), | |
| 51 | 118 | }); |
| 52 | 119 | |
| 53 | 120 | 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 { | ||
| 87 | 87 | const which = typeof host === "string" ? host : "a domain"; |
| 88 | 88 | if (kind === "app.domain.added") return `gave the app the domain ${which}`; |
| 89 | 89 | 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}`; | |
| 90 | 91 | return `removed the domain ${which}`; |
| 91 | 92 | } |
| 92 | 93 | if (kind === "build.shots") { |
src/http/api/app.ts +13 −2
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import { readAppMembers, setAppMembersThrough, type AppMembersError } from "../../actions/app_members.ts"; |
| 10 | 10 | import { appView, appVisitors, deleteAppEnvVar, disableApp, enableApp, listAppEnv, openEnvUrl, redeployEnv, setAppEnvVar, stopEnv, updateAppSettings, type AppSettingsInput } from "../../actions/apps.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts"; |
| 14 | 14 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 15 | 15 | import { appsRuntime } from "../../apps/runtime.ts"; |
| @@ -175,7 +175,7 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 175 | 175 | const repo = loadRepo(ctx); |
| 176 | 176 | if (!repo) return notFound(); |
| 177 | 177 | 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); | |
| 179 | 179 | if (!added.ok) return respondAccess(added.error, 422); |
| 180 | 180 | return json(added.value, 201); |
| 181 | 181 | }); |
| @@ -199,6 +199,17 @@ export function appRoutes(router: Router<Handler>): void { | ||
| 199 | 199 | }, |
| 200 | 200 | ); |
| 201 | 201 | |
| 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 | ||
| 202 | 213 | router.add( |
| 203 | 214 | "DELETE", |
| 204 | 215 | "/api/v1/repos/:owner/:name/app/domains/:host", |
src/http/mcp/tools/app_domains.ts +11 −5
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { |
| 2 | 2 | addAppDomain, |
| 3 | 3 | checkAppDomain, |
| 4 | keepOldRecords, | |
| 4 | 5 | listAppDomains, |
| 5 | 6 | removeAppDomain, |
| 6 | 7 | } from "../../../actions/domains.ts"; |
| @@ -15,14 +16,15 @@ import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer, str } f | ||
| 15 | 16 | |
| 16 | 17 | export const tool: Tool = { |
| 17 | 18 | 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.", | |
| 20 | 21 | inputSchema: { |
| 21 | 22 | type: "object", |
| 22 | 23 | properties: { |
| 23 | 24 | repo: REPO_ARG, |
| 24 | 25 | action: { type: "string" }, |
| 25 | 26 | host: { type: "string" }, |
| 27 | records: { type: "array" }, | |
| 26 | 28 | }, |
| 27 | 29 | }, |
| 28 | 30 | writes: true, |
| @@ -31,8 +33,8 @@ export const tool: Tool = { | ||
| 31 | 33 | const repo = repoArg(ctx, user, args); |
| 32 | 34 | if (isAnswer(repo)) return repo; |
| 33 | 35 | 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"); | |
| 36 | 38 | const host = str(args["host"]); |
| 37 | 39 | if (action !== "list" && host === "") return bad("invalid", "host is the domain name"); |
| 38 | 40 | const refused = (error: string, fallback: string): Answer => |
| @@ -46,11 +48,15 @@ export const tool: Tool = { | ||
| 46 | 48 | : refused(listed.error, NO_SUCH_DOMAIN); |
| 47 | 49 | } |
| 48 | 50 | if (action === "add") { |
| 49 | const added = addAppDomain(ctx.app, user, repo, host); | |
| 51 | const added = addAppDomain(ctx.app, user, repo, host, args["records"]); | |
| 50 | 52 | return added.ok |
| 51 | 53 | ? good({ ...added.value, says: domainAddedText(added.value) }) |
| 52 | 54 | : refused(added.error, "That domain could not be added."); |
| 53 | 55 | } |
| 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 | } | |
| 54 | 60 | if (action === "check") { |
| 55 | 61 | const checked = await checkAppDomain(ctx.app, user, repo, host); |
| 56 | 62 | return checked.ok |
src/tui/screens/deploys.ts +40 −4
| @@ -46,7 +46,9 @@ | ||
| 46 | 46 | * The app's admin sees its own domains under the head, each with its state |
| 47 | 47 | * in the settings page's words (`domain_words.ts`), and gets `n`: a domains |
| 48 | 48 | * 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` | |
| 50 | 52 | * — `addAppDomain`, `checkAppDomain` and `removeAppDomain`, the page's and |
| 51 | 53 | * MCP `app_domains`' actions. `listAppDomains` is the admin gate: anyone it |
| 52 | 54 | * refuses sees neither the domains nor the key. |
| @@ -75,9 +77,11 @@ import { | ||
| 75 | 77 | import { previewBuild, previewBuildRefusalText } from "../../actions/build_preview.ts"; |
| 76 | 78 | import { STOP_APP } from "../../web/danger_words.ts"; |
| 77 | 79 | import { envPhrase } from "../../apps/env_phrase.ts"; |
| 80 | import { recordsFromLine, type OldRecord } from "../../core/domains.ts"; | |
| 78 | 81 | import { |
| 79 | 82 | addAppDomain, |
| 80 | 83 | checkAppDomain, |
| 84 | keepOldRecords, | |
| 81 | 85 | listAppDomains, |
| 82 | 86 | removeAppDomain, |
| 83 | 87 | type AppDomainView, |
| @@ -713,6 +717,12 @@ function stillNeeded(d: AppDomainView): string { | ||
| 713 | 717 | return d.verified ? "" : `Still needed: ${recordsInWords(d.records)}.`; |
| 714 | 718 | } |
| 715 | 719 | |
| 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 | ||
| 716 | 726 | /** |
| 717 | 727 | * The app's domains, for its admin. Every key goes through the action, which |
| 718 | 728 | * 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 | ||
| 767 | 777 | after(); |
| 768 | 778 | }); |
| 769 | 779 | |
| 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 | ||
| 770 | 800 | const remove = (d: AppDomainView): void => { |
| 771 | 801 | const choose = (choice: number): void => { |
| 772 | 802 | if (choice !== 0) return; |
| @@ -797,7 +827,7 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 797 | 827 | : [ |
| 798 | 828 | binding(["j", "k"], "move"), |
| 799 | 829 | 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")] : []), | |
| 801 | 831 | binding(["r"], "refresh"), |
| 802 | 832 | binding(["esc"], "back"), |
| 803 | 833 | ]; |
| @@ -805,7 +835,7 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 805 | 835 | load: async () => read(), |
| 806 | 836 | render(area, s) { |
| 807 | 837 | 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)]); | |
| 809 | 839 | s.text( |
| 810 | 840 | head!.x, |
| 811 | 841 | head!.y, |
| @@ -825,7 +855,9 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 825 | 855 | hitList(ctx, body!, state, rows.length, {}); |
| 826 | 856 | const selected = domains[state.index]; |
| 827 | 857 | 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 | ); | |
| 829 | 861 | }, |
| 830 | 862 | key(e) { |
| 831 | 863 | if (refused !== undefined) return false; |
| @@ -839,6 +871,10 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 839 | 871 | check(selected); |
| 840 | 872 | return true; |
| 841 | 873 | } |
| 874 | if (e.key === "o" && selected !== undefined) { | |
| 875 | keep(selected); | |
| 876 | return true; | |
| 877 | } | |
| 842 | 878 | if (e.key === "-" && selected !== undefined) { |
| 843 | 879 | remove(selected); |
| 844 | 880 | return true; |
test/app-domains.test.ts +77
| @@ -28,6 +28,7 @@ import { certificateSaid, probeCertificate } from "../src/apps/certificate.ts"; | ||
| 28 | 28 | import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts"; |
| 29 | 29 | import { addDomain, getDomain, TOKEN_LENGTH } from "../src/core/domains.ts"; |
| 30 | 30 | import { listEvents } from "../src/core/events.ts"; |
| 31 | import { eventVerb } from "../src/design/activity.ts"; | |
| 31 | 32 | import type { Repo } from "../src/core/repos.ts"; |
| 32 | 33 | import { listRuns, type Run } from "../src/core/runs.ts"; |
| 33 | 34 | import { readRunLogTail } from "../src/runs/log.ts"; |
| @@ -794,6 +795,82 @@ describe("an app gets its own domain", () => { | ||
| 794 | 795 | expect(getDomain(app.db, "api.example.com")).toBeUndefined(); |
| 795 | 796 | }); |
| 796 | 797 | |
| 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 | ||
| 797 | 874 | test("removal makes the host unknown again", async () => { |
| 798 | 875 | const removed = removeAppDomain(app, alice, repo, HOST); |
| 799 | 876 | expect(removed.ok).toBe(true); |
test/core-domains.test.ts +17
| @@ -7,7 +7,10 @@ import { expect, test } from "bun:test"; | ||
| 7 | 7 | import { |
| 8 | 8 | addDomain, |
| 9 | 9 | getVerifiedDomain, |
| 10 | getDomain, | |
| 10 | 11 | listDomains, |
| 12 | parseRecordsInput, | |
| 13 | setOldRecords, | |
| 11 | 14 | primaryDomain, |
| 12 | 15 | recordCheck, |
| 13 | 16 | removeDomain, |
| @@ -81,3 +84,17 @@ test("primaryDomain names the verified one and removeDomain unserves it", () => | ||
| 81 | 84 | expect(listDomains(db, "a1")).toEqual([]); |
| 82 | 85 | expect(removeDomain(db, "app.example.com")).toBe(false); |
| 83 | 86 | }); |
| 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", () => { | ||
| 120 | 120 | expect((await tool(admin, { repo, action: "check", host: "app.example.com" })).error).toContain("no_such_domain"); |
| 121 | 121 | }); |
| 122 | 122 | |
| 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 | ||
| 123 | 147 | test("a non-admin is refused", async () => { |
| 124 | 148 | expect((await tool(bob, { repo, action: "list" })).error).toContain("not_found"); |
| 125 | 149 | 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 | ||
| 266 | 266 | expect(checked).toContain("No TXT record at _forge-verify.parlour.example.com yet."); |
| 267 | 267 | expect(checked).toContain("Still needed: a TXT record at _forge-verify.parlour.example.com"); |
| 268 | 268 | |
| 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 | ||
| 269 | 281 | // A reader sees neither the domains nor the key, and the screen refuses them in words. |
| 270 | 282 | const d = await signUp(forge, { |
| 271 | 283 | username: "dana", |
| @@ -303,4 +315,4 @@ test("the app's admin lists, adds, checks and removes a domain; a reader is offe | ||
| 303 | 315 | } finally { |
| 304 | 316 | await runtime.stop(); |
| 305 | 317 | } |
| 306 | }); | |
| 318 | }, 20_000); |
new above
offline