diff --git a/scripts/setup-lib.test.ts b/scripts/setup-lib.test.ts index 826edbd7..47346e56 100644 --- a/scripts/setup-lib.test.ts +++ b/scripts/setup-lib.test.ts @@ -20,7 +20,9 @@ import { imageResizingOutcome, imageResizingIsOn, ciWiringEntries, - cfApi + cfApi, + securitySummaryLines, + pagesPatchConfirmsSitekey } from './setup-lib.ts'; describe('buildMigrationSql', () => { @@ -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); + }); +}); diff --git a/scripts/setup-lib.ts b/scripts/setup-lib.ts index 023a9f4b..b247db83 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,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= 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 } }; + } | null + )?.deployment_configs?.production?.env_vars; + return envVars?.TURNSTILE_SITEKEY?.value === sitekey; +} diff --git a/scripts/setup.ts b/scripts/setup.ts index 738fcda0..c7e6a028 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -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'; @@ -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 @@ -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) { @@ -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}`); } } @@ -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).' @@ -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); @@ -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, @@ -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= 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'); 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([]); + }); +});