From 56b72e8b0151382bdd8255dacdcc28fff7897707 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:47:44 -0700 Subject: [PATCH 1/6] fix(setup): print the Turnstile summary for every rate-limit outcome (SONA-189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing closing brace nested the admin-login Turnstile summary inside the rate-limit 'applied' branch, so the summary dropped the Turnstile status — including the no-bot-check warning — whenever the rate limit errored, already existed, or never ran. Reported by a fork operator. Extract the two blocks into securitySummaryLines() in setup-lib.ts so a unit test can pin that the Turnstile lines print for every rate-limit outcome. --- scripts/setup-lib.test.ts | 30 +++++++++++++++++++++++++++++- scripts/setup-lib.ts | 38 ++++++++++++++++++++++++++++++++++++++ scripts/setup.ts | 24 ++++++------------------ 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index 826edbd7..d52e6a5a 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -20,7 +20,8 @@ import { imageResizingOutcome, imageResizingIsOn, ciWiringEntries, - cfApi + cfApi, + securitySummaryLines } from './setup-lib.ts'; describe('buildMigrationSql', () => { @@ -606,3 +607,30 @@ 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, 'error'); + expect(lines, `downloadRateLimit=${rl}`).toContain(turnstileWarning); + } + }); + + it('reports an applied rate limit and a created Turnstile widget together', () => { + const lines = securitySummaryLines('taro.surf', 'created', 'created'); + 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('tells the operator how to fix a scope-starved token for the rate limit', () => { + const lines = securitySummaryLines('taro.surf', 'error', null); + expect(lines.join('\n')).toContain('npm run apply-download-ratelimit -- taro.surf'); + }); + + it('stays silent about pre-existing rate limits and unattempted Turnstile', () => { + expect(securitySummaryLines('taro.surf', 'exists', null)).toEqual([]); + expect(securitySummaryLines('taro.surf', null, null)).toEqual([]); + }); +}); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index 023a9f4b..ba78cd39 100644 --- a/scripts/setup-lib.ts +++ b/scripts/setup-lib.ts @@ -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 { @@ -390,3 +393,38 @@ 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' = the token lacked the scope, which is the one case worth telling + * the operator how to fix; 'exists' rate limits are old news and stay silent. + */ +export function securitySummaryLines( + host: string, + downloadRateLimit: RateLimitStatus | null, + turnstileStatus: TurnstileStatus | null +): string[] { + const lines: string[] = []; + if (downloadRateLimit === 'error') { + lines.push(' • Public-endpoint rate limit: NOT set (token lacks Zone · WAF · Edit).'); + lines.push(' Add that permission to the token, then run:'); + lines.push(` CLOUDFLARE_API_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) { + lines.push(` • Admin-login bot check: Turnstile ${turnstileStatus} for ${host}`); + lines.push(' (TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret set; enforced once deployed).'); + } + return lines; +} diff --git a/scripts/setup.ts b/scripts/setup.ts index 738fcda0..7610ffea 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -40,7 +40,8 @@ import { imageResizingOutcome, imageResizingIsOn, ciWiringEntries, - cfApi + cfApi, + securitySummaryLines } from './setup-lib.ts'; import { applyDownloadRateLimit, type RateLimitStatus } from './waf-lib.ts'; import { provisionTurnstileWidget, type TurnstileStatus } from './turnstile-lib.ts'; @@ -655,23 +656,10 @@ 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= 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).'); + // Public-endpoint rate limit + admin-login Turnstile (status contracts and + // wording live with the helper so its test can pin them). + for (const line of securitySummaryLines(host, downloadRateLimit, turnstileStatus)) { + console.log(line); } } console.log('\n Your one-time setup token (enter it in the wizard):\n'); From f19ddd84b3e3c3f700f1ab2d6d3098726f681482 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:00:41 -0700 Subject: [PATCH 2/6] harden(scripts): typecheck scripts/ in the suite, fix the shadowed readline binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round follow-ups: nothing typechecked scripts/ (the app tsconfig includes src/ only), which is how main shipped an unparseable setup.ts for three weeks — add scripts/typecheck.test.ts, a full syntactic + semantic typecheck of every scripts/*.ts riding the ordinary vitest run. Born green after renaming the inner rl binding at the rate-limit call, which shadowed the module-level readline interface and made the DNS-scope abort path throw a ReferenceError instead of exiting cleanly. --- scripts/setup.ts | 13 ++++----- scripts/typecheck.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 scripts/typecheck.test.ts diff --git a/scripts/setup.ts b/scripts/setup.ts index 7610ffea..07095ec3 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -377,12 +377,12 @@ 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; + 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}`); } } @@ -656,8 +656,7 @@ 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 + admin-login Turnstile (status contracts and - // wording live with the helper so its test can pin them). + // Zone security: rate limit + admin-login Turnstile. for (const line of securitySummaryLines(host, downloadRateLimit, turnstileStatus)) { console.log(line); } diff --git a/scripts/typecheck.test.ts b/scripts/typecheck.test.ts new file mode 100644 index 00000000..ce014ae1 --- /dev/null +++ b/scripts/typecheck.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +// Regression guard for SONA-189: a missing brace left scripts/setup.ts +// unparseable for a month with nothing noticing — the app tsconfig only +// includes src/, so `npm run check` never sees scripts/, and vitest +// transpiles test imports without typechecking. This test typechecks every +// non-test scripts/*.ts file through the TypeScript API so a syntax or type +// error in scripts/ fails the ordinary unit suite. + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const rootDir = dirname(scriptsDir); + +const entryFiles = readdirSync(scriptsDir) + .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.spec.ts')) + .map((f) => join(scriptsDir, f)); + +// Mirrors the repo tsconfig where it matters for scripts/ (strict, bundler +// resolution, .ts-extension imports via tsx) without dragging in the +// svelte-kit generated config, which scripts/ deliberately lives outside of. +const compilerOptions: ts.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + allowImportingTsExtensions: true, + esModuleInterop: true, + resolveJsonModule: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + types: ['node'], + typeRoots: [join(rootDir, 'node_modules', '@types')], + paths: { + '$app/environment': [join(rootDir, 'vitest-stubs', 'app-environment.ts')], + '$lib/*': [join(rootDir, 'src', 'lib', '*')] + } +}; + +describe('scripts/ typecheck', () => { + it('found the script entry points', () => { + expect(entryFiles.length).toBeGreaterThan(0); + }); + + it('every scripts/*.ts file typechecks cleanly', () => { + const program = ts.createProgram(entryFiles, compilerOptions); + const diagnostics = [ + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics() + ]; + const formatted = diagnostics.map((d) => { + const where = d.file + ? `${d.file.fileName}:${d.file.getLineAndCharacterOfPosition(d.start ?? 0).line + 1}` + : '(no file)'; + return `${where} TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`; + }); + expect(formatted).toEqual([]); + }); +}); From 2c264995b48d099729bc36a8ee3827a072c6ffa1 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:27:37 -0700 Subject: [PATCH 3/6] fix(setup): only claim the admin-login bot check is enforced when its wiring landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary printed 'TURNSTILE_SITEKEY var + TURNSTILE_SECRET secret set; enforced once deployed' based solely on the widget being provisioned, while the Pages PATCH that carries the sitekey only warns on failure and putSecret swallowed its errors — and the login check fails open without either. putSecret now reports success, the PATCH result is captured, and securitySummaryLines takes a turnstileWired flag: failed wiring reads as '/admin/login has NO bot check' with the fix, never as enforced. Tests pin both directions. --- scripts/setup-lib.test.ts | 28 +++++++++++++++++++++++----- scripts/setup-lib.ts | 15 ++++++++++++++- scripts/setup.ts | 22 ++++++++++++++++++---- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index d52e6a5a..4ab3f001 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -613,24 +613,42 @@ describe('securitySummaryLines', () => { 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, 'error'); + const lines = securitySummaryLines('taro.surf', rl, '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', 'created'); + const lines = securitySummaryLines('taro.surf', 'created', '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('tells the operator how to fix a scope-starved token for the rate limit', () => { - const lines = securitySummaryLines('taro.surf', 'error', null); + const lines = securitySummaryLines('taro.surf', 'error', null, false); expect(lines.join('\n')).toContain('npm run apply-download-ratelimit -- taro.surf'); }); + 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', status, false).join('\n'); + expect(text).toContain('but the wiring FAILED'); + expect(text).toContain('/admin/login has NO bot check'); + expect(text).not.toContain('enforced once deployed'); + } + }); + + it('never prints the enforced claim unless the wiring landed', () => { + const wired = securitySummaryLines('taro.surf', null, 'created', true).join('\n'); + expect(wired).toContain('enforced once deployed'); + const unwired = securitySummaryLines('taro.surf', 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)).toEqual([]); - expect(securitySummaryLines('taro.surf', null, null)).toEqual([]); + expect(securitySummaryLines('taro.surf', 'exists', null, false)).toEqual([]); + expect(securitySummaryLines('taro.surf', null, null, false)).toEqual([]); }); }); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index ba78cd39..6a54e864 100644 --- a/scripts/setup-lib.ts +++ b/scripts/setup-lib.ts @@ -403,11 +403,17 @@ export function ciWiringEntries(input: CiWiringInput): CiEntry[] { * Status contracts: null = not attempted (no domain / no zone / no token); * 'error' = the token lacked the scope, which is the one case worth telling * the operator how to fix; '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, - turnstileStatus: TurnstileStatus | null + turnstileStatus: TurnstileStatus | null, + turnstileWired: boolean ): string[] { const lines: string[] = []; if (downloadRateLimit === 'error') { @@ -422,6 +428,13 @@ export function securitySummaryLines( 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) { + lines.push( + ` • Admin-login bot check: Turnstile widget ${turnstileStatus} for ${host}, but the wiring FAILED —` + ); + lines.push(' the TURNSTILE_SITEKEY var or TURNSTILE_SECRET secret did not attach, and the'); + lines.push(' login check fails open, so /admin/login has NO bot check. Re-run setup, or set'); + lines.push(' 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).'); diff --git a/scripts/setup.ts b/scripts/setup.ts index 07095ec3..bae86960 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -319,6 +319,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) { @@ -465,6 +468,7 @@ async function main() { method: 'PATCH', body: payload }); + pagesConfigOk = res.ok; if (res.ok) { console.log( '✔ attached D1/R2 bindings + FURTRACK_MODE to the Pages project (CI deploys get working bindings).' @@ -532,15 +536,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); @@ -551,7 +558,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, @@ -657,7 +666,12 @@ async function main() { console.log(' Until on, gallery thumbnails serve the full-size original (slow) or 404.'); } // Zone security: rate limit + admin-login Turnstile. - for (const line of securitySummaryLines(host, downloadRateLimit, turnstileStatus)) { + for (const line of securitySummaryLines( + host, + downloadRateLimit, + turnstileStatus, + pagesConfigOk && turnstileSecretSet + )) { console.log(line); } } From 2f57e3dc37ffed169cf48a52edf4e74558dc356a Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:43:32 -0700 Subject: [PATCH 4/6] test(setup): pin the turnstileWired call-site wiring; honest re-run wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped-review follow-ups: a source-contract block pins that securitySummaryLines receives pagesConfigOk && turnstileSecretSet (a forced literal previously survived the whole suite), that pagesConfigOk comes from the PATCH result, and that putSecret's catch returns false. The unwired warning is reworded as an unverified-this-run claim so a re-run whose PATCH fails doesn't falsely assert an already-wired login is unprotected — first runs still read as NO bot check. --- scripts/setup-lib.test.ts | 27 ++++++++++++++++++++++++++- scripts/setup-lib.ts | 11 +++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index 4ab3f001..1049c040 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -634,8 +634,11 @@ describe('securitySummaryLines', () => { // provisioned widget with failed wiring must never read as enforced. for (const status of ['created', 'exists'] as const) { const text = securitySummaryLines('taro.surf', 'exists', status, false).join('\n'); - expect(text).toContain('but the wiring FAILED'); + 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'); } }); @@ -652,3 +655,25 @@ describe('securitySummaryLines', () => { expect(securitySummaryLines('taro.surf', 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', () => { + expect(src).toMatch(/pagesConfigOk = res\.ok/); + }); + + it('putSecret reports failure instead of swallowing it', () => { + expect(src).toMatch(/catch\s*\{\s*\n?\s*return false/); + }); +}); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index 6a54e864..ed946151 100644 --- a/scripts/setup-lib.ts +++ b/scripts/setup-lib.ts @@ -429,12 +429,15 @@ export function securitySummaryLines( 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 the wiring FAILED —` + ` • Admin-login bot check: Turnstile widget ${turnstileStatus} for ${host}, but this run could` ); - lines.push(' the TURNSTILE_SITEKEY var or TURNSTILE_SECRET secret did not attach, and the'); - lines.push(' login check fails open, so /admin/login has NO bot check. Re-run setup, or set'); - lines.push(' the var + secret on the Pages project yourself.'); + 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).'); From 22062f75bc5045885b8640b0e7cf50b51f9ce5c6 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:48:14 -0700 Subject: [PATCH 5/6] harden(setup): confirm the Pages PATCH persisted the Turnstile sitekey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit turnstileWired trusted the PATCH's HTTP status as a proxy for the sitekey landing; a 200 whose body dropped the var would still read as wired. pagesPatchConfirmsSitekey() reads the returned project config back and requires the exact value we sent — missing or malformed bodies read as unconfirmed, which under-claims rather than over-claims. Unit tests cover confirm/reject/malformed; the call-site contract pins the composition. --- scripts/setup-lib.test.ts | 31 ++++++++++++++++++++++++++++--- scripts/setup-lib.ts | 17 +++++++++++++++++ scripts/setup.ts | 8 ++++++-- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index 1049c040..f88108d6 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -21,7 +21,8 @@ import { imageResizingIsOn, ciWiringEntries, cfApi, - securitySummaryLines + securitySummaryLines, + pagesPatchConfirmsSitekey } from './setup-lib.ts'; describe('buildMigrationSql', () => { @@ -669,11 +670,35 @@ describe('setup.ts ↔ securitySummaryLines call-site contract', () => { expect(src).toMatch(/pagesConfigOk && turnstileSecretSet/); }); - it('assigns pagesConfigOk from the Pages PATCH result', () => { - expect(src).toMatch(/pagesConfigOk = res\.ok/); + 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); + }); +}); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index ed946151..18a6e542 100644 --- a/scripts/setup-lib.ts +++ b/scripts/setup-lib.ts @@ -444,3 +444,20 @@ export function securitySummaryLines( } 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 } }; + } | null + )?.deployment_configs?.production?.env_vars; + return envVars?.TURNSTILE_SITEKEY?.value === sitekey; +} diff --git a/scripts/setup.ts b/scripts/setup.ts index bae86960..2f3a4e67 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -41,7 +41,8 @@ import { imageResizingIsOn, ciWiringEntries, cfApi, - securitySummaryLines + securitySummaryLines, + pagesPatchConfirmsSitekey } from './setup-lib.ts'; import { applyDownloadRateLimit, type RateLimitStatus } from './waf-lib.ts'; import { provisionTurnstileWidget, type TurnstileStatus } from './turnstile-lib.ts'; @@ -468,7 +469,10 @@ async function main() { method: 'PATCH', body: payload }); - pagesConfigOk = res.ok; + // 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).' From 085701f4dc198ee119cc132cb4ab21a1f94136a9 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:42:14 -0700 Subject: [PATCH 6/6] fix(setup): report the real rate-limit failure reason in the summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit securitySummaryLines assumed every rate-limit 'error' meant a missing Zone · WAF · Edit scope, but waf-lib also returns 'error' for absent zones and plain HTTP failures. Thread waf-lib's detail string through to the summary so it repeats the actual reason (falling back to a generic line), instead of always prescribing a token-scope change. --- scripts/setup-lib.test.ts | 36 ++++++++++++++++++++++++++---------- scripts/setup-lib.ts | 13 +++++++++---- scripts/setup.ts | 6 ++++++ 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index f88108d6..47346e56 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -614,27 +614,43 @@ describe('securitySummaryLines', () => { 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, 'error', true); + 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', 'created', true); + 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('tells the operator how to fix a scope-starved token for the rate limit', () => { - const lines = securitySummaryLines('taro.surf', 'error', null, false); - expect(lines.join('\n')).toContain('npm run apply-download-ratelimit -- 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', status, false).join('\n'); + 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. @@ -645,15 +661,15 @@ describe('securitySummaryLines', () => { }); it('never prints the enforced claim unless the wiring landed', () => { - const wired = securitySummaryLines('taro.surf', null, 'created', true).join('\n'); + const wired = securitySummaryLines('taro.surf', null, null, 'created', true).join('\n'); expect(wired).toContain('enforced once deployed'); - const unwired = securitySummaryLines('taro.surf', null, 'created', false).join('\n'); + 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, false)).toEqual([]); - expect(securitySummaryLines('taro.surf', null, null, false)).toEqual([]); + expect(securitySummaryLines('taro.surf', 'exists', null, null, false)).toEqual([]); + expect(securitySummaryLines('taro.surf', null, null, null, false)).toEqual([]); }); }); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index 18a6e542..b247db83 100644 --- a/scripts/setup-lib.ts +++ b/scripts/setup-lib.ts @@ -401,8 +401,10 @@ export function ciWiringEntries(input: CiWiringInput): CiEntry[] { * can pin that, and the wording, without running the CLI. * * Status contracts: null = not attempted (no domain / no zone / no token); - * 'error' = the token lacked the scope, which is the one case worth telling - * the operator how to fix; 'exists' rate limits are old news and stay silent. + * '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 @@ -412,13 +414,16 @@ export function ciWiringEntries(input: CiWiringInput): CiEntry[] { 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 (token lacks Zone · WAF · Edit).'); - lines.push(' Add that permission to the token, then run:'); + lines.push( + ` • Public-endpoint rate limit: NOT set (${downloadRateLimitDetail ?? 'provisioning failed'}).` + ); + lines.push(' Fix that, then run:'); lines.push(` CLOUDFLARE_API_TOKEN= npm run apply-download-ratelimit -- ${host}`); } else if (downloadRateLimit && downloadRateLimit !== 'exists') { lines.push( diff --git a/scripts/setup.ts b/scripts/setup.ts index 2f3a4e67..c7e6a028 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -312,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 @@ -383,6 +387,7 @@ async function main() { // result we warn about in Next steps — setup keeps going regardless. 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 { @@ -673,6 +678,7 @@ async function main() { for (const line of securitySummaryLines( host, downloadRateLimit, + downloadRateLimitDetail, turnstileStatus, pagesConfigOk && turnstileSecretSet )) {