#!/usr/bin/env node // tools/scripts/lint-vite-env.mjs // [doc->REQ-CLI-01] [impl->REQ-CLI-01] // Source: 06-CONTEXT.md D-18 + Phase 5 D-19 carryover. // Validates apps/client/.env.staging + apps/client/.env.prod: // (a) all required VITE_* keys present // (b) VITE_HTTP_BASE/VITE_WSS_URL are https/wss // (c) no secret-named keys leak into the client bundle (Vite inlines all // VITE_*-prefixed keys at build time — anything else with a secret-y // name is a leak risk if the operator accidentally prefixes it) // (d) prod placeholder VITE_ROOM_SIGNING_PUBKEY=PROD_PUBKEY_PENDING_... // has been replaced via the Phase 5 D-19 fly-ssh ritual // // Env overrides (used by tests + CI): // VITE_ENV_STAGING — defaults to apps/client/.env.staging // VITE_ENV_PROD — defaults to apps/client/.env.prod // // Usage: // node tools/scripts/lint-vite-env.mjs # scan both (default) // node tools/scripts/lint-vite-env.mjs --target staging # staging only (CI staging deploy) // node tools/scripts/lint-vite-env.mjs --target prod # prod only (CI prod deploy) // node tools/scripts/lint-vite-env.mjs --target all # both (default, fails on prod placeholder) // // The default `all` scope includes the prod-placeholder check, which acts as // a tripwire for pre-prod-deploy ritual completion (Phase 5 D-19). CI staging // deploy uses `--target staging` so the prod placeholder does not gate the // staging release. // // Exit: 0 clean, 1 violation. import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; const REQUIRED = ['VITE_WSS_URL', 'VITE_HTTP_BASE', 'VITE_ROOM_SIGNING_PUBKEY']; const FORBIDDEN_PATTERNS = [ /^BETTER_AUTH_SECRET\b/m, /^ROOM_SIGNING_PRIVATE_KEY\b/m, /^STAGING_INVITE_TOKEN\b/m, /^AWS_SECRET_ACCESS_KEY\b/m, /^AWS_ACCESS_KEY_ID\b/m, /^FLY_API_TOKEN\b/m, /^DATABASE_URL\b/m, /^TIGRIS_SECRET\b/m, ]; const PLACEHOLDER = 'PROD_PUBKEY_PENDING_FLY_SSH_PHASE_5_D19_RITUAL'; // Parse --target arg const argv = process.argv.slice(2); let target = 'all'; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--target' && argv[i + 1]) { target = argv[i + 1]; i++; } else if (argv[i].startsWith('--target=')) { target = argv[i].slice('--target='.length); } } if (!['all', 'staging', 'prod'].includes(target)) { console.error(`lint-vite-env: invalid --target=${target} (use staging|prod|all)`); process.exit(2); } const allTargets = [ { name: 'staging', path: process.env.VITE_ENV_STAGING ?? resolve(process.cwd(), 'apps/client/.env.staging'), }, { name: 'prod', path: process.env.VITE_ENV_PROD ?? resolve(process.cwd(), 'apps/client/.env.prod'), }, ]; const targets = target === 'all' ? allTargets : allTargets.filter((t) => t.name === target); const errors = []; function must(cond, msg) { if (!cond) errors.push(msg); } for (const t of targets) { if (!existsSync(t.path)) { errors.push(`${t.name}: file not found at ${t.path}`); continue; } const text = readFileSync(t.path, 'utf-8'); // (a) required keys for (const k of REQUIRED) { const re = new RegExp(`^${k}=`, 'm'); must(re.test(text), `${t.name}: missing required key: ${k}`); } // (c) forbidden secret-pattern leakage for (const re of FORBIDDEN_PATTERNS) { must( !re.test(text), `${t.name}: secret-leak: ${re.source.replace(/^\^/, '').replace(/\\b$/, '')} MUST NOT appear in client env`, ); } // (b) https/wss enforcement const httpMatch = text.match(/^VITE_HTTP_BASE=(.*)$/m); if (httpMatch) { const v = httpMatch[1].trim(); must(/^https:\/\//.test(v), `${t.name}: VITE_HTTP_BASE must be https:// — got ${v}`); } const wssMatch = text.match(/^VITE_WSS_URL=(.*)$/m); if (wssMatch) { const v = wssMatch[1].trim(); must(/^wss:\/\//.test(v), `${t.name}: VITE_WSS_URL must be wss:// — got ${v}`); } // (d) prod placeholder check if (t.name === 'prod') { must( !text.includes(PLACEHOLDER), 'prod: VITE_ROOM_SIGNING_PUBKEY still placeholder; replace via fly ssh extract (Phase 5 D-19 ritual) before prod deploy', ); } } if (errors.length) { for (const e of errors) console.error('lint-vite-env:', e); process.exit(1); } console.log('lint-vite-env: OK');