← back to Dw Signup Fulfillment
scripts/golive-preflight.js
103 lines
#!/usr/bin/env node
'use strict';
// golive-preflight.js — read-only readiness check for the Option C go-live
// (verify email → verified-sample tag → Regios). Verifies every dependency WITHOUT
// sending an email or writing anything, so it's safe to run anytime. Emits PASS/WARN/FAIL
// (the fleet-health-rollup vocabulary) and writes data/latest.json for the rollup panel.
//
// PASS = every hard dependency green, DRY_RUN already off → safe to serve live.
// WARN = launch-blocking config still pending (e.g. DRY_RUN=1, no PUBLIC_URL, Regios manual).
// FAIL = a hard dependency is broken (George auth down, missing secret/token).
//
// Run: node scripts/golive-preflight.js
const fs = require('fs');
const path = require('path');
const http = require('http');
// When run standalone (not under pm2), pull the same env pm2 injects from ecosystem.config.js
// so the preflight reflects the LIVE process config (DRY_RUN/PORT/PUBLIC_URL) instead of the
// code defaults in lib/config.js. No-override: keys already in process.env (i.e. a real pm2 run,
// or the secrets-master-resolved tokens) win. Must run BEFORE requiring ../lib/config.
try {
const eco = require('../ecosystem.config.js');
const envs = (eco.apps && eco.apps[0] && eco.apps[0].env) || {};
for (const [k, v] of Object.entries(envs)) if (process.env[k] === undefined) process.env[k] = String(v);
} catch (e) { /* ecosystem.config.js absent — fall back to lib/config.js code defaults */ }
const config = require('../lib/config');
const checks = [];
const add = (name, level, detail) => checks.push({ name, level, detail });
// Small authenticated GET helper (used only for George's health probe — no email send).
function getJSON(url, headers) {
return new Promise((resolve) => {
let u;
try { u = new URL(url); } catch (e) { return resolve({ status: 0, error: e.message }); }
const req = http.request({ hostname: u.hostname, port: u.port || 80, path: u.pathname + (u.search || ''), method: 'GET', headers }, (res) => {
let d = ''; res.on('data', (c) => (d += c));
res.on('end', () => resolve({ status: res.statusCode, body: d.slice(0, 200) }));
});
req.on('error', (e) => resolve({ status: 0, error: e.message }));
req.setTimeout(8000, () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });
req.end();
});
}
async function main() {
// 1) George Basic-auth — the exact thing that caused the outage. Authenticated GET to
// George's health endpoint: 200 = auth good, 401 = the empty-password bug is back.
const auth = config.GEORGE_BASIC_AUTH || 'admin:';
const basic = auth.startsWith('Basic ') ? auth : 'Basic ' + Buffer.from(auth.includes(':') ? auth : 'admin:' + auth).toString('base64');
const gh = await getJSON((config.GEORGE_URL || 'http://127.0.0.1:9850') + '/api/health', { Authorization: basic });
if (gh.status === 200) add('george_basic_auth', 'PASS', 'George reachable + Basic-auth accepted');
else if (gh.status === 401) add('george_basic_auth', 'FAIL', 'George 401 — Basic-auth wrong (empty-password bug?). Set GEORGE_AUTH in secrets.');
else add('george_basic_auth', 'FAIL', `George unreachable/error (status ${gh.status}${gh.error ? ' ' + gh.error : ''})`);
// 2) External-send approval token (needed for external welcome emails).
add('george_send_token', config.GEORGE_EXTERNAL_SEND_TOKEN ? 'PASS' : 'FAIL',
config.GEORGE_EXTERNAL_SEND_TOKEN ? 'set (…' + String(config.GEORGE_EXTERNAL_SEND_TOKEN).slice(-4) + ')' : 'missing — external sends will be blocked');
// 3) Verify-token signing secret (a dev fallback exists ONLY in DRY_RUN; live needs a real one).
add('verify_secret', config.VERIFY_SECRET ? 'PASS' : (config.DRY_RUN ? 'WARN' : 'FAIL'),
config.VERIFY_SECRET ? 'DW_SIGNUP_VERIFY_SECRET set' : 'missing — set DW_SIGNUP_VERIFY_SECRET before live (dev fallback is DRY-only)');
// 4) Shopify token (needed to write the verified-sample tag).
add('shopify_token', config.SHOPIFY_FULFILLMENT_TOKEN ? 'PASS' : 'FAIL',
config.SHOPIFY_FULFILLMENT_TOKEN ? 'set' : 'missing — the tag write (read_customers+write_customers) will no-op');
// 5) Public URL (verify links + webhook must resolve off localhost).
const pu = config.PUBLIC_URL;
add('public_url', pu && /^https:\/\//.test(pu) && !/localhost|127\.0\.0\.1/.test(pu) ? 'PASS' : 'WARN',
pu ? `PUBLIC_URL=${pu}` : 'PUBLIC_URL unset — verify links + webhook won\'t resolve for customers');
// 6) Reward tag name (informational).
add('verified_tag', 'PASS', `VERIFIED_TAG=${config.VERIFIED_TAG} (must equal the Regios rule tag)`);
// 7) DRY_RUN state — must be OFF to actually serve live.
add('dry_run', config.DRY_RUN ? 'WARN' : 'PASS', config.DRY_RUN ? 'DRY_RUN=1 — service is inert; flip to 0 to go live' : 'DRY_RUN=0 (live)');
// 8) Webhook token (only if using the customers/create webhook entry).
add('webhook_url_token', config.WEBHOOK_URL_TOKEN ? 'PASS' : 'WARN',
config.WEBHOOK_URL_TOKEN ? 'set' : 'unset — only needed if using the webhook trigger (the /claim form works without it)');
// 9) Local service health.
const sh = await getJSON(`http://127.0.0.1:${config.PORT}/healthz`, {});
add('service_health', sh.status === 200 ? 'PASS' : 'WARN', sh.status === 200 ? `:${config.PORT}/healthz 200` : `:${config.PORT}/healthz status ${sh.status}`);
// 10) Regios rule — not API-verifiable; always a manual gate.
add('regios_rule', 'WARN', 'MANUAL: confirm a Regios rule makes samples free for tag ' + config.VERIFIED_TAG + ', scoped to the Sample VARIANT only (roll stays full price).');
const worst = checks.some((c) => c.level === 'FAIL') ? 'FAIL' : checks.some((c) => c.level === 'WARN') ? 'WARN' : 'PASS';
const out = { skill: 'dw-signup-golive-preflight', verdict: worst, status: worst, checked_at: new Date().toISOString(), checks };
try {
fs.mkdirSync(path.join(__dirname, '..', 'data'), { recursive: true });
fs.writeFileSync(path.join(__dirname, '..', 'data', 'latest.json'), JSON.stringify(out, null, 2));
} catch (e) { console.error('WARNING: could not write data/latest.json:', e.message); }
console.log(`\nOption C go-live preflight — verdict: ${worst}\n`);
for (const c of checks) console.log(` ${c.level === 'PASS' ? '✅' : c.level === 'WARN' ? '🟠' : '❌'} ${c.name.padEnd(18)} ${c.detail}`);
console.log(`\n${worst === 'PASS' ? 'Ready to serve live.' : worst === 'WARN' ? 'Launch-blocking items remain (WARN) — resolve, then flip DRY_RUN=0.' : 'A hard dependency is broken (FAIL) — fix before launch.'}`);
console.log('wrote data/latest.json');
process.exit(worst === 'FAIL' ? 1 : 0);
}
main().catch((e) => { console.error('Preflight crashed:', e.message); process.exit(2); });