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' }) + }) +})