Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down Expand Up @@ -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=<token> CLOUDFLARE_ACCOUNT_ID=<account id> # same pair as setup
npm run connect-domains -- yourdomain.com # attach cdn.<domain> → bucket, <domain> → 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 → <project>.pages.dev`
Expand Down
166 changes: 166 additions & 0 deletions scripts/connect-domains-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
parseWranglerConfig,
classifyZone,
zoneGuidance,
zoneConsentLabel,
resolveZone,
findBucketDomain,
cdnDomainState,
bucketDomainTlsIssued,
Expand Down Expand Up @@ -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 } });

Expand Down Expand Up @@ -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');
});
Expand Down
109 changes: 97 additions & 12 deletions scripts/connect-domains-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<subdomain>` 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.`
);
}
Expand Down Expand Up @@ -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[];
}

/**
Expand All @@ -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;

Expand All @@ -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.`
);
Expand All @@ -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 =
Expand Down
Loading
Loading