From e3ebaadbc8b7abf074eae5eef06e3b1129967a17 Mon Sep 17 00:00:00 2001 From: Lyu Date: Sat, 19 Sep 2026 20:23:33 -0700 Subject: [PATCH] feat(domain): delegate a bought domain's zone, and take it to another registrar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform #484 added both exits; the CLI had neither. - `domain nameservers show|set|reset` — whose nameservers the registrar holds, delegate the zone away, put it back. `set` takes down every hostname the domain serves, because the records an attach published live in the zone being left, so it prints each one with the platform's reason rather than reporting success. - `domain transfer unlock|lock|code` — the registrar lock and the EPP code, two steps because the code alone moves nothing. `unlock` names the registry's own 60-day lock when that still holds the domain. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/domain.ts | 59 ++++++++++++++++-- src/index.ts | 16 +++++ test/domain.test.ts | 136 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 206 insertions(+), 5 deletions(-) diff --git a/src/commands/domain.ts b/src/commands/domain.ts index 494fbe4..07abd97 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -6,9 +6,11 @@ import { domainDeps, domainTarget, setDomain, checkDomain, removeDomain, type Do type Quote = { domainName: string; purchasable: boolean; priceCents?: number; renewalPriceCents?: number; reason?: string } type Order = { id: string; domainName: string; years: number; status: string; priceCents: number; renewalPriceCents: number | null; checkoutUrl?: string; failedReason: string | null } type HostnameState = { hostname: string; state: string; reason?: string; service: string | null } -type Purchased = { domainName: string; status: string; hostnames: HostnameState[]; expiresAt: string | null; autorenew: boolean } +type Purchased = { domainName: string; status: string; hostnames: HostnameState[]; expiresAt: string | null; autorenew: boolean; locked: boolean; nameservers: string[]; delegated: boolean; transferLockExpiresAt: string | null } type DnsRecord = { id: number; type: string; fqdn: string; answer: string; ttl: number; priority?: number; managed: boolean; hostname?: string } +type RecordsOpts = { org?: string; json?: boolean } + const usd = (cents: number): string => `$${(cents / 100).toFixed(2)}` // --org wins; otherwise the linked project's org — the org verbs must not force a project link. @@ -100,12 +102,19 @@ export async function domainAttach(host: string, opts: { branch?: string; group? function domainLines(d: Purchased, linked = true): string[] { const out = [`${d.domainName} ${d.status}${d.expiresAt ? ` (expires ${d.expiresAt.slice(0, 10)}${d.autorenew ? ', auto-renews' : ''})` : ''}`] // Vacuously true for a domain with no hostnames, which is every domain until something attaches. - if (linked && d.hostnames.every((h) => h.state === 'failed')) { + // Not while delegated: the platform fails every hostname on the way out, and refuses the attach. + if (linked && !d.delegated && d.hostnames.every((h) => h.state === 'failed')) { const names = d.hostnames.map((h) => h.hostname) // Attaching the bought name itself re-attaches its www. const retry = names.includes(d.domainName) ? names.filter((h) => h !== `www.${d.domainName}`) : names out.push(` nothing serving — ${(retry.length ? retry : [d.domainName]).map((h) => `insta domain attach ${h}`).join('; ')}`) } + // The zone answers elsewhere, so nothing published here resolves and an attach is refused. The + // repair is a next action, so it follows `linked` like the others: under --org it would name this org. + if (d.delegated) { + out.push(` delegated to ${d.nameservers.join(', ')}${linked ? '' : ' — attach is refused'}`) + if (linked) out.push(` attach is refused until: insta domain nameservers reset ${d.domainName}`) + } const w = Math.max(0, ...d.hostnames.map((x) => x.hostname.length)) for (const h of d.hostnames) out.push(` ${h.hostname.padEnd(w)} ${h.state}${h.service ? ` → ${h.service}` : ''}${h.reason ? ` — ${h.reason}` : ''}`) return out @@ -139,9 +148,51 @@ export async function domainStatus(name: string, opts: { org?: string; json?: bo for (const line of domain ? domainLines(domain, !opts.org) : orderStatusLines(order!, !opts.org)) info(line) } -// ---- records ---- +// ---- nameservers and transferring out ---- -type RecordsOpts = { org?: string; json?: boolean } +const domainPath = (orgId: string, domainName: string): string => + `/orgs/${orgId}/domains/${encodeURIComponent(domainName)}` + +export async function domainNameserversSet(domainName: string, hosts: string[], opts: RecordsOpts, deps?: DomainDeps): Promise { + const nameservers = hosts.flatMap((h) => h.split(/[\s,]+/)).map((h) => h.replace(/\.$/, '')).filter(Boolean) + if (!nameservers.length) die("name at least one nameserver, or `insta domain nameservers reset ` to restore the registrar's own") + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request('PUT', `${domainPath(orgId, domainName)}/nameservers`, { nameservers }) + if (opts.json) return printJson(r) + for (const line of domainLines(r, !opts.org)) info(line) +} + +export async function domainNameserversReset(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request('DELETE', `${domainPath(orgId, domainName)}/nameservers`) + if (opts.json) return printJson(r) + for (const line of domainLines(r, !opts.org)) info(line) +} + +export async function domainTransferLock(domainName: string, mode: string, opts: RecordsOpts, deps?: DomainDeps): Promise { + if (mode !== 'on' && mode !== 'off') die('mode must be on or off') + const locked = mode === 'on' + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request('PATCH', domainPath(orgId, domainName), { locked }) + if (opts.json) return printJson(r) + info(`${r.domainName} transfer lock ${r.locked ? 'on' : 'off'}`) + if (!r.locked) { + // ICANN's post-registration lock outranks this one and nothing here can waive it. + if (r.transferLockExpiresAt && new Date(r.transferLockExpiresAt).getTime() > Date.now()) { + info(` ICANN holds the registration until ${r.transferLockExpiresAt.slice(0, 10)} whatever this says`) + } + if (!opts.org) info(` authorization code: insta domain transfer code ${r.domainName}`) + } +} + +export async function domainTransferCode(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request<{ authCode: string }>('POST', `${domainPath(orgId, domainName)}/auth-code`) + if (opts.json) return printJson(r) + info(r.authCode) +} + +// ---- records ---- export function recordLines(records: DnsRecord[]): string[] { const w = (pick: (r: DnsRecord) => string) => Math.max(...records.map((r) => pick(r).length)) diff --git a/src/index.ts b/src/index.ts index 4611860..c5918f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -215,6 +215,22 @@ dom.command('list').description("Domains bought through InstaCloud in this org dom.command('status ').description("A bought domain's order and attach state") .option('--org ', "target org (default: linked project's org)").option('--json') .action(guard((name, o) => domainCmd.domainStatus(name, o))) +const ns = dom.command('nameservers').description("Delegate a bought domain's zone to nameservers you name, or put it back on InstaCloud's registrar") +ns.command('set ').description("Delegate the zone — the nameservers must already host it. Every hostname the domain serves stops answering, unless they are the registrar's own: the records an attach published live in the zone you are leaving") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, hosts, o) => domainCmd.domainNameserversSet(domain, hosts, o))) +ns.command('reset ').description("Put the zone back on the registrar's own nameservers; a hostname it took down is re-attached with `insta domain attach`") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, o) => domainCmd.domainNameserversReset(domain, o))) + +const xfer = dom.command('transfer').description('Take a bought domain to another registrar — open the lock, then read the code (all three need org admin)') +xfer.command('lock ').description("Open or close the registrar transfer lock (mode: on|off). ICANN's own 60-day lock on a new registration outranks it") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, mode, o) => domainCmd.domainTransferLock(domain, mode, o))) +xfer.command('code ').description('The EPP authorization code the gaining registrar asks for — it moves nothing on its own') + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, o) => domainCmd.domainTransferCode(domain, o))) + const rec = dom.command('records').description('DNS records of a bought domain — the zone InstaCloud holds at the registrar') rec.command('list ').description('Every record in the zone, managed ones marked') .option('--org ', "target org (default: linked project's org)").option('--json') diff --git a/test/domain.test.ts b/test/domain.test.ts index bd82bcf..6e0d0a9 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, afterAll } from 'vitest' -import { domainSearch, domainBuy, domainAttach, domainCheck, domainDetach, domainList, domainStatus, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js' +import { domainSearch, domainBuy, domainAttach, domainCheck, domainDetach, domainList, domainNameserversReset, domainNameserversSet, domainStatus, domainTransferCode, domainTransferLock, domainRecordsAdd, domainRecordsList, domainRecordsRemove, domainRecordsSet, ownerOf, searchLines } from '../src/commands/domain.js' import type { DomainDeps } from '../src/commands/compute.js' const services = [ @@ -319,3 +319,137 @@ describe('domain records', () => { expect(JSON.parse(stdout.at(-1)!)).toEqual({ ok: true }) }) }) + +const bought = (over: Record = {}) => ({ + domainName: 'myapp.com', + status: 'active', + hostnames: [{ hostname: 'www.myapp.com', state: 'active', service: 'web' }], + expiresAt: null, + autorenew: true, + locked: true, + nameservers: [], + delegated: false, + transferLockExpiresAt: null, + ...over, +}) + +describe('domain nameservers', () => { + it('sends one list however the nameservers were separated, trailing dots and all', async () => { + const { deps: d, calls } = deps({ '/nameservers': bought() }) + await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com.,rob.ns.cloudflare.com'], {}, d) + expect(calls[0]).toMatchObject({ + method: 'PUT', + path: '/orgs/org1/domains/myapp.com/nameservers', + body: { nameservers: ['kate.ns.cloudflare.com', 'rob.ns.cloudflare.com'] }, + }) + }) + + it('refuses an empty list rather than sending one, and names the way back', async () => { + const { deps: d, calls } = deps({}) + await expect(domainNameserversSet('myapp.com', [' '], {}, d)).rejects.toThrow('exit 1') + expect(calls).toEqual([]) + expect(stderr.join('')).toContain('nameservers reset') + }) + + // The answer is the domain, so the hostnames it just took down are printed by the one renderer + // `list` and `status` already use — reason included. + it('prints what the move took down, and why an attach is now refused', async () => { + const answer = bought({ + nameservers: ['kate.ns.cloudflare.com'], + delegated: true, + hostnames: [{ hostname: 'www.myapp.com', state: 'failed', service: 'web', reason: 'myapp.com answers from kate.ns.cloudflare.com' }], + }) + const { deps: d } = deps({ '/nameservers': answer }) + await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com'], {}, d) + expect(out()).toContain('delegated to kate.ns.cloudflare.com') + expect(out()).toContain('insta domain nameservers reset myapp.com') + expect(out()).toContain('www.myapp.com failed → web — myapp.com answers from kate.ns.cloudflare.com') + }) + + // The platform fails every hostname on the way out, so the "nothing serving" retry would fire on + // the success path and name an attach the same answer says is refused. + it('does not offer the attach it just made impossible', async () => { + const answer = bought({ + nameservers: ['kate.ns.cloudflare.com'], + delegated: true, + hostnames: [{ hostname: 'www.myapp.com', state: 'failed', service: 'web' }], + }) + const { deps: d } = deps({ '/nameservers': answer }) + await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com'], {}, d) + expect(out()).not.toContain('insta domain attach') + expect(out()).toContain('insta domain nameservers reset myapp.com') + }) + + // `--org` names an org the printed commands would not act on, so they are dropped, not rewritten. + it('drops the follow-up commands when --org names another org', async () => { + const answer = bought({ nameservers: ['kate.ns.cloudflare.com'], delegated: true, hostnames: [] }) + const { deps: d } = deps({ '/nameservers': answer }) + await domainNameserversSet('myapp.com', ['kate.ns.cloudflare.com'], { org: 'org2' }, d) + expect(out()).toContain('delegated to kate.ns.cloudflare.com — attach is refused') + expect(out()).not.toContain('insta domain nameservers reset') + }) + + it('restores the registrar own, and then says nothing about being delegated', async () => { + const { deps: d, calls } = deps({ '/nameservers': bought() }) + await domainNameserversReset('myapp.com', {}, d) + expect(calls[0]).toMatchObject({ method: 'DELETE', path: '/orgs/org1/domains/myapp.com/nameservers' }) + expect(out()).not.toContain('delegated to') + }) +}) + +describe('domain transfer', () => { + it('opens the lock and points at the code, naming ICANN while it still holds the registration', async () => { + const held = new Date(Date.now() + 86_400_000).toISOString() + const { deps: d, calls } = deps({ '/domains/myapp.com': bought({ locked: false, transferLockExpiresAt: held }) }) + await domainTransferLock('myapp.com', 'off', {}, d) + expect(calls[0]).toMatchObject({ method: 'PATCH', path: '/orgs/org1/domains/myapp.com', body: { locked: false } }) + expect(out()).toContain('transfer lock off') + expect(out()).toContain(`ICANN holds the registration until ${held.slice(0, 10)}`) + expect(out()).toContain('insta domain transfer code myapp.com') + }) + + // The platform never clears the date, so an old domain always carries a past one. + it('does not announce an ICANN lock that lapsed', async () => { + const { deps: d } = deps({ '/domains/myapp.com': bought({ locked: false, transferLockExpiresAt: '2020-01-01T00:00:00.000Z' }) }) + await domainTransferLock('myapp.com', 'off', {}, d) + expect(out()).not.toContain('ICANN holds') + expect(out()).toContain('insta domain transfer code myapp.com') + }) + + it('drops the code hint when --org names another org', async () => { + const { deps: d } = deps({ '/domains/myapp.com': bought({ locked: false }) }) + await domainTransferLock('myapp.com', 'off', { org: 'org2' }, d) + expect(out()).toContain('transfer lock off') + expect(out()).not.toContain('insta domain transfer code') + }) + + it('closes it again, and then points at neither', async () => { + const held = new Date(Date.now() + 86_400_000).toISOString() + const { deps: d, calls } = deps({ '/domains/myapp.com': bought({ locked: true, transferLockExpiresAt: held }) }) + await domainTransferLock('myapp.com', 'on', {}, d) + expect(calls[0]).toMatchObject({ body: { locked: true } }) + expect(out()).toContain('transfer lock on') + expect(out()).not.toContain('transfer code') + expect(out()).not.toContain('ICANN holds') + }) + + it('refuses a mode that is not on or off, and sends nothing', async () => { + const { deps: d, calls } = deps({}) + await expect(domainTransferLock('myapp.com', 'yes', {}, d)).rejects.toThrow('exit 1') + expect(calls).toEqual([]) + }) + + it('reads the code with a POST, so an agent credential is refused rather than shown it', async () => { + const { deps: d, calls } = deps({ '/auth-code': { authCode: 'EPP-123' } }) + await domainTransferCode('myapp.com', {}, d) + expect(calls[0]).toMatchObject({ method: 'POST', path: '/orgs/org1/domains/myapp.com/auth-code' }) + expect(out()).toContain('EPP-123') + }) + + // The one new shape that is not a purchased domain. + it('answers --json with the code object, not the domain', async () => { + const { deps: d } = deps({ '/auth-code': { authCode: 'EPP-123' } }) + await domainTransferCode('myapp.com', { json: true }, d) + expect(JSON.parse(out())).toEqual({ authCode: 'EPP-123' }) + }) +})