From fb3e415c51d0851e5411eb1cc525facb11f94078 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:50:16 -0700 Subject: [PATCH 01/11] docs(setup): point operators at connect-domains for the CDN/site domains (SONA-190) connect-domains has attached cdn. to the bucket and the site domain to the Pages project since SONA-6, but nothing named it: setup's Next steps said to do it by hand in the dashboard, and the README never mentioned attaching the CDN host at all. A real fork setup shipped with broken images because of it. Make connect-domains the primary instruction (with --check for diagnosis) wherever a domain is known, keep the dashboard walkthrough as the no-domain fallback, and extract the lines into cdnAttachmentLines() so a test pins the pointer. --- README.md | 22 +++++++++++++++++++--- scripts/setup-lib.test.ts | 21 ++++++++++++++++++++- scripts/setup-lib.ts | 33 +++++++++++++++++++++++++++++++++ scripts/setup.ts | 15 +++++++++------ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 78f7412b..080f75ee 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 @@ -133,8 +135,22 @@ 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 +npm run connect-domains -- yourdomain.com # attach cdn. → bucket, → Pages +npm run connect-domains -- --check yourdomain.com # read-only doctor: which step is missing? +``` + +It 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** — and with *Zone · Zone Settings · Edit* on the token it also enables +Image Transformations. It touches nothing else in the zone, and every step is +idempotent, so re-running is safe. + +Two things still need a manual step — setup and connect-domains preflight them +when they can, but they need dashboard/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/setup-lib.test.ts b/scripts/setup-lib.test.ts index 47346e56..95438788 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', () => { @@ -718,3 +719,21 @@ 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'); + // 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'); + 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', null).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.'); + }); +}); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index b247db83..cf9ce5aa 100644 --- a/scripts/setup-lib.ts +++ b/scripts/setup-lib.ts @@ -466,3 +466,36 @@ export function pagesPatchConfirmsSitekey(result: unknown, sitekey: string): boo )?.deployment_configs?.production?.env_vars; return envVars?.TURNSTILE_SITEKEY?.value === sitekey; } + +/** + * Next-steps lines for wiring the R2 public URL (the CDN host) to the bucket. + * connect-domains is the primary path when a domain was given — it can't run + * inside setup because the zone must already be ACTIVE, and nameserver + * propagation can lag by hours. Without a domain there is nothing to hand + * connect-domains, so the dashboard walkthrough stands alone. Kept pure so a + * test can pin that the connect-domains pointer doesn't rot out of the output + * again (a real fork setup shipped broken images because nothing named it). + */ +export function cdnAttachmentLines( + r2PublicUrl: string, + bucket: string, + domainHost: string | null +): string[] { + if (domainHost) { + return [ + ` 3. Connect ${r2PublicUrl} to the bucket — setup did not touch DNS. Once the`, + ' zone is active in Cloudflare, run:', + ` CLOUDFLARE_API_TOKEN= npm run connect-domains -- ${domainHost}`, + ` It attaches ${r2PublicUrl} to the bucket and the site domain to the Pages`, + ' project, and touches nothing else in the zone. Or add the CDN host by hand:', + ` dashboard → R2 → ${bucket} → Settings → Custom Domains → add ${r2PublicUrl}.`, + ' Images 404 until this is done. Diagnose a half-finished domain setup with:', + ` npm run connect-domains -- --check ${domainHost}` + ]; + } + return [ + ` 3. Point ${r2PublicUrl} at the bucket YOURSELF (setup did not touch DNS):`, + ` Cloudflare dashboard → R2 → ${bucket} → Settings → Custom Domains → add ${r2PublicUrl},`, + ' then create the DNS record it prompts for. Images 404 until this is done.' + ]; +} diff --git a/scripts/setup.ts b/scripts/setup.ts index c7e6a028..93340f77 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -42,7 +42,8 @@ import { ciWiringEntries, cfApi, securitySummaryLines, - pagesPatchConfirmsSitekey + pagesPatchConfirmsSitekey, + cdnAttachmentLines } from './setup-lib.ts'; import { applyDownloadRateLimit, type RateLimitStatus } from './waf-lib.ts'; import { provisionTurnstileWidget, type TurnstileStatus } from './turnstile-lib.ts'; @@ -648,11 +649,13 @@ async function main() { console.log(' 1. Deploy: git push (or `npx wrangler pages deploy .svelte-kit/cloudflare`)'); console.log(` 2. Open https://${project}.pages.dev/admin/setup and finish in the wizard.`); if (useR2 && r2PublicUrl) { - console.log(` 3. Point ${r2PublicUrl} at the bucket YOURSELF (setup did not touch DNS):`); - console.log( - ` Cloudflare dashboard → R2 → ${bucket} → Settings → Custom Domains → add ${r2PublicUrl},` - ); - console.log(' then create the DNS record it prompts for. Images 404 until this is done.'); + for (const line of cdnAttachmentLines( + r2PublicUrl, + bucket, + domain ? hostFromDomain(domain) : null + )) { + console.log(line); + } } if (domain) { const host = hostFromDomain(domain); From 0daea6c63e613dc580050d161c546555488d4f80 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:04:05 -0700 Subject: [PATCH 02/11] fix(setup): make the connect-domains pointer honest, and resolve subdomain zones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round follow-ups: the printed command now names both required env vars (connect-domains exits without CLOUDFLARE_ACCOUNT_ID); the pointer only appears when the R2 public URL actually is cdn., since that's the host connect-domains attaches — overridden URLs get the dashboard walkthrough; the README scope table gains the Zone Zone Read row connect-domains needs; and connect-domains itself now resolves the zone by walking zoneNameCandidates (new pure resolveZone(), unit tested), so a subdomain fork no longer dead-ends on the exact-name lookup. Trimmed the duplicated behavior contract from the CLI lines and added a package.json contract test for the printed script names. --- README.md | 18 +++++---- scripts/connect-domains-lib.test.ts | 61 +++++++++++++++++++++++++++++ scripts/connect-domains-lib.ts | 42 +++++++++++++++++--- scripts/connect-domains.ts | 30 ++++++++------ scripts/setup-lib.test.ts | 42 +++++++++++++++++++- scripts/setup-lib.ts | 32 ++++++++------- scripts/setup.ts | 6 +-- 7 files changed, 187 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 080f75ee..f1cf555f 100644 --- a/README.md +++ b/README.md @@ -88,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 | @@ -139,18 +140,21 @@ Sona is single-admin, so there's no second account to let you back in. Two paths 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? ``` It 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** — and with *Zone · Zone Settings · Edit* on the token it also enables -Image Transformations. It touches nothing else in the zone, and every step is -idempotent, so re-running is safe. - -Two things still need a manual step — setup and connect-domains preflight them -when they can, but they need dashboard/DNS access: +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. The +token needs *Zone · Zone · Read*, *Zone · DNS · Edit*, *Account · Workers R2 +Storage · Edit*, and *Account · Cloudflare Pages · Edit* — all in the **API +token scopes** table in step 3. + +Two things can 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..d5911391 100644 --- a/scripts/connect-domains-lib.test.ts +++ b/scripts/connect-domains-lib.test.ts @@ -4,6 +4,7 @@ import { parseWranglerConfig, classifyZone, zoneGuidance, + resolveZone, findBucketDomain, cdnDomainState, bucketDomainTlsIssued, @@ -74,11 +75,71 @@ describe('zoneGuidance (fail-soft messages)', () => { expect(g).toContain('carter.ns.cf, fish.ns.cf'); }); + it('names the candidates tried instead of telling them to add the subdomain as a site', () => { + const g = zoneGuidance({ exists: false, active: false }, 'sona.taro.surf', [ + 'sona.taro.surf', + 'taro.surf' + ]); + expect(g).toContain('tried sona.taro.surf, taro.surf'); + expect(g).toContain('registrable domain'); + }); + it('returns null when the zone is active (proceed)', () => { expect(zoneGuidance({ exists: true, active: true, id: 'z1' }, 'taro.surf')).toBeNull(); }); }); +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, authStatus } = 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(authStatus).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, authStatus } = await resolveZone( + ['sona.taro.surf', 'taro.surf'], + async () => ok([]) + ); + expect(zone).toEqual({ exists: false, active: false }); + expect(zoneName).toBeNull(); + expect(authStatus).toBeNull(); + }); + + it('aborts the walk on an auth error and surfaces the status', async () => { + const tried: string[] = []; + const { zone, authStatus } = await resolveZone(['sona.taro.surf', 'taro.surf'], async (name) => { + tried.push(name); + return { ok: false, status: 403 }; + }); + expect(tried).toEqual(['sona.taro.surf']); + expect(authStatus).toBe(403); + expect(zone).toEqual({ exists: false, active: false }); + }); +}); + describe('cdnDomainState', () => { const ok = (domains: unknown[]) => ({ ok: true, status: 200, result: { domains } }); diff --git a/scripts/connect-domains-lib.ts b/scripts/connect-domains-lib.ts index 52691f06..ec94ef67 100644 --- a/scripts/connect-domains-lib.ts +++ b/scripts/connect-domains-lib.ts @@ -61,18 +61,50 @@ 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. An auth error (401/403) on any lookup aborts + * the walk — it would fail identically for every candidate — and is surfaced as + * `authStatus` for the caller's hard-error path. + */ +export async function resolveZone( + candidates: string[], + lookup: (name: string) => Promise<{ ok: boolean; status: number; result?: unknown }> +): Promise<{ zone: ZoneStatus; zoneName: string | null; authStatus: number | null }> { + for (const name of candidates) { + const res = await lookup(name); + if (!res.ok && (res.status === 401 || res.status === 403)) + return { zone: { exists: false, active: false }, zoneName: null, authStatus: res.status }; + const zone = classifyZone(res.result); + if (zone.exists) return { zone, zoneName: name, authStatus: null }; + } + return { zone: { exists: false, active: false }, zoneName: null, authStatus: null }; +} + /** * 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. `candidates` are the zone names the lookup tried (a subdomain's + * host is NOT one someone can add as a site, so the message names what was + * tried instead of telling them to add `host` itself). */ -export function zoneGuidance(zone: ZoneStatus, host: string): string | null { - if (!zone.exists) +export function zoneGuidance( + zone: ZoneStatus, + host: string, + candidates: string[] = [host] +): string | null { + if (!zone.exists) { + const tried = candidates.length > 1 ? ` (tried ${candidates.join(', ')})` : ''; 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}${tried}. Add your registrable domain to this ` + + `Cloudflare account (dashboard → Add a site), point your registrar's nameservers at ` + + `Cloudflare, then re-run.` ); + } if (!zone.active) { const ns = zone.nameServers?.length ? ` Assigned nameservers: ${zone.nameServers.join(', ')}.` : ''; return ( diff --git a/scripts/connect-domains.ts b/scripts/connect-domains.ts index b5c5dc5e..dc5ec704 100644 --- a/scripts/connect-domains.ts +++ b/scripts/connect-domains.ts @@ -28,12 +28,19 @@ 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, cdnDomainState, bucketDomainTlsIssued, pagesDomainAttached, @@ -55,9 +62,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 +139,26 @@ 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`); + // 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; anything else is diagnostic state, not a crash. + const candidates = zoneNameCandidates(host); + const { zone, authStatus } = await resolveZone(candidates, (name) => + cfApi(cfToken, `/zones?name=${encodeURIComponent(name)}`) + ); + if (authStatus !== null) { + console.error(`✖ The API token cannot read zones (HTTP ${authStatus}).\n`); console.error(TOKEN_RECIPE); return 1; } - const zone = classifyZone(zoneRes.result); if (check) { return await runDoctor({ cfToken, cfAccount, bucket, host, cdn, zone, dbName }); } // --- mutating mode ------------------------------------------------------- - const guidance = zoneGuidance(zone, host); + const guidance = zoneGuidance(zone, host, candidates); if (guidance) { console.log(`ℹ ${guidance}`); return 0; // fail soft — the registrar/propagation step is the operator's diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index 95438788..f0a3e089 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -725,15 +725,55 @@ describe('cdnAttachmentLines', () => { 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'); expect(text).toContain('Images 404 until this is done.'); }); + 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', null).join('\n'); + 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