diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 912025d57..aeb75ee2e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -572,7 +572,9 @@ See [Environment Variables](../guide/env-vars.md) for the full guide. void domain add [--project ] ``` -Add a custom domain to a project. Prints the CNAME target you need to add in your DNS provider. +Add a custom domain to a project. Prints the two DNS records to add at your DNS provider: a traffic **CNAME** pointing `` at the CNAME target shown in the command output, and a non-rotating `_cf-custom-hostname` ownership **TXT**. Certificates are validated over HTTP at Cloudflare's edge and renew automatically — there are no `_acme-challenge` records to publish, at first issuance or ever. After adding the records the domain activates automatically (no polling required); run `void domain status ` to check progress. + +> Wildcard custom hostnames (`*.example.com`) are not supported — register each subdomain individually. ### `void domain delete` @@ -596,7 +598,7 @@ List all custom domains and their status (active/pending). void domain status [--project ] [--verbose] ``` -Check verification and SSL status for a specific domain. Prints a rolled-up state (`awaiting_dns`, `verifying_dns`, `issuing_cert`, `deploying_cert`, `awaiting_deployment`, `active`, `error`, or `pending`) with a one-line diagnostic explaining what Cloudflare is doing and any user action required. When DNS records are still pending, the command also prints the exact TXT records to add at your DNS provider. +Check verification and SSL status for a specific domain. Prints a rolled-up state (`awaiting_dns`, `verifying_dns`, `issuing_cert`, `deploying_cert`, `awaiting_deployment`, `active`, `error`, or `pending`) with a one-line diagnostic explaining what Cloudflare is doing and any user action required. While the certificate is not yet active, the command also surfaces the records to configure — the traffic **CNAME** and the `_cf-custom-hostname` ownership **TXT** — so you can verify them. Activation is automatic: a background job reconciles pending domains (about every 2 minutes for the first 30 minutes after adding, then hourly), so this command is for instant feedback rather than required polling. Pass `--verbose` to additionally print the raw multi-line status breakdown (DB status, SSL status, ownership state, verification errors) underneath the rollup. diff --git a/packages/void/src/cli/domain.ts b/packages/void/src/cli/domain.ts index e592e3b0f..6d32cc5a8 100644 --- a/packages/void/src/cli/domain.ts +++ b/packages/void/src/cli/domain.ts @@ -55,11 +55,11 @@ async function domainAdd( const domain = await client.addDomain(projectId, hostname); log.success(`Domain ${hostname} added (status: ${domain.status})`); - // Named locals make the section order explicit (CNAME -> ownership -> SSL), + // Named locals make the section order explicit (CNAME -> ownership), // rather than relying on the order of conditional pushes onto an array. + // Cert DCV uses HTTP-DCV, so no user-facing SSL-validation records exist. let cnameSection: string | undefined; let ownershipSection: string | undefined; - let sslSection: string | undefined; if (domain.cname_target) { cnameSection = `CNAME (traffic)\n ${hostname} → ${domain.cname_target}`; @@ -69,28 +69,12 @@ async function domainAdd( ownershipSection = `TXT (ownership verification)\n ${domain.ownership_verification.name} → ${domain.ownership_verification.value}`; } - const sslRecords = (domain.ssl_validation_records ?? []).filter( - (r): r is { txt_name: string; txt_value: string; status?: string } => - Boolean(r.txt_name) && Boolean(r.txt_value), - ); - if (sslRecords.length > 0) { - const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); - sslSection = `TXT (SSL validation)\n${lines}`; - } else { - // Deadline-miss path: API short-poll didn't see SSL records before timeout. - // Tell the user they'll surface in `domain status` instead of silently - // omitting the section. - sslSection = `SSL validation records will appear in \`void domain status\` shortly.`; - } - - const summary = `Add the records above to your DNS, then run \`void domain status ${hostname}\` to check progress.`; + const recordSections = [cnameSection, ownershipSection].filter((s) => s !== undefined); - const sections = [cnameSection, ownershipSection, sslSection, summary].filter( - (s) => s !== undefined, - ); - - if (sections.length > 0) { - note(sections.join('\n\n'), 'DNS Setup'); + if (recordSections.length > 0) { + const recordWord = recordSections.length === 1 ? 'the record above' : 'the records above'; + const summary = `Add ${recordWord} to your DNS, then run \`void domain status ${hostname}\` to check progress.`; + note([...recordSections, summary].join('\n\n'), 'DNS Setup'); } } @@ -177,7 +161,14 @@ export function computeRollup(status: DomainStatusResponse): RolledUpStatus { ) { return { state: 'issuing_cert', - diagnostic: 'Cloudflare is issuing the SSL certificate (typically <5 minutes).', + // Only cite the CNAME "below" when it will actually render. The CNAME block + // shows iff `status.cname_target` is set (ssl_status !== 'active' always + // holds in this branch), so a status response without a target (e.g. an + // older API during the deploy window) no longer points at a record we + // don't display. + diagnostic: status.cname_target + ? 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes). If this persists, confirm the traffic CNAME below is published.' + : 'Cloudflare is issuing the SSL certificate automatically (typically <5 minutes).', }; } @@ -238,43 +229,30 @@ async function domainStatus( note(rollupLines.join('\n'), 'Domain Status'); - // Gate on actual validation states, not the aggregate `status.status`. - // Once the DB row is flipped to `active`, the API keeps returning - // `status: 'active'` even if CF later reports SSL/ownership re-validation - // (cert renewal, ownership re-check). Keying off the per-field states - // ensures pending records still surface in that window. CF stops returning - // `ownership_verification` once ownership is verified, so its presence is - // itself the pending signal. - const ownershipPending = Boolean(status.ownership_verification); - const sslPending = status.ssl_status !== 'active'; - - if (ownershipPending || sslPending) { - const pendingSections: Array = []; - - if (ownershipPending && status.ownership_verification) { - pendingSections.push( - `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`, - ); - } + // Surface the DNS records the user may still need to publish. The ownership + // TXT shows while `ownership_verification` is present (CF drops it once + // verified). The traffic CNAME shows whenever the cert is not yet active — + // HTTP-DCV cannot validate until the hostname proxies through Cloudflare, so + // a missing/misconfigured CNAME silently blocks issuance even after ownership + // is verified. Once `ssl_status === 'active'` the domain is live and both + // records are correct, so neither is shown. + let cnameSection: string | undefined; + let ownershipSection: string | undefined; - if (sslPending) { - // Defensive: drop any per-record entries CF has already marked active. - // CF normally clears `ssl.validation_records` once SSL is active, but - // during cert renewals or partial states a stale `active` row can sit - // alongside a pending one — we only want to surface what the user still - // needs to add. - const sslRecords = (status.ssl_validation_records ?? []).filter( - (r): r is { txt_name: string; txt_value: string; status?: string } => - Boolean(r.txt_name) && Boolean(r.txt_value) && r.status !== 'active', - ); - if (sslRecords.length > 0) { - const lines = sslRecords.map((r) => ` ${r.txt_name} → ${r.txt_value}`).join('\n'); - pendingSections.push(`TXT (SSL validation)\n${lines}`); - } - } + if (status.cname_target && status.ssl_status !== 'active') { + cnameSection = `CNAME (traffic)\n ${status.hostname} → ${status.cname_target}`; + } + if (status.ownership_verification) { + ownershipSection = `TXT (ownership verification)\n ${status.ownership_verification.name} → ${status.ownership_verification.value}`; + } - if (pendingSections.length > 0) { - note(pendingSections.join('\n\n'), 'Add these DNS records to your provider'); - } + const pendingRecords = [cnameSection, ownershipSection].filter((s) => s !== undefined); + if (pendingRecords.length > 0) { + note( + pendingRecords.join('\n\n'), + pendingRecords.length === 1 + ? 'Add this DNS record to your provider' + : 'Add these DNS records to your provider', + ); } } diff --git a/packages/void/test/unit/domain-command.test.ts b/packages/void/test/unit/domain-command.test.ts index e00503ed7..edbe29dd0 100644 --- a/packages/void/test/unit/domain-command.test.ts +++ b/packages/void/test/unit/domain-command.test.ts @@ -134,7 +134,7 @@ describe('runDomainCommand top-level guards', () => { }); describe('domainAdd', () => { - it('prints CNAME + ownership TXT + SSL TXT when all three records returned', async () => { + it('prints CNAME + ownership TXT when both records returned', async () => { mockAddDomain.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -144,7 +144,6 @@ describe('domainAdd', () => { created_at: 'now', cname_target: 'foo.example.com.cdn.cloudflare.net', ownership_verification: { type: 'txt', name: '_cf-custom-hostname.foo', value: 'own-token' }, - ssl_validation_records: [{ txt_name: '_acme-challenge.foo', txt_value: 'ssl-token' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); @@ -155,32 +154,8 @@ describe('domainAdd', () => { expect(content).toContain('foo.example.com → foo.example.com.cdn.cloudflare.net'); expect(content).toContain('TXT (ownership verification)'); expect(content).toContain('_cf-custom-hostname.foo → own-token'); - expect(content).toContain('TXT (SSL validation)'); - expect(content).toContain('_acme-challenge.foo → ssl-token'); - }); - - it('prints multiple SSL records as separate lines under one section', async () => { - mockAddDomain.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - cname_target: 'cname.example', - ssl_validation_records: [ - { txt_name: 'a', txt_value: 'b' }, - { txt_name: 'c', txt_value: 'd' }, - ], - }); - - await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); - - const content = mockNote.mock.calls[0][0] as string; - expect(content).toContain('a → b'); - expect(content).toContain('c → d'); - // Only one SSL section header, despite two records - expect(content.match(/TXT \(SSL validation\)/g)).toHaveLength(1); + // HTTP-DCV: no user-facing SSL-validation records. + expect(content).not.toContain('TXT (SSL validation)'); }); it('skips ownership section when ownership_verification is missing', async () => { @@ -192,38 +167,19 @@ describe('domainAdd', () => { status: 'pending', created_at: 'now', cname_target: 'cname.example', - ssl_validation_records: [{ txt_name: 'a', txt_value: 'b' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); const content = mockNote.mock.calls[0][0] as string; expect(content).toContain('CNAME (traffic)'); - expect(content).toContain('TXT (SSL validation)'); expect(content).not.toContain('TXT (ownership verification)'); - }); - - it('shows hint when SSL records missing (deadline-miss path)', async () => { - mockAddDomain.mockResolvedValue({ - id: 'dom_1', - project_id: 'proj_default', - hostname: 'foo.example.com', - cf_hostname_id: 'cf_1', - status: 'pending', - created_at: 'now', - cname_target: 'cname.example', - ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'a' }], - }); - - await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); - - const content = mockNote.mock.calls[0][0] as string; - expect(content).toContain('CNAME (traffic)'); expect(content).not.toContain('TXT (SSL validation)'); + // Only one record rendered: summary is singular and must not claim "both". expect(content).toContain( - 'SSL validation records will appear in `void domain status` shortly.', + 'Add the record above to your DNS, then run `void domain status foo.example.com` to check progress.', ); + expect(content).not.toContain('both'); }); it('appends the summary line to the DNS Setup block', async () => { @@ -236,7 +192,6 @@ describe('domainAdd', () => { created_at: 'now', cname_target: 'cname.example', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); @@ -247,7 +202,7 @@ describe('domainAdd', () => { ); }); - it('orders sections CNAME -> ownership -> SSL', async () => { + it('orders sections CNAME -> ownership', async () => { mockAddDomain.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', @@ -257,7 +212,6 @@ describe('domainAdd', () => { created_at: 'now', cname_target: 'cname.example', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'add', hostname: 'foo.example.com' })); @@ -265,10 +219,8 @@ describe('domainAdd', () => { const content = mockNote.mock.calls[0][0] as string; const cnameIdx = content.indexOf('CNAME (traffic)'); const ownershipIdx = content.indexOf('TXT (ownership verification)'); - const sslIdx = content.indexOf('TXT (SSL validation)'); expect(cnameIdx).toBeGreaterThanOrEqual(0); expect(ownershipIdx).toBeGreaterThan(cnameIdx); - expect(sslIdx).toBeGreaterThan(ownershipIdx); }); }); @@ -439,6 +391,56 @@ describe('domainStatus rollup', () => { expect(statusNote).toContain('Status: issuing_cert —'); }); + it('issuing_cert cites the traffic CNAME and renders it when cname_target is set', async () => { + setupDomain(); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cf_status: 'pending_issuance', + ssl_status: 'pending_validation', + cname_target: 'cname.void.app', + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: issuing_cert —'); + expect(statusNote).toContain('confirm the traffic CNAME below is published'); + // The CNAME block it cites is actually rendered. + const allNotes = mockNote.mock.calls.map((c) => c[0] as string).join('\n'); + expect(allNotes).toContain('CNAME (traffic)'); + expect(allNotes).toContain('foo.example.com → cname.void.app'); + }); + + it('issuing_cert omits the CNAME citation when cname_target is absent', async () => { + setupDomain(); + mockGetDomainStatus.mockResolvedValue({ + id: 'dom_1', + project_id: 'proj_default', + hostname: 'foo.example.com', + cf_hostname_id: 'cf_1', + status: 'pending', + created_at: 'now', + cf_status: 'pending_issuance', + ssl_status: 'pending_validation', + // no cname_target (e.g. an older API during the deploy window) + }); + + await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + + const statusNote = mockNote.mock.calls[0][0] as string; + expect(statusNote).toContain('Status: issuing_cert —'); + expect(statusNote).toContain('Cloudflare is issuing the SSL certificate'); + // No dangling reference to a CNAME block that isn't shown. + const allNotes = mockNote.mock.calls.map((c) => c[0] as string).join('\n'); + expect(allNotes).not.toContain('confirm the traffic CNAME below'); + expect(allNotes).not.toContain('CNAME (traffic)'); + }); + it('renders deploying_cert state when cf_status=pending_deployment', async () => { setupDomain(); mockGetDomainStatus.mockResolvedValue({ @@ -650,7 +652,7 @@ describe('domainStatus --verbose', () => { }); describe('domainStatus pending records block', () => { - it('shows pending ownership record ONLY when ownership is pending', async () => { + it('shows the pending ownership record while ownership_verification is present', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, ]); @@ -661,7 +663,7 @@ describe('domainStatus pending records block', () => { cf_hostname_id: 'cf_1', status: 'pending', created_at: 'now', - ssl_status: 'active', + ssl_status: 'pending_validation', cf_status: 'pending', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, }); @@ -672,10 +674,12 @@ describe('domainStatus pending records block', () => { const pendingNote = mockNote.mock.calls[1][0] as string; expect(pendingNote).toContain('TXT (ownership verification)'); expect(pendingNote).toContain('own-name → own-value'); + // HTTP-DCV: no SSL-validation records ever surface to the user. expect(pendingNote).not.toContain('TXT (SSL validation)'); + expect(mockNote.mock.calls[1][1]).toBe('Add this DNS record to your provider'); }); - it('shows pending SSL record ONLY when SSL is pending and ownership is verified', async () => { + it('omits the pending block when ownership_verification is absent (SSL is automatic under HTTP-DCV)', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, ]); @@ -688,19 +692,15 @@ describe('domainStatus pending records block', () => { created_at: 'now', ssl_status: 'pending_validation', cf_status: 'pending_validation', - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - expect(mockNote).toHaveBeenCalledTimes(2); - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('TXT (SSL validation)'); - expect(pendingNote).toContain('ssl-name → ssl-value'); - expect(pendingNote).not.toContain('TXT (ownership verification)'); + expect(mockNote).toHaveBeenCalledTimes(1); + expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); }); - it('shows both records when both pending', async () => { + it('shows the traffic CNAME while the cert is still pending (ownership already verified)', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, ]); @@ -712,22 +712,23 @@ describe('domainStatus pending records block', () => { status: 'pending', created_at: 'now', ssl_status: 'pending_validation', - cf_status: 'pending', - ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], + cf_status: 'pending_issuance', + cname_target: 'cname.void.app', }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); expect(mockNote).toHaveBeenCalledTimes(2); const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('TXT (ownership verification)'); - expect(pendingNote).toContain('own-name → own-value'); - expect(pendingNote).toContain('TXT (SSL validation)'); - expect(pendingNote).toContain('ssl-name → ssl-value'); + expect(pendingNote).toContain('CNAME (traffic)'); + expect(pendingNote).toContain('foo.example.com → cname.void.app'); + // Ownership already verified — CF dropped the TXT, so it must not appear. + expect(pendingNote).not.toContain('TXT (ownership verification)'); + // One record only: singular title. + expect(mockNote.mock.calls[1][1]).toBe('Add this DNS record to your provider'); }); - it('skips the pending block when status is fully verified', async () => { + it('does NOT show the traffic CNAME once the domain is fully active', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, ]); @@ -740,16 +741,18 @@ describe('domainStatus pending records block', () => { created_at: 'now', ssl_status: 'active', cf_status: 'active', + cname_target: 'cname.void.app', }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + // Only the Domain Status note — the CNAME is correct by definition when live. expect(mockNote).toHaveBeenCalledTimes(1); - // The single note call is the Domain Status block; no pending block follows. expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); + expect(mockNote.mock.calls[0][0] as string).not.toContain('CNAME (traffic)'); }); - it('shows multiple SSL records under one section in pending note', async () => { + it('shows both records (CNAME before TXT) with a plural title before activation', async () => { mockListDomains.mockResolvedValue([ { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, ]); @@ -761,45 +764,47 @@ describe('domainStatus pending records block', () => { status: 'pending', created_at: 'now', ssl_status: 'pending_validation', - cf_status: 'pending_validation', - ssl_validation_records: [ - { txt_name: 'a', txt_value: 'b' }, - { txt_name: 'c', txt_value: 'd' }, - ], + cf_status: 'pending', + cname_target: 'cname.void.app', + ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); + expect(mockNote).toHaveBeenCalledTimes(2); const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('a → b'); - expect(pendingNote).toContain('c → d'); - expect(pendingNote.match(/TXT \(SSL validation\)/g)).toHaveLength(1); + expect(pendingNote).toContain('CNAME (traffic)'); + expect(pendingNote).toContain('foo.example.com → cname.void.app'); + expect(pendingNote).toContain('TXT (ownership verification)'); + expect(pendingNote).toContain('own-name → own-value'); + // CNAME ordered before ownership, matching `domain add`. + expect(pendingNote.indexOf('CNAME (traffic)')).toBeLessThan( + pendingNote.indexOf('TXT (ownership verification)'), + ); + // Two records: plural title. + expect(mockNote.mock.calls[1][1]).toBe('Add these DNS records to your provider'); }); - it('drops per-record SSL entries already marked active', async () => { + it('skips the pending block when status is fully verified', async () => { mockListDomains.mockResolvedValue([ - { id: 'dom_1', hostname: 'foo.example.com', status: 'pending' }, + { id: 'dom_1', hostname: 'foo.example.com', status: 'active' }, ]); mockGetDomainStatus.mockResolvedValue({ id: 'dom_1', project_id: 'proj_default', hostname: 'foo.example.com', cf_hostname_id: 'cf_1', - status: 'pending', + status: 'active', created_at: 'now', - ssl_status: 'pending_validation', - cf_status: 'pending_validation', - ssl_validation_records: [ - { txt_name: 'done', txt_value: 'done-val', status: 'active' }, - { txt_name: 'pending', txt_value: 'pending-val', status: 'pending_validation' }, - ], + ssl_status: 'active', + cf_status: 'active', }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' })); - const pendingNote = mockNote.mock.calls[1][0] as string; - expect(pendingNote).toContain('pending → pending-val'); - expect(pendingNote).not.toContain('done → done-val'); + expect(mockNote).toHaveBeenCalledTimes(1); + // The single note call is the Domain Status block; no pending block follows. + expect(mockNote.mock.calls[0][1]).toBe('Domain Status'); }); it('exit code stays 0 even when pending', async () => { @@ -816,7 +821,6 @@ describe('domainStatus pending records block', () => { ssl_status: 'pending_validation', cf_status: 'pending', ownership_verification: { type: 'txt', name: 'own-name', value: 'own-value' }, - ssl_validation_records: [{ txt_name: 'ssl-name', txt_value: 'ssl-value' }], }); await runDomainCommand(root, args({ subcommand: 'status', hostname: 'foo.example.com' }));