diff --git a/README.md b/README.md index 78f7412b..af3071f9 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ original deployment it grew out of). The project home is > **Setting up a custom domain?** Export `CLOUDFLARE_API_TOKEN` + > `CLOUDFLARE_ACCOUNT_ID` (the same token as step 3, under **API token > scopes** below) before running setup so it can preflight your DNS / - > image-transform config. + > image-transform config. Setup itself never touches DNS — once the zone is + > active, `npm run connect-domains` attaches the CDN and site domains (see + > **Custom domain + image thumbnails** below). > **Run it in a real terminal.** `npm run setup` is interactive; piping input > through `npm run` (e.g. `printf ... | npm run setup`) truncates stdin. If you @@ -86,6 +88,7 @@ original deployment it grew out of). The project home is | Account · D1 · Edit | create + migrate the database | | Account · Workers R2 Storage · Edit | create the image bucket | | Account · Turnstile · Edit | **only if** attaching a custom domain — provisions the admin-login bot check | + | Zone · Zone · Read | **only if** attaching a custom domain — lets connect-domains resolve the zone | | Zone · DNS · Edit | **only if** attaching a custom domain (writes the apex record) | | Zone · WAF · Edit | **only if** attaching a custom domain — adds a WAF rate limit on the public API endpoints (the download beacon and the oEmbed provider) | | Zone · Zone Settings · Edit | *optional* — lets setup enable image resizing for you | @@ -133,8 +136,26 @@ Sona is single-admin, so there's no second account to let you back in. Two paths ### Custom domain + image thumbnails (post-deploy) -Two things need a manual step on a custom domain — setup preflights them when it -can, but calls them out here because they need dashboard/DNS access: +`npm run setup` does not touch DNS — the domain wiring runs after it, once your +nameservers point at Cloudflare and the zone is **active**: + +```sh +export CLOUDFLARE_API_TOKEN= CLOUDFLARE_ACCOUNT_ID= # same pair as setup +npm run connect-domains -- yourdomain.com # attach cdn. → bucket, → Pages +npm run connect-domains -- --check yourdomain.com # read-only doctor: which step is missing? +``` + +`connect-domains` attaches the CDN host (`cdn.yourdomain.com`) to the images +bucket and the site domain to the Pages project. **Images 404 until the CDN +host is attached.** With *Zone · Zone Settings · Edit* on the token it also +enables Image Transformations. It adds no other DNS records, and it's safe to +re-run. Its token scopes are all in the **API token scopes** table in step 3. +If your site lives on a subdomain (like +`sona.yourdomain.com`), the zone is the root domain — scope the token to that +zone, and Image Transformations is enabled zone-wide on it. + +Two things might still need a manual step. Setup and connect-domains preflight +them where they can, but finishing either may need dashboard or DNS access: - **Pages apex domain.** After adding your domain to the Pages project, the **apex** needs a manual **proxied CNAME** `yourdomain.com → .pages.dev` diff --git a/scripts/connect-domains-lib.test.ts b/scripts/connect-domains-lib.test.ts index 3e4aa3c6..61906480 100644 --- a/scripts/connect-domains-lib.test.ts +++ b/scripts/connect-domains-lib.test.ts @@ -4,6 +4,8 @@ import { parseWranglerConfig, classifyZone, zoneGuidance, + zoneConsentLabel, + resolveZone, findBucketDomain, cdnDomainState, bucketDomainTlsIssued, @@ -74,11 +76,132 @@ describe('zoneGuidance (fail-soft messages)', () => { expect(g).toContain('carter.ns.cf, fish.ns.cf'); }); + it('tells a subdomain operator to add the domain they registered, naming the zones tried', () => { + const g = zoneGuidance({ exists: false, active: false }, 'sona.taro.surf', [ + 'sona.taro.surf', + 'taro.surf' + ]); + expect(g).toContain('Add the domain you registered to this Cloudflare account'); + expect(g).toContain('Looked for zones named sona.taro.surf, taro.surf'); + }); + + it('never names a computed root domain (co.uk is a public suffix, not an addable site)', () => { + const g = zoneGuidance({ exists: false, active: false }, 'example.co.uk', [ + 'example.co.uk', + 'co.uk' + ]); + expect(g).not.toContain('co.uk to this Cloudflare account'); + expect(g).toContain('Add the domain you registered to this Cloudflare account'); + expect(g).toContain('Looked for zones named example.co.uk, co.uk'); + }); + + it('omits the names-tried parenthetical when only one zone name was looked up', () => { + const g = zoneGuidance({ exists: false, active: false }, 'taro.surf', ['taro.surf']); + expect(g).toContain('Add the domain you registered to this Cloudflare account'); + expect(g).not.toContain('Looked for zones named'); + }); + + it('names the RESOLVED parent zone in the not-active message for a subdomain host', () => { + const g = zoneGuidance( + { exists: true, active: false, nameServers: ['carter.ns.cf', 'fish.ns.cf'] }, + 'sona.taro.surf', + ['sona.taro.surf', 'taro.surf'], + 'taro.surf' + ); + expect(g).toContain('Zone taro.surf (serving sona.taro.surf) exists but is not active'); + expect(g).toContain('carter.ns.cf, fish.ns.cf'); + }); + it('returns null when the zone is active (proceed)', () => { expect(zoneGuidance({ exists: true, active: true, id: 'z1' }, 'taro.surf')).toBeNull(); }); }); +describe('zoneConsentLabel', () => { + it('names the parent zone AND the host it serves when they differ', () => { + expect(zoneConsentLabel('sona.taro.surf', 'taro.surf')).toBe( + 'the taro.surf zone (which serves sona.taro.surf)' + ); + }); + + it('names just the zone when the host IS the zone', () => { + expect(zoneConsentLabel('taro.surf', 'taro.surf')).toBe('the taro.surf zone'); + }); + + it('falls back to the host when no zone name was resolved', () => { + expect(zoneConsentLabel('taro.surf', null)).toBe('the taro.surf zone'); + expect(zoneConsentLabel('taro.surf', undefined)).toBe('the taro.surf zone'); + }); +}); + +describe('resolveZone', () => { + const ok = (result: unknown) => ({ ok: true, status: 200, result }); + const activeZone = [{ id: 'z1', status: 'active', name_servers: ['a.ns.cf'] }]; + + it('finds the registrable-domain zone for a subdomain (second candidate)', async () => { + const tried: string[] = []; + const { zone, zoneName, errorStatus } = await resolveZone( + ['sona.taro.surf', 'taro.surf'], + async (name) => { + tried.push(name); + return ok(name === 'taro.surf' ? activeZone : []); + } + ); + expect(tried).toEqual(['sona.taro.surf', 'taro.surf']); + expect(zone).toEqual({ exists: true, active: true, id: 'z1', nameServers: ['a.ns.cf'] }); + expect(zoneName).toBe('taro.surf'); + expect(errorStatus).toBeNull(); + }); + + it('stops at the first candidate that matches (no extra lookups)', async () => { + const tried: string[] = []; + const { zoneName } = await resolveZone(['taro.surf'], async (name) => { + tried.push(name); + return ok(activeZone); + }); + expect(tried).toEqual(['taro.surf']); + expect(zoneName).toBe('taro.surf'); + }); + + it('reports no zone when no candidate matches', async () => { + const { zone, zoneName, errorStatus } = await resolveZone( + ['sona.taro.surf', 'taro.surf'], + async () => ok([]) + ); + expect(zone).toEqual({ exists: false, active: false }); + expect(zoneName).toBeNull(); + expect(errorStatus).toBeNull(); + }); + + it('aborts the walk on an auth error and surfaces the status', async () => { + const tried: string[] = []; + const { zone, errorStatus, failedName } = await resolveZone(['sona.taro.surf', 'taro.surf'], async (name) => { + tried.push(name); + return { ok: false, status: 403 }; + }); + expect(tried).toEqual(['sona.taro.surf']); + expect(errorStatus).toBe(403); + expect(zone).toEqual({ exists: false, active: false }); + expect(failedName).toBe('sona.taro.surf'); + }); + + it('aborts the walk on a transient error too (a 500 must not silently pick the parent zone)', async () => { + const tried: string[] = []; + const { zone, zoneName, errorStatus, failedName } = await resolveZone( + ['sona.taro.surf', 'taro.surf'], + async (name) => { + tried.push(name); + return name === 'sona.taro.surf' ? { ok: false, status: 500 } : ok(activeZone); + } + ); + expect(tried).toEqual(['sona.taro.surf']); // no further candidates tried + expect(errorStatus).toBe(500); + expect(failedName).toBe('sona.taro.surf'); + expect(zoneName).toBeNull(); + expect(zone).toEqual({ exists: false, active: false }); + }); +}); + describe('cdnDomainState', () => { const ok = (domains: unknown[]) => ({ ok: true, status: 200, result: { domains } }); @@ -247,6 +370,49 @@ describe('buildLadder + firstFailingRung', () => { expect(ladder.filter((r) => r.status === 'skip')).toHaveLength(5); }); + it('zone-exists tells a subdomain operator to add the domain they registered, naming the zones tried', () => { + const ladder = buildLadder({ + ...healthy, + host: 'sona.taro.surf', + zoneExists: false, + zoneName: null, + candidates: ['sona.taro.surf', 'taro.surf'] + }); + const rung = firstFailingRung(ladder)!; + expect(rung.id).toBe('zone-exists'); + expect(rung.action).toContain('Add the domain you registered to this Cloudflare account'); + expect(rung.action).toContain('Looked for zones named sona.taro.surf, taro.surf'); + expect(rung.action).not.toContain('Add sona.taro.surf'); + }); + + it('zone-exists never names a computed root domain (multi-part TLD: co.uk is a public suffix)', () => { + const ladder = buildLadder({ + ...healthy, + host: 'example.co.uk', + zoneExists: false, + zoneName: null, + candidates: ['example.co.uk', 'co.uk'] + }); + const rung = firstFailingRung(ladder)!; + expect(rung.id).toBe('zone-exists'); + expect(rung.action).not.toContain('co.uk to this Cloudflare account'); + expect(rung.action).toContain('Add the domain you registered to this Cloudflare account'); + expect(rung.action).toContain('Looked for zones named example.co.uk, co.uk'); + }); + + it('names the RESOLVED (parent) zone on the transforms rung for a subdomain host', () => { + const ladder = buildLadder({ + ...healthy, + host: 'sona.taro.surf', + imageTransforms: false, + zoneName: 'taro.surf', + candidates: ['sona.taro.surf', 'taro.surf'] + }); + const rung = ladder.find((r) => r.id === 'image-transforms')!; + expect(rung.label).toContain('the taro.surf zone'); + expect(rung.action).toContain('dashboard → taro.surf → Images'); + }); + it('names zone-active as the first failure when the zone is pending', () => { expect(firstFailingRung(buildLadder({ ...healthy, zoneActive: false }))?.id).toBe('zone-active'); }); diff --git a/scripts/connect-domains-lib.ts b/scripts/connect-domains-lib.ts index 52691f06..613ed16e 100644 --- a/scripts/connect-domains-lib.ts +++ b/scripts/connect-domains-lib.ts @@ -61,22 +61,98 @@ export function classifyZone(result: unknown): ZoneStatus { }; } +/** + * Resolves the Cloudflare zone serving `host` by trying each candidate zone + * name in order (most specific first — `sona.example.com`, then `example.com`), + * because a subdomain is served by its registrable domain's zone and an exact + * `GET /zones?name=` lookup finds nothing for it. Returns the first + * candidate that exists as a zone. ANY failed lookup aborts the walk and is + * surfaced as `errorStatus` for the caller's hard-error path — treating a + * transient error (500/429/status 0) as "no zone for this candidate" could + * silently pick a parent zone, or report "add your domain" for an API blip. + */ +export async function resolveZone( + candidates: string[], + lookup: (name: string) => Promise<{ ok: boolean; status: number; result?: unknown }> +): Promise<{ + zone: ZoneStatus; + zoneName: string | null; + errorStatus: number | null; + /** The candidate whose lookup failed — error messages must name IT, not the host. */ + failedName: string | null; +}> { + for (const name of candidates) { + const res = await lookup(name); + if (!res.ok) + return { + zone: { exists: false, active: false }, + zoneName: null, + errorStatus: res.status, + failedName: name + }; + const zone = classifyZone(res.result); + if (zone.exists) return { zone, zoneName: name, errorStatus: null, failedName: null }; + } + return { zone: { exists: false, active: false }, zoneName: null, errorStatus: null, failedName: null }; +} + +/** + * The no-zone next step shared by zoneGuidance and the doctor ladder: the + * action ("add the domain you registered") plus the zone-names-tried + * parenthetical (empty when only one name was looked up). Never names a + * computed "root domain" — for a multi-part TLD like example.co.uk the last + * candidate is a public suffix (co.uk) Cloudflare can't accept as a site. + */ +export function addZoneAction(candidates: string[]): { action: string; tried: string } { + const tried = + candidates.length > 1 ? ` (Looked for zones named ${candidates.join(', ')}.)` : ''; + return { + action: 'Add the domain you registered to this Cloudflare account (dashboard → Add a site)', + tried + }; +} + +/** + * How consent/success lines name the zone the mutations touch: the RESOLVED + * zone (the parent zone for a subdomain host), naming the host it serves when + * the two differ. Falls back to the host itself when the lookup reported no + * zone name. + */ +export function zoneConsentLabel(host: string, zoneName?: string | null): string { + return zoneName && zoneName !== host + ? `the ${zoneName} zone (which serves ${host})` + : `the ${zoneName ?? host} zone`; +} + /** * Fail-soft precondition message for the zone, or null when it's active and we * can proceed. Not-a-zone and not-active are operator/registrar steps outside * any Cloudflare token, so connect-domains prints this and exits 0 rather than - * erroring. + * erroring. The no-zone message tells the operator to add the domain THEY + * registered (never a name computed from `candidates` — see addZoneAction) and + * names the zone names the lookup tried. */ -export function zoneGuidance(zone: ZoneStatus, host: string): string | null { - if (!zone.exists) +export function zoneGuidance( + zone: ZoneStatus, + host: string, + candidates: string[] = [host], + zoneName?: string | null +): string | null { + if (!zone.exists) { + const { action, tried } = addZoneAction(candidates); return ( - `No Cloudflare zone found for ${host}. Add ${host} to this Cloudflare account ` + - `(dashboard → Add a site), point your registrar's nameservers at Cloudflare, then re-run.` + `No Cloudflare zone found for ${host}. ${action}, ` + + `point your registrar's nameservers at Cloudflare, then re-run.${tried}` ); + } if (!zone.active) { + // Name the RESOLVED zone — for a subdomain host the nameserver change + // belongs to the parent zone, and naming the host here would send the + // operator looking for a zone that doesn't exist. + const which = zoneName && zoneName !== host ? `${zoneName} (serving ${host})` : host; const ns = zone.nameServers?.length ? ` Assigned nameservers: ${zone.nameServers.join(', ')}.` : ''; return ( - `Zone for ${host} exists but is not active yet.${ns} Set those nameservers at your ` + + `Zone ${which} exists but is not active yet.${ns} Set those nameservers at your ` + `registrar; propagation can take a few hours. Re-run once the zone shows Active.` ); } @@ -234,6 +310,10 @@ export interface LadderInputs { /** true = on, false = off, null = couldn't verify (token lacks Zone Settings·Read). */ imageTransforms: boolean | null; cdnLoad: CdnProbe; + /** The RESOLVED zone's name (the parent zone for a subdomain host); null/absent when no zone matched. */ + zoneName?: string | null; + /** The zone names the lookup tried, most specific first (last one is the root domain). */ + candidates?: string[]; } /** @@ -248,6 +328,11 @@ export interface LadderInputs { */ export function buildLadder(i: LadderInputs): Rung[] { const cdn = cdnHost(i.host); + // The zone serving the host — its parent zone for a subdomain. Falls back to + // the host itself when the lookup found nothing (or the caller didn't say). + const zoneName = i.zoneName ?? i.host; + const candidates = i.candidates?.length ? i.candidates : [i.host]; + const { action: addAction, tried } = addZoneAction(candidates); const rungs: Rung[] = []; let blocked = false; @@ -261,13 +346,13 @@ export function buildLadder(i: LadderInputs): Rung[] { step( 'zone-exists', - `${i.host} is a zone in this Cloudflare account`, + `this Cloudflare account has a zone serving ${i.host}`, i.zoneExists ? 'pass' : 'fail', - `Add ${i.host} to this Cloudflare account and point your registrar's nameservers at Cloudflare.` + `${addAction} and point your registrar's nameservers at Cloudflare.${tried}` ); step( 'zone-active', - `the ${i.host} zone is active`, + `the ${zoneName} zone is active`, i.zoneActive ? 'pass' : 'fail', `Set the Cloudflare-assigned nameservers at your registrar; propagation can take a few hours.` ); @@ -292,11 +377,11 @@ export function buildLadder(i: LadderInputs): Rung[] { step( 'image-transforms', - `Image Transformations are enabled on the ${i.host} zone`, + `Image Transformations are enabled on the ${zoneName} zone`, i.imageTransforms === true ? 'pass' : 'warn', i.imageTransforms === null - ? `Couldn't verify Image Transformations (token lacks Zone Settings·Read); check dashboard → ${i.host} → Images → Transformations.` - : `Enable it: dashboard → ${i.host} → Images → Transformations → "Enable for zone". Until on, thumbnails serve the full-size original or 404.` + ? `Couldn't verify Image Transformations (token lacks Zone Settings·Read); check dashboard → ${zoneName} → Images → Transformations.` + : `Enable it: dashboard → ${zoneName} → Images → Transformations → "Enable for zone". Until on, thumbnails serve the full-size original or 404.` ); const cdnLoadAction = diff --git a/scripts/connect-domains.test.ts b/scripts/connect-domains.test.ts index 385c8ea0..a3c77f75 100644 --- a/scripts/connect-domains.test.ts +++ b/scripts/connect-domains.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { runDoctor, type DoctorArgs, type DoctorDeps } from './connect-domains.ts'; import type { CfApiResult } from './setup-lib.ts'; @@ -82,6 +85,50 @@ describe('runDoctor', () => { expect(text).not.toMatch(/Next action:/); // a warn is not a hard failure }); + it('names the RESOLVED (parent) zone in the transforms rung for a subdomain host', async () => { + const out: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...x) => { + out.push(x.join(' ')); + }); + const { api } = recordingApi({ image_resizing: { ok: true, status: 200, result: { value: 'off' } } }); + await runDoctor( + args({ + host: 'sona.taro.surf', + cdn: 'cdn.sona.taro.surf', + zoneName: 'taro.surf', + candidates: ['sona.taro.surf', 'taro.surf'] + }), + deps(api) + ); + spy.mockRestore(); + const text = out.join('\n'); + expect(text).toContain('the taro.surf zone'); + }); + + it('tells a no-zone operator to add the domain they registered, naming the zones tried', async () => { + const out: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((...x) => { + out.push(x.join(' ')); + }); + const { api, calls } = recordingApi(); + await runDoctor( + args({ + host: 'sona.taro.surf', + cdn: 'cdn.sona.taro.surf', + zone: { exists: false, active: false }, + zoneName: null, + candidates: ['sona.taro.surf', 'taro.surf'] + }), + deps(api) + ); + spy.mockRestore(); + expect(calls).toEqual([]); // no zone → nothing else to look up + const text = out.join('\n'); + expect(text).toContain('Add the domain you registered to this Cloudflare account'); + expect(text).toContain('Looked for zones named sona.taro.surf, taro.surf'); + expect(text).not.toContain('Add the root domain'); + }); + it('always returns 0 (diagnostic), even when a rung hard-fails', async () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); // Domain unreachable → cdn-loads fails, but the command still exits 0. @@ -91,3 +138,42 @@ describe('runDoctor', () => { expect(code).toBe(0); }); }); + +describe('connect-domains.ts ↔ candidate-walk source contract', () => { + // main() isn't importable without running the CLI, so pin the subdomain zone + // wiring at the source level: the host's candidate list must be built via + // zoneNameCandidates and flow into BOTH resolveZone (the walk) and + // zoneGuidance (the no-zone message). Reverting to a bare [host] would + // silently break subdomain hosts again. Whitespace-tolerant, wiring-only + // assertions — message wording is covered behaviorally in the lib tests. + const src = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'connect-domains.ts'), + 'utf8' + ); + + it('builds the candidate list with zoneNameCandidates(host)', () => { + expect(src).toMatch(/zoneNameCandidates\(\s*host\s*\)/); + }); + + it('passes the candidates to resolveZone', () => { + expect(src).toMatch(/resolveZone\(\s*candidates/); + }); + + it('passes the candidates to zoneGuidance', () => { + expect(src).toMatch(/zoneGuidance\(\s*zone,\s*host,\s*candidates,\s*zoneName\s*\)/); + }); + + // Consent honesty: the confirm prompt covers a zone-wide mutation, so it must + // name the RESOLVED zone via zoneConsentLabel — reverting to `the ${host} + // zone` (the round-3 bug) would mislead subdomain operators with every + // behavioral test still green. The transforms bullet's whole-zone disclosure + // is pinned the same way. + it('derives the consent label via zoneConsentLabel and prints it', () => { + expect(src).toMatch(/zoneConsentLabel\(\s*host,\s*zoneName\s*\)/); + expect(src).toMatch(/your account and \$\{zoneLabel\}/); + }); + + it('keeps the whole-zone disclosure on the transforms bullet', () => { + expect(src).toMatch(/affects the whole zone, not just \$\{host\}/); + }); +}); diff --git a/scripts/connect-domains.ts b/scripts/connect-domains.ts index b5c5dc5e..f84359d6 100644 --- a/scripts/connect-domains.ts +++ b/scripts/connect-domains.ts @@ -10,8 +10,10 @@ * Split out of first-run `npm run setup` because zone activation can lag * nameserver propagation by hours — this must run AFTER the zone is active. It * makes exactly two mutations (the R2 custom domain + the Pages custom domain) - * plus, with the scope, enabling Image Transformations; each is idempotent and - * nothing else in the zone is touched. The API token comes from + * plus, with the scope, enabling Image Transformations — a ZONE-WIDE setting on + * the resolved zone (for a subdomain host that's the parent zone serving it); + * each is idempotent and beyond those nothing else in the zone is touched. The + * API token comes from * CLOUDFLARE_API_TOKEN (Zone·Read + DNS·Edit, plus Zone Settings·Edit to enable * Image Transformations for you); it is read from the env, never stored or * printed. @@ -28,12 +30,20 @@ import { readFileSync, existsSync } from 'node:fs'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout, env, argv, exit } from 'node:process'; import { fileURLToPath } from 'node:url'; -import { cfApi, hostFromDomain, imageResizingOutcome, type CfApiResult } from './setup-lib.ts'; +import { + cfApi, + hostFromDomain, + zoneNameCandidates, + imageResizingOutcome, + type CfApiResult +} from './setup-lib.ts'; import { cdnHost, parseWranglerConfig, classifyZone, zoneGuidance, + resolveZone, + zoneConsentLabel, cdnDomainState, bucketDomainTlsIssued, pagesDomainAttached, @@ -55,9 +65,6 @@ const TOKEN_RECIPE = ' • Zone · Zone Settings · Edit (optional; lets it enable Image Transformations)\n' + 'Then export CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID and re-run.'; -const isAuthError = (r: { ok: boolean; status: number }) => - !r.ok && (r.status === 401 || r.status === 403); - /** Best-effort read of the deployed `siteUrl` site-setting (forward-compat with SONA-24). */ function readSiteUrlSetting(dbName: string): string | null { try { @@ -135,22 +142,40 @@ async function main(): Promise { } const cdn = cdnHost(host); - // Zone lookup (account-scoped by the token). A 401/403 here is a hard token - // error; anything else is diagnostic state, not a crash. - const zoneRes = await cfApi(cfToken, `/zones?name=${encodeURIComponent(host)}`); - if (isAuthError(zoneRes)) { - console.error(`✖ The API token cannot read zones (HTTP ${zoneRes.status}).\n`); - console.error(TOKEN_RECIPE); + // Zone lookup (account-scoped by the token). A subdomain like + // sona.example.com is served by the example.com zone, so walk the candidate + // zone names most-specific-first instead of one exact lookup. A 401/403 is + // a hard token error; any other failed lookup aborts too (a transient error + // must not silently pick the parent zone or read as "no zone"). + const candidates = zoneNameCandidates(host); + const { zone, zoneName, errorStatus, failedName } = await resolveZone(candidates, (name) => + cfApi(cfToken, `/zones?name=${encodeURIComponent(name)}`) + ); + if (errorStatus !== null) { + // Name the candidate whose lookup failed — for a subdomain host that can + // be the parent zone, and pointing at the host would mislead. + const lookupName = failedName ?? host; + if (errorStatus === 401 || errorStatus === 403) { + console.error(`✖ The API token cannot read zones (HTTP ${errorStatus}).\n`); + console.error(TOKEN_RECIPE); + } else if (errorStatus === 0) { + console.error( + `✖ Could not reach the Cloudflare API while looking up the zone for ${lookupName} — check your network and re-run.` + ); + } else { + console.error( + `✖ Cloudflare API error (HTTP ${errorStatus}) while looking up the zone for ${lookupName} — wait a moment and re-run.` + ); + } return 1; } - const zone = classifyZone(zoneRes.result); if (check) { - return await runDoctor({ cfToken, cfAccount, bucket, host, cdn, zone, dbName }); + return await runDoctor({ cfToken, cfAccount, bucket, host, cdn, zone, zoneName, candidates, dbName }); } // --- mutating mode ------------------------------------------------------- - const guidance = zoneGuidance(zone, host); + const guidance = zoneGuidance(zone, host, candidates, zoneName); if (guidance) { console.log(`ℹ ${guidance}`); return 0; // fail soft — the registrar/propagation step is the operator's @@ -199,10 +224,20 @@ async function main(): Promise { } // Preview EVERY change the confirm covers (records AND the zone setting), so - // the one prompt is honest about what it does. - console.log('This will make the following changes to your zone (and nothing else):'); + // the one prompt is honest about what it does. Consent and success lines + // name the RESOLVED zone (the parent zone for a subdomain host) because the + // Image Transformations toggle is zone-wide on it. + const zoneLabel = zoneConsentLabel(host, zoneName); + console.log( + `This will make the following changes to your account and ${zoneLabel}, and nothing else:` + ); for (const m of plan) console.log(` • ${m.label}`); - if (willEnableTransforms) console.log(` • enable Image Transformations on the ${host} zone`); + if (willEnableTransforms) + console.log( + ` • enable Image Transformations on the ${zoneName ?? host} zone${ + zoneName && zoneName !== host ? ` — this affects the whole zone, not just ${host}` : '' + }` + ); let proceed = yes; if (!proceed) { @@ -231,7 +266,7 @@ async function main(): Promise { method: 'PATCH', body: { value: 'on' } }); - if (patched.ok) console.log(`✔ Image Transformations enabled on the ${host} zone.`); + if (patched.ok) console.log(`✔ Image Transformations enabled on ${zoneLabel}.`); else console.warn( `⚠ Could not enable Image Transformations (HTTP ${patched.status}) — enable it in the dashboard.` @@ -259,6 +294,10 @@ export interface DoctorArgs { host: string; cdn: string; zone: ReturnType; + /** The RESOLVED zone's name (the parent zone for a subdomain host); null when no zone matched. */ + zoneName?: string | null; + /** The zone names the lookup tried, most specific first. */ + candidates?: string[]; dbName: string; } @@ -314,6 +353,8 @@ export async function runDoctor(a: DoctorArgs, deps: DoctorDeps = defaultDoctorD host: a.host, zoneExists: a.zone.exists, zoneActive: a.zone.active, + zoneName: a.zoneName, + candidates: a.candidates, cdnState, tlsIssued, imageTransforms: transforms, diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index 47346e56..2d7154e9 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -22,7 +22,8 @@ import { ciWiringEntries, cfApi, securitySummaryLines, - pagesPatchConfirmsSitekey + pagesPatchConfirmsSitekey, + cdnAttachmentLines } from './setup-lib.ts'; describe('buildMigrationSql', () => { @@ -667,6 +668,19 @@ describe('securitySummaryLines', () => { expect(unwired).not.toContain('enforced once deployed'); }); + it('names the RESOLVED parent zone in the applied line for a subdomain host', () => { + const text = securitySummaryLines( + 'sona.taro.surf', + 'created', + null, + null, + false, + 'taro.surf' + ).join('\n'); + expect(text).toContain('applied to the taro.surf zone'); + expect(text).not.toContain('applied to the sona.taro.surf zone'); + }); + it('stays silent about pre-existing rate limits and unattempted Turnstile', () => { expect(securitySummaryLines('taro.surf', 'exists', null, null, false)).toEqual([]); expect(securitySummaryLines('taro.surf', null, null, null, false)).toEqual([]); @@ -686,6 +700,10 @@ describe('setup.ts ↔ securitySummaryLines call-site contract', () => { expect(src).toMatch(/pagesConfigOk && turnstileSecretSet/); }); + it('passes the resolved zone name so subdomain summaries name the real zone', () => { + expect(src).toMatch(/pagesConfigOk && turnstileSecretSet,\s*\n?\s*resolvedZoneName/); + }); + it('assigns pagesConfigOk from the Pages PATCH result, read-back confirmed', () => { expect(src).toMatch(/pagesConfigOk =\s*\n?\s*res\.ok/); expect(src).toMatch(/pagesPatchConfirmsSitekey\(res\.result, turnstileSitekey\)/); @@ -718,3 +736,81 @@ describe('pagesPatchConfirmsSitekey', () => { expect(pagesPatchConfirmsSitekey({ deployment_configs: null }, '0xKEY')).toBe(false); }); }); + +describe('cdnAttachmentLines', () => { + it('points at connect-domains (attach + --check) when a domain was given', () => { + const text = cdnAttachmentLines('https://cdn.taro.surf', 'taro-images', 'taro.surf').join('\n'); + expect(text).toContain('npm run connect-domains -- taro.surf'); + expect(text).toContain('npm run connect-domains -- --check taro.surf'); + // connect-domains hard-requires BOTH env vars (it exits 1 otherwise), so + // both commands must name the pair. + expect(text.match(/CLOUDFLARE_API_TOKEN= CLOUDFLARE_ACCOUNT_ID=/g)).toHaveLength(2); + // The dashboard route survives as the fallback for tokens without DNS scope. + expect(text).toContain('R2 → taro-images → Settings → Custom Domains → add https://cdn.taro.surf'); + // The images-404 consequence rides the connect instruction itself. + expect(text).toContain('setup did not touch DNS.'); + expect(text).toContain('Images 404 until you connect it.'); + }); + + it('normalizes a messy domain answer to the bare host itself', () => { + const text = cdnAttachmentLines( + 'https://cdn.taro.surf', + 'taro-images', + 'https://Taro.Surf/gallery' + ).join('\n'); + expect(text).toContain('npm run connect-domains -- taro.surf'); + }); + + it('falls back to the dashboard when the R2 public URL is not cdn.', () => { + // connect-domains always attaches cdn.; pointing an overridden + // public URL at it would wire the wrong host and leave images 404ing. + const text = cdnAttachmentLines('https://images.taro.surf', 'taro-images', 'taro.surf').join('\n'); + expect(text).not.toContain('connect-domains'); + expect(text).toContain('Cloudflare dashboard → R2 → taro-images → Settings → Custom Domains'); + expect(text).toContain('add https://images.taro.surf'); + expect(text).toContain('Images 404 until this is done.'); + }); + + it('falls back to the dashboard walkthrough when no domain was given', () => { + const text = cdnAttachmentLines('https://cdn.taro.surf', 'taro-images', '').join('\n'); + expect(text).not.toContain('connect-domains'); + expect(text).toContain('Cloudflare dashboard → R2 → taro-images → Settings → Custom Domains'); + expect(text).toContain('Images 404 until this is done.'); + }); +}); + +describe('cdnAttachmentLines ↔ package.json contract', () => { + // The printed `npm run