diff --git a/src/commands/domain.ts b/src/commands/domain.ts index 7670511..f80e53b 100644 --- a/src/commands/domain.ts +++ b/src/commands/domain.ts @@ -341,3 +341,91 @@ export async function domainDetach(host: string, opts: HostOpts, deps?: DomainDe } return removeDomain(name, opts, d) } + +// ---- BYO delegated zones: option B for domains owned at an outside registrar ---------------- + +type OrgZone = { domainName: string; status: 'awaiting_ns' | 'active'; nameservers: string[]; delegated: boolean } +type ZoneRecord = { type: string; host: string; answer: string; ttl?: number; priority?: number; proxied?: boolean } + +const zonePath = (orgId: string, domainName?: string): string => + `/orgs/${encodeURIComponent(orgId)}/zones${domainName ? `/${encodeURIComponent(domainName.trim().toLowerCase())}` : ''}` + +export function zoneLines(z: OrgZone, orgFlag?: string): string[] { + const orgArg = orgFlag ? ` --org ${orgFlag}` : '' + if (z.status === 'active') return [`${z.domainName} delegated (${z.nameservers.join(', ')})`] + return [ + `${z.domainName} waiting for nameservers`, + ` set these at your domain's registrar: ${z.nameservers.join(', ')}`, + ` review the zone BEFORE switching: insta domain zone records ${z.domainName}${orgArg}`, + ] +} + +export function zoneRecordLines(records: ZoneRecord[]): string[] { + if (!records.length) return ['no records in the zone yet — the provider scan runs shortly after delegating; run this again in a moment'] + const w = (pick: (r: ZoneRecord) => string) => Math.max(...records.map((r) => pick(r).length)) + const typeW = w((r) => r.type), hostW = w((r) => r.host), answerW = w((r) => r.answer) + return records.map((r) => + ` ${r.type.padEnd(typeW)} ${r.host.padEnd(hostW)} ${r.answer.padEnd(answerW)}${r.ttl !== undefined ? ` ttl ${r.ttl}` : ''}${r.priority !== undefined ? ` prio ${r.priority}` : ''}${r.proxied ? ' (proxied)' : ''}`) +} + +/** + * Delegate a bring-your-own domain: the platform builds a managed zone for it and answers the two + * nameservers to set at the domain's own registrar. From then on every attach publishes its records + * into the zone itself — apexes included — instead of printing them for hand-copying. The zone is + * SEEDED by the provider's record scan, which is a heuristic: the review-then-switch contract + * (printed, and enforced by nothing else) is to compare `zone records` against the domain's current + * DNS and add what is missing at the CURRENT provider — re-running delegate re-imports — before + * re-pointing. A domain carrying live MX records is refused outright: a DNS move that can drop + * mail is never done implicitly. 202 approval_required in agent mode (zone.delegate); org admin + * either way. + */ +const orgArgOf = (opts: RecordsOpts): string => (opts.org ? ` --org ${opts.org}` : '') + +export async function zoneDelegate(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { + const { api, orgId } = await orgDeps(opts, deps) + // Same precedent as `domain delegate`: the org route signs for the linked project in agent mode + // (zone.delegate is read at the session project), but only when the link belongs to the org + // being mutated — under `--org` naming another org the call goes projectless and the platform's + // org-administration path judges it. + const link = deps?.project ?? (await readProject()) ?? undefined + const projectId = link && link.orgId === orgId ? link.projectId : undefined + const res = await api.rawRequest('POST', zonePath(orgId), { domainName }, projectId ? { projectId } : undefined) + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body) + const z = res.body as OrgZone + for (const line of zoneLines(z, opts.org)) info(line) + info(`the zone was seeded by a provider scan — a heuristic. Check \`insta domain zone records ${z.domainName}${orgArgOf(opts)}\` against your current DNS, add anything missing at your CURRENT provider (re-running delegate re-imports), and only then switch the nameservers.`) +} + +export async function zoneList(opts: RecordsOpts, deps?: DomainDeps): Promise { + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request<{ items: OrgZone[] }>('GET', zonePath(orgId)) + if (opts.json) return printJson(r) + if (!r.items.length) return info('no delegated zones — start one: insta domain zone delegate ') + for (const z of r.items) for (const line of zoneLines(z, opts.org)) info(line) +} + +export async function zoneRecords(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { + const { api, orgId } = await orgDeps(opts, deps) + const r = await api.request<{ items: ZoneRecord[] }>('GET', `${zonePath(orgId, domainName)}/records`) + if (opts.json) return printJson(r) + for (const line of zoneRecordLines(r.items)) info(line) + if (r.items.length) info(`every type shows here (the scan is a heuristic) — add anything missing at your current DNS provider and re-run \`insta domain zone delegate ${domainName.trim().toLowerCase()}${orgArgOf(opts)}\` to re-import before switching nameservers`) +} + +/** + * Release a delegated zone: the platform prunes the records it published and deletes the managed + * zone. The customer's next step — printed — is pointing the domain's nameservers back at their + * own provider; hostnames then re-verify on the records path. + */ +export async function zoneRelease(domainName: string, opts: RecordsOpts, deps?: DomainDeps): Promise { + const { api, orgId } = await orgDeps(opts, deps) + const link = deps?.project ?? (await readProject()) ?? undefined + const projectId = link && link.orgId === orgId ? link.projectId : undefined + const res = await api.rawRequest('DELETE', zonePath(orgId, domainName), undefined, projectId ? { projectId } : undefined) + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body) + const r = res.body as { domainName: string; released: boolean } + info(`${r.domainName} released`) + info(`point the domain's nameservers back at your DNS provider — hostnames re-verify on the records path (insta domain attach prints them)`) +} diff --git a/src/index.ts b/src/index.ts index 878619d..aae7eca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -286,6 +286,20 @@ ns.command('reset ').description("Put the zone back on the registrar's o .option('--org ', "target org (default: linked project's org)").option('--json') .action(guard((domain, o) => domainCmd.domainNameserversReset(domain, o))) +const zone = dom.command('zone').description("Bring-your-own domains served from an InstaCloud-managed zone (nameserver delegation): point the domain's registrar at the pair `zone delegate` answers, and every attach's records — apexes included — are published for you. The alternative stays available: `insta domain attach` alone prints records to paste into your own zone") +zone.command('delegate ').description("Delegate a domain you own elsewhere: builds its managed zone (seeded by a provider record scan — a HEURISTIC, so review `zone records` and add anything missing at your current provider BEFORE switching nameservers; re-running delegate re-imports) and answers the two nameservers to set at your registrar. A domain carrying live MX records is refused — move mail first or keep the records path (org admin; gated: zone.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, o) => domainCmd.zoneDelegate(domain, o))) +zone.command('list').description("The org's delegated zones: which are still waiting for the nameserver switch and which are serving") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((o) => domainCmd.zoneList(o))) +zone.command('records ').description("Every record in the delegated zone — the pre-switch review. The scan seeds common records but is not exhaustive: compare against your current DNS and add what is missing at your CURRENT provider, then re-run `zone delegate` to re-import") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, o) => domainCmd.zoneRecords(domain, o))) +zone.command('release ').description("Release a delegated zone: the platform's records are pruned and the zone deleted. Point the nameservers back at your own provider; hostnames re-verify on the records path (org admin; gated: zone.delegate — agent mode gates from a linked project, an unlinked --org call falls under org administration instead)") + .option('--org ', "target org (default: linked project's org)").option('--json') + .action(guard((domain, o) => domainCmd.zoneRelease(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') diff --git a/test/zone.test.ts b/test/zone.test.ts new file mode 100644 index 0000000..a433983 --- /dev/null +++ b/test/zone.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, afterEach, afterAll } from 'vitest' +import { zoneDelegate, zoneList, zoneRecords, zoneRelease, zoneLines, zoneRecordLines } from '../src/commands/domain.js' +import type { DomainDeps } from '../src/commands/compute.js' + +const awaiting = { + domainName: 'byo.example', + status: 'awaiting_ns' as const, + nameservers: ['ada.ns.cloudflare.com', 'bob.ns.cloudflare.com'], + delegated: false, +} + +type Call = { method: string; path: string; body?: unknown; scope?: unknown } +function deps(answers: Record = {}, raw: { status: number; body: unknown } = { status: 200, body: awaiting }) { + const calls: Call[] = [] + const api = { + request: async (method: string, path: string, body?: unknown) => { + calls.push({ method, path, body }) + const hit = Object.entries(answers).find(([k]) => path.includes(k)) + if (!hit) throw new Error(`unexpected ${method} ${path}`) + return hit[1] + }, + rawRequest: async (method: string, path: string, body?: unknown, scope?: unknown) => { calls.push({ method, path, body, scope }); return raw }, + } + return { deps: { api, project: { projectId: 'p1', orgId: 'org1', branch: 'main' } } as unknown as DomainDeps, calls } +} + +const stdout: string[] = [] +const stderr: string[] = [] +const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((c: any) => { stdout.push(String(c)); return true }) +const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((c: any) => { stderr.push(String(c)); return true }) +afterEach(() => { stdout.length = 0; stderr.length = 0; process.exitCode = 0 }) +afterAll(() => { outSpy.mockRestore(); errSpy.mockRestore() }) +const out = () => stdout.join('') + +describe('domain zone delegate', () => { + it('posts the domain, signs for the linked project (zone.delegate reads there), and prints the switch instructions', async () => { + const { deps: d, calls } = deps({}, { status: 200, body: awaiting }) + await zoneDelegate('Byo.Example', {}, d) + expect(calls[0]).toMatchObject({ method: 'POST', path: '/orgs/org1/zones', body: { domainName: 'Byo.Example' }, scope: { projectId: 'p1' } }) + expect(out()).toContain('waiting for nameservers') + expect(out()).toContain('ada.ns.cloudflare.com, bob.ns.cloudflare.com') + // The review-then-switch contract is the safety of the whole flow — it prints every time. + expect(out()).toContain('insta domain zone records byo.example') + expect(out()).toContain('only then switch the nameservers') + }) + it('under --org naming ANOTHER org the call goes projectless — the wrong project must not sign', async () => { + const { deps: d, calls } = deps({}, { status: 200, body: awaiting }) + await zoneDelegate('byo.example', { org: 'org9' }, d) + expect(calls[0]).toMatchObject({ method: 'POST', path: '/orgs/org9/zones' }) + expect((calls[0] as { scope?: unknown }).scope).toBeUndefined() + }) + it('stops at approval_required like the other gated verbs', async () => { + const { deps: d } = deps({}, { status: 202, body: { status: 'approval_required', approvalId: 'ap1', action: 'zone.delegate' } }) + await zoneDelegate('byo.example', {}, d) + expect(process.exitCode).toBe(2) + }) + it('--json is the platform body, verbatim', async () => { + const { deps: d } = deps({}, { status: 200, body: awaiting }) + await zoneDelegate('byo.example', { json: true }, d) + expect(JSON.parse(out())).toEqual(awaiting) + }) +}) + +describe('domain zone list', () => { + it('prints each zone with its state, and an honest empty line', async () => { + const { deps: d, calls } = deps({ '/zones': { items: [awaiting, { ...awaiting, domainName: 'live.example', status: 'active', delegated: true }] } }) + await zoneList({}, d) + expect(calls[0]).toMatchObject({ method: 'GET', path: '/orgs/org1/zones' }) + expect(out()).toContain('byo.example waiting for nameservers') + expect(out()).toContain('live.example delegated (ada.ns.cloudflare.com, bob.ns.cloudflare.com)') + stdout.length = 0 + const { deps: d2 } = deps({ '/zones': { items: [] } }) + await zoneList({}, d2) + expect(out()).toContain('no delegated zones') + }) +}) + +describe('domain zone records', () => { + it('lists EVERY type (the review must see CAA) and prints the add-then-reimport reminder', async () => { + const { deps: d, calls } = deps({ + '/zones/byo.example/records': { items: [ + { type: 'CNAME', host: '@', answer: 'edge.example', proxied: true }, + { type: 'CAA', host: '@', answer: '0 issue "letsencrypt.org"' }, + { type: 'TXT', host: 'mail', answer: 'v=spf1 -all', ttl: 300 }, + ] }, + }) + await zoneRecords('byo.example', {}, d) + expect(calls[0]).toMatchObject({ method: 'GET', path: '/orgs/org1/zones/byo.example/records' }) + expect(out()).toContain('CAA') + expect(out()).toContain('(proxied)') + // The instruction is copy-pasteable VERBATIM: domain and org scope included. + expect(out()).toContain('re-run `insta domain zone delegate byo.example` to re-import') + stdout.length = 0 + await zoneRecords('Byo.Example', { org: 'org9' }, d) + expect(out()).toContain('re-run `insta domain zone delegate byo.example --org org9` to re-import') + }) + it('says plainly when the scan has not landed yet', () => { + expect(zoneRecordLines([])[0]).toContain('run this again in a moment') + }) +}) + +describe('domain zone release', () => { + it('deletes the zone and prints the re-point instruction', async () => { + const { deps: d, calls } = deps({}, { status: 200, body: { domainName: 'byo.example', released: true } }) + await zoneRelease('byo.example', {}, d) + expect(calls[0]).toMatchObject({ method: 'DELETE', path: '/orgs/org1/zones/byo.example', scope: { projectId: 'p1' } }) + expect(out()).toContain('byo.example released') + expect(out()).toContain('point the domain\'s nameservers back') + }) + it('stops at approval_required', async () => { + const { deps: d } = deps({}, { status: 202, body: { status: 'approval_required', approvalId: 'ap2', action: 'zone.delegate' } }) + await zoneRelease('byo.example', {}, d) + expect(process.exitCode).toBe(2) + }) +}) + +describe('zoneLines', () => { + it('carries --org into the review hint so the copy-paste works from any directory', () => { + const lines = zoneLines(awaiting, 'org9') + expect(lines.join('\n')).toContain('insta domain zone records byo.example --org org9') + }) + it('the delegate answer\'s review hint carries --org too — every printed command is runnable as-is', async () => { + const { deps: d } = deps({}, { status: 200, body: awaiting }) + await zoneDelegate('byo.example', { org: 'org9' }, d) + expect(out()).toContain('insta domain zone records byo.example --org org9') + }) +})