Public: anyone can read this; the crew can change it.
Platform app move gap: edit an own domain's DNS at the registrar #11735
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11717-platform-app-move-gap-edit-an-own-domain → main 9 commits fork 1 thread · 1 unresolved
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`; | ||
| 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, keep, remove | 234 | | |
| 55 | | `app_domains` | — | App domains: list, add, check, keep, remove; dns_proof, dns_cut, dns_revert | 266 | | |
| 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 +31 −4
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | The step of a move that can strand a |
| 4 | 4 | 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 | |
| 6 | 6 | 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, |
| @@ -33,9 +33,36 @@ the owner's records, put back by hand in one edit. In order: | ||
| 33 | 33 | (`remove`); removing it is only tidiness once DNS no longer points here, |
| 34 | 34 | and the old host's certificate was never touched. |
| 35 | 35 | |
| 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, | |
| 39 | 66 | not as new code. |
| 40 | 67 | |
| 41 | 68 | ## 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" | |
| 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 { | ||
| 172 | 172 | * its own, so proving `app.example.com` needs no network in a test. |
| 173 | 173 | */ |
| 174 | 174 | 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; | |
| 175 | 177 | /** |
| 176 | 178 | * What a check asks of a verified domain to read its certificate's expiry: |
| 177 | 179 | * a TLS handshake unless a test hands in its own. |
src/http/mcp/tools/app_domains.ts +19 −5
| @@ -5,6 +5,11 @@ import { | ||
| 5 | 5 | listAppDomains, |
| 6 | 6 | removeAppDomain, |
| 7 | 7 | } from "../../../actions/domains.ts"; |
| 8 | import { | |
| 9 | dnsRefusal, | |
| 10 | DNS_STEPS, | |
| 11 | editAppDomainDns, | |
| 12 | } from "../../../actions/domain_dns.ts"; | |
| 8 | 13 | import { |
| 9 | 14 | NO_SUCH_DOMAIN, |
| 10 | 15 | domainAddedText, |
| @@ -16,15 +21,15 @@ import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer, str } f | ||
| 16 | 21 | |
| 17 | 22 | export const tool: Tool = { |
| 18 | 23 | 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.", | |
| 21 | 26 | inputSchema: { |
| 22 | 27 | type: "object", |
| 23 | 28 | properties: { |
| 24 | 29 | repo: REPO_ARG, |
| 25 | 30 | action: { type: "string" }, |
| 26 | 31 | host: { type: "string" }, |
| 27 | records: { type: "array" }, | |
| 32 | records: { type: "array", maxItems: 50 }, | |
| 28 | 33 | }, |
| 29 | 34 | }, |
| 30 | 35 | writes: true, |
| @@ -33,9 +38,18 @@ export const tool: Tool = { | ||
| 33 | 38 | const repo = repoArg(ctx, user, args); |
| 34 | 39 | if (isAnswer(repo)) return repo; |
| 35 | 40 | 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"); | |
| 38 | 45 | 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 | } | |
| 39 | 53 | if (action !== "list" && host === "") return bad("invalid", "host is the domain name"); |
| 40 | 54 | const refused = (error: string, fallback: string): Answer => |
| 41 | 55 | error === "not_found" |
src/tui/screens/deploys.ts +75 −4
| @@ -50,8 +50,11 @@ | ||
| 50 | 50 | * cut-over (`keepOldRecords`) and shows them under the list, and `-` removes |
| 51 | 51 | * it after `remove <host>? y/n` |
| 52 | 52 | * — `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. | |
| 55 | 58 | * |
| 56 | 59 | * The admin who is a person also gets `i`: the app's prompt source, the |
| 57 | 60 | * Instagram account whose comments become the crew's tell (`prompts.ts`). |
| @@ -133,6 +136,8 @@ import { pullScreen } from "./pull.ts"; | ||
| 133 | 136 | import { local } from "./where.ts"; |
| 134 | 137 | import { appMembersScreen } from "./appmembers.ts"; |
| 135 | 138 | import { mayManagePrompts, promptsScreen } from "./prompts.ts"; |
| 139 | import { editDnsFromTerminal } from "./domain_dns.ts"; | |
| 140 | import type { DnsStep } from "../../actions/domain_dns.ts"; | |
| 136 | 141 | |
| 137 | 142 | const LIMIT = 50; |
| 138 | 143 | |
| @@ -733,6 +738,8 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 733 | 738 | let domains: AppDomainView[] = []; |
| 734 | 739 | let refused: string | undefined; |
| 735 | 740 | 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 = ""; | |
| 736 | 743 | |
| 737 | 744 | const refusal = (code: string, fallback: string): string => |
| 738 | 745 | code === "not_found" ? NOT_ADMIN : domainRefusalText(code, fallback); |
| @@ -777,6 +784,49 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 777 | 784 | after(); |
| 778 | 785 | }); |
| 779 | 786 | |
| 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 | ||
| 780 | 830 | /** Write down what the name carries today, before the cut: `keepOldRecords`, as the API's PUT and MCP `keep`. */ |
| 781 | 831 | const keep = (d: AppDomainView): void => { |
| 782 | 832 | ctx.push( |
| @@ -827,7 +877,16 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 827 | 877 | : [ |
| 828 | 878 | binding(["j", "k"], "move"), |
| 829 | 879 | 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 | : []), | |
| 831 | 890 | binding(["r"], "refresh"), |
| 832 | 891 | binding(["esc"], "back"), |
| 833 | 892 | ]; |
| @@ -855,7 +914,7 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 855 | 914 | hitList(ctx, body!, state, rows.length, {}); |
| 856 | 915 | const selected = domains[state.index]; |
| 857 | 916 | if (selected !== undefined) |
| 858 | [stillNeeded(selected), oldLine(selected)].forEach((text, row) => | |
| 917 | [told !== "" ? told : stillNeeded(selected), oldLine(selected)].forEach((text, row) => | |
| 859 | 918 | s.text(foot!.x, foot!.y + row, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w), |
| 860 | 919 | ); |
| 861 | 920 | }, |
| @@ -879,6 +938,18 @@ export function domainsScreen(f: Session, ctx: Ctx, repo: Repo, changed: () => v | ||
| 879 | 938 | remove(selected); |
| 880 | 939 | return true; |
| 881 | 940 | } |
| 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 | } | |
| 882 | 953 | if (e.key === "r") { |
| 883 | 954 | read(); |
| 884 | 955 | 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", () => { | ||
| 170 | 170 | expect(again.error).toContain(domainRefusalText("taken", "")); |
| 171 | 171 | await tool(admin, { repo, action: "remove", host: "dup.example.com" }); |
| 172 | 172 | }); |
| 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 | }); | |
| 173 | 182 | }); |
new above
offline
Non-blocking:
left_halfis dead; the half-done case is theDnsLostobject.