Skip to content
Merged
114 changes: 113 additions & 1 deletion scripts/setup-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
imageResizingOutcome,
imageResizingIsOn,
ciWiringEntries,
cfApi
cfApi,
securitySummaryLines,
pagesPatchConfirmsSitekey
} from './setup-lib.ts';

describe('buildMigrationSql', () => {
Expand Down Expand Up @@ -606,3 +608,113 @@ describe('ciWiringEntries ↔ workflow YAML contract', () => {
expect(yaml).toContain(ref);
});
});

describe('securitySummaryLines', () => {
const turnstileWarning = ' • Admin-login bot check: NOT set (token lacks Account · Turnstile · Edit).';

it('prints the Turnstile warning for EVERY rate-limit outcome (regression: a missing brace once nested it inside the applied branch)', () => {
for (const rl of [null, 'exists', 'error', 'created', 'updated'] as const) {
const lines = securitySummaryLines('taro.surf', rl, null, 'error', true);
expect(lines, `downloadRateLimit=${rl}`).toContain(turnstileWarning);
}
});

it('reports an applied rate limit and a created Turnstile widget together', () => {
const lines = securitySummaryLines('taro.surf', 'created', null, 'created', true);
expect(lines.join('\n')).toContain('Public-endpoint rate limit: applied to the taro.surf zone');
expect(lines.join('\n')).toContain('Admin-login bot check: Turnstile created for taro.surf');
});

it('repeats waf-lib’s failure reason and the retry command for a rate-limit error', () => {
const detail = 'token has no access to zone taro.surf: add Zone · WAF · Edit';
const text = securitySummaryLines('taro.surf', 'error', detail, null, false).join('\n');
expect(text).toContain(detail);
expect(text).toContain('npm run apply-download-ratelimit -- taro.surf');
});

it('does not blame token scope for a non-permission rate-limit failure', () => {
const detail = 'failed to write the rate-limit rule to taro.surf (HTTP 500)';
const text = securitySummaryLines('taro.surf', 'error', detail, null, false).join('\n');
expect(text).toContain(detail);
expect(text).not.toContain('token lacks Zone · WAF · Edit');
expect(text).toContain('npm run apply-download-ratelimit -- taro.surf');
});

it('falls back to a generic failure line when no detail survived', () => {
const text = securitySummaryLines('taro.surf', 'error', null, null, false).join('\n');
expect(text).toContain('NOT set (provisioning failed)');
expect(text).not.toContain('token lacks');
});

it('reports NO bot check when the widget provisioned but the wiring failed', () => {
// The login check fails open without the sitekey var + secret, so a
// provisioned widget with failed wiring must never read as enforced.
for (const status of ['created', 'exists'] as const) {
const text = securitySummaryLines('taro.surf', 'exists', null, status, false).join('\n');
expect(text).toContain('NOT confirm the TURNSTILE_SITEKEY');
expect(text).toContain('/admin/login has NO bot check');
// Honest on re-runs: the claim is scoped to this run / first runs.
expect(text).toContain('this run');
expect(text).toContain('first run');
expect(text).not.toContain('enforced once deployed');
}
});

it('never prints the enforced claim unless the wiring landed', () => {
const wired = securitySummaryLines('taro.surf', null, null, 'created', true).join('\n');
expect(wired).toContain('enforced once deployed');
const unwired = securitySummaryLines('taro.surf', null, null, 'created', false).join('\n');
expect(unwired).not.toContain('enforced once deployed');
});

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([]);
});
});

describe('setup.ts ↔ securitySummaryLines call-site contract', () => {
// main() is not importable (it drives live Cloudflare state), so pin the
// wiring at the source level: turnstileWired must be composed from the real
// PATCH result and the real secret-put result — forcing a literal here once
// survived the entire suite while reintroducing the very over-claim the
// helper exists to prevent.
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'setup.ts'), 'utf8');

it('passes pagesConfigOk && turnstileSecretSet, not a literal', () => {
expect(src).toMatch(/securitySummaryLines\(/);
expect(src).toMatch(/pagesConfigOk && turnstileSecretSet/);
});

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\)/);
});

it('putSecret reports failure instead of swallowing it', () => {
expect(src).toMatch(/catch\s*\{\s*\n?\s*return false/);
});
});

describe('pagesPatchConfirmsSitekey', () => {
const body = (value?: string) => ({
deployment_configs: {
production: { env_vars: value === undefined ? {} : { TURNSTILE_SITEKEY: { value } } }
}
});

it('confirms when the response echoes the sitekey we sent', () => {
expect(pagesPatchConfirmsSitekey(body('0xKEY'), '0xKEY')).toBe(true);
});

it('rejects a response that dropped or changed the var', () => {
expect(pagesPatchConfirmsSitekey(body(), '0xKEY')).toBe(false);
expect(pagesPatchConfirmsSitekey(body('0xOTHER'), '0xKEY')).toBe(false);
});

it('reads a missing/malformed body as unconfirmed (the safe direction)', () => {
expect(pagesPatchConfirmsSitekey(undefined, '0xKEY')).toBe(false);
expect(pagesPatchConfirmsSitekey({}, '0xKEY')).toBe(false);
expect(pagesPatchConfirmsSitekey({ deployment_configs: null }, '0xKEY')).toBe(false);
});
});
76 changes: 76 additions & 0 deletions scripts/setup-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
* tested without a Cloudflare account or a live shell.
*/

import type { RateLimitStatus } from './waf-lib.ts';
import type { TurnstileStatus } from './turnstile-lib.ts';

const sqlStr = (s: string) => s.replace(/'/g, "''");

export interface Migration {
Expand Down Expand Up @@ -390,3 +393,76 @@ export function ciWiringEntries(input: CiWiringInput): CiEntry[] {
{ kind: 'variable', name: 'FURTRACK_MODE', value: input.furtrackMode }
];
}

/**
* End-of-run summary lines for the zone-security provisioning (public-endpoint
* rate limit + admin-login Turnstile). The two features are independent, so the
* Turnstile lines must print for every rate-limit outcome — kept pure so a test
* can pin that, and the wording, without running the CLI.
*
* Status contracts: null = not attempted (no domain / no zone / no token);
* 'error' = provisioning failed — `downloadRateLimitDetail` carries waf-lib's
* reason (missing scope, absent zone, HTTP failure), which the summary repeats
* instead of assuming a cause; 'exists' rate limits are old news and stay
* silent.
*
* `turnstileWired` says whether BOTH halves of the wiring actually landed (the
* Pages PATCH carrying TURNSTILE_SITEKEY and the TURNSTILE_SECRET put). The
* login check fails open when either is missing, so a provisioned widget with
* failed wiring must read as NOT protected — never as enforced.
*/
export function securitySummaryLines(
host: string,
downloadRateLimit: RateLimitStatus | null,
downloadRateLimitDetail: string | null,
turnstileStatus: TurnstileStatus | null,
turnstileWired: boolean
): string[] {
const lines: string[] = [];
if (downloadRateLimit === 'error') {
lines.push(
` • Public-endpoint rate limit: NOT set (${downloadRateLimitDetail ?? 'provisioning failed'}).`
);
lines.push(' Fix that, then run:');
lines.push(` CLOUDFLARE_API_TOKEN=<token> npm run apply-download-ratelimit -- ${host}`);
} else if (downloadRateLimit && downloadRateLimit !== 'exists') {
lines.push(
` • Public-endpoint rate limit: applied to the ${host} zone (download beacon + oEmbed).`
);
}
if (turnstileStatus === 'error') {
lines.push(' • Admin-login bot check: NOT set (token lacks Account · Turnstile · Edit).');
lines.push(' Add that permission to the token and re-run setup to protect /admin/login.');
} else if (turnstileStatus && !turnstileWired) {
// Worded as an unverified-THIS-RUN claim: on a re-run, a previous run may
// have wired the project already, so "no bot check" would be false there —
// but on a first run it's exactly true, and that's the case that matters.
lines.push(
` • Admin-login bot check: Turnstile widget ${turnstileStatus} for ${host}, but this run could`
);
lines.push(' NOT confirm the TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret attached. On a');
lines.push(' first run that means /admin/login has NO bot check (it fails open without both) —');
lines.push(' re-run setup, or set the var + secret on the Pages project yourself.');
} else if (turnstileStatus) {
lines.push(` • Admin-login bot check: Turnstile ${turnstileStatus} for ${host}`);
lines.push(' (TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret set; enforced once deployed).');
}
return lines;
}

/**
* True when a Pages-project PATCH response confirms TURNSTILE_SITEKEY persisted
* with the value we sent. The PATCH returns the updated project; a 200 whose
* body silently dropped the var must not be reported as wired (the login check
* fails open without the sitekey), so the summary's turnstileWired flag keys
* off this read-back, not the HTTP status alone. Missing/malformed bodies read
* as unconfirmed — the safe, under-claiming direction.
*/
export function pagesPatchConfirmsSitekey(result: unknown, sitekey: string): boolean {
const envVars = (
result as {
deployment_configs?: { production?: { env_vars?: Record<string, { value?: string } | null> } };
} | null
)?.deployment_configs?.production?.env_vars;
return envVars?.TURNSTILE_SITEKEY?.value === sitekey;
}
63 changes: 37 additions & 26 deletions scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import {
imageResizingOutcome,
imageResizingIsOn,
ciWiringEntries,
cfApi
cfApi,
securitySummaryLines,
pagesPatchConfirmsSitekey
} from './setup-lib.ts';
import { applyDownloadRateLimit, type RateLimitStatus } from './waf-lib.ts';
import { provisionTurnstileWidget, type TurnstileStatus } from './turnstile-lib.ts';
Expand Down Expand Up @@ -310,6 +312,10 @@ async function main() {
// runs on a zone the operator controls — a *.pages.dev-only fork has no zone to
// attach it to. Null = not attempted (no domain / no zone / no token).
let downloadRateLimit: RateLimitStatus | null = null;
// The human-readable reason behind a rate-limit 'error' — waf-lib names the
// actual failure (scope, missing zone, HTTP error), and the summary should
// repeat that instead of guessing at a cause.
let downloadRateLimitDetail: string | null = null;
// Admin-login Turnstile widget. Only meaningful with a custom
// domain — a *.pages.dev-only fork isn't provisioned one. Its sitekey (public)
// is set as a Pages var below and its secret as a Pages secret; the login page
Expand All @@ -318,6 +324,9 @@ async function main() {
let turnstileStatus: TurnstileStatus | null = null;
let turnstileSitekey = '';
let turnstileSecret = '';
// Whether the Pages-project PATCH (which carries TURNSTILE_SITEKEY) landed —
// the summary reports the bot check as wired only when it did.
let pagesConfigOk = false;
if (domain) {
const host = hostFromDomain(domain);
if (cfToken && cfAccount) {
Expand Down Expand Up @@ -376,12 +385,13 @@ async function main() {
// beacon + oEmbed provider — one rule, Free-plan cap). Non-fatal:
// a token without Zone · WAF · Edit just yields an 'error'
// result we warn about in Next steps — setup keeps going regardless.
const rl = await applyDownloadRateLimit(cfToken, host);
downloadRateLimit = rl.status;
if (rl.status === 'error') {
console.warn(`\n⚠ Could not attach the public-endpoint rate-limit rule: ${rl.detail}`);
const rateLimit = await applyDownloadRateLimit(cfToken, host);
downloadRateLimit = rateLimit.status;
downloadRateLimitDetail = rateLimit.detail;
if (rateLimit.status === 'error') {
console.warn(`\n⚠ Could not attach the public-endpoint rate-limit rule: ${rateLimit.detail}`);
} else {
console.log(`✔ Public-endpoint rate limit: ${rl.detail}`);
console.log(`✔ Public-endpoint rate limit: ${rateLimit.detail}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -464,6 +474,10 @@ async function main() {
method: 'PATCH',
body: payload
});
// A 200 whose body dropped the sitekey must not read as wired — confirm the
// PATCH persisted TURNSTILE_SITEKEY (skipped when no widget was provisioned).
pagesConfigOk =
res.ok && (!turnstileSitekey || pagesPatchConfirmsSitekey(res.result, turnstileSitekey));
if (res.ok) {
console.log(
'✔ attached D1/R2 bindings + FURTRACK_MODE to the Pages project (CI deploys get working bindings).'
Expand Down Expand Up @@ -531,15 +545,18 @@ async function main() {
// 7. Generate + set secrets. SETUP_TOKEN gates the first-run wizard.
const setupToken = token();
const cronSecret = token();
const putSecret = (name: string, value: string) => {
const putSecret = (name: string, value: string): boolean => {
// Feed the value over stdin (never the command line or the log) so the
// secret is not echoed to the console or exposed in the process list.
// Returns whether the put succeeded — the summary must not claim a
// security control is wired when the write silently failed.
const cmd = `npx wrangler pages secret put ${name} --project-name ${project}`;
console.log(`\n$ ${cmd}`);
try {
execSync(cmd, { input: `${value}\n`, stdio: ['pipe', 'inherit', 'inherit'] });
return true;
} catch {
// allowFail
return false; // allowFail
}
};
putSecret('SETUP_TOKEN', setupToken);
Expand All @@ -550,7 +567,9 @@ async function main() {
if (resendFrom) putSecret('RESEND_FROM', resendFrom);
// Turnstile secret for the admin-login siteverify. Server-only, so
// it's a Pages secret (never a plain var); the public sitekey was set above.
if (turnstileSecret) putSecret('TURNSTILE_SECRET', turnstileSecret);
// The login check fails open without it, so remember whether the put landed.
let turnstileSecretSet = false;
if (turnstileSecret) turnstileSecretSet = putSecret('TURNSTILE_SECRET', turnstileSecret);

// 8. Offer to wire the fork's GitHub Actions secrets/vars so CI deploys work
// with no separate manual step. Only when gh is installed + authenticated,
Expand Down Expand Up @@ -655,23 +674,15 @@ async function main() {
console.log(' "Resize images from any origin". Free tier: 5,000 transformations/month.');
console.log(' Until on, gallery thumbnails serve the full-size original (slow) or 404.');
}
// Public-endpoint rate limit. null = not attempted (no domain / no zone / no
// token — same contract as the declaration above); 'error' = the token lacked
// Zone · WAF · Edit, which is the one case worth telling them how to fix.
if (downloadRateLimit === 'error') {
console.log(' • Public-endpoint rate limit: NOT set (token lacks Zone · WAF · Edit).');
console.log(' Add that permission to the token, then run:');
console.log(` CLOUDFLARE_API_TOKEN=<token> npm run apply-download-ratelimit -- ${host}`);
} else if (downloadRateLimit && downloadRateLimit !== 'exists') {
console.log(` • Public-endpoint rate limit: applied to the ${host} zone (download beacon + oEmbed).`);
// Admin-login Turnstile. 'error' = token lacked the scope, so the
// login has no bot check; otherwise the sitekey/secret are wired and enforced.
if (turnstileStatus === 'error') {
console.log(' • Admin-login bot check: NOT set (token lacks Account · Turnstile · Edit).');
console.log(' Add that permission to the token and re-run setup to protect /admin/login.');
} else if (turnstileStatus) {
console.log(` • Admin-login bot check: Turnstile ${turnstileStatus} for ${host}`);
console.log(' (TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret set; enforced once deployed).');
// Zone security: rate limit + admin-login Turnstile.
for (const line of securitySummaryLines(
host,
downloadRateLimit,
downloadRateLimitDetail,
turnstileStatus,
pagesConfigOk && turnstileSecretSet
)) {
console.log(line);
}
}
console.log('\n Your one-time setup token (enter it in the wizard):\n');
Expand Down
Loading
Loading