Blame · Line-by-line history
production-readiness.mjs
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 38de014 | 1 | #!/usr/bin/env node |
| 2 | /** | |
| 3 | * Production-readiness gate. | |
| 4 | * | |
| 5 | * Answers one question: "would launching right now expose a class of fault we | |
| 6 | * have already seen?" Every HARD gate below exists because it caught a real | |
| 7 | * defect on 2026-07-26/27 — none are hypothetical. | |
| 8 | * | |
| 9 | * privacy GET /api/repos/:o/:n returned a PRIVATE repo's full row | |
| 10 | * (isPrivate, ownerId, on-disk path) to an anonymous caller. | |
| 11 | * api-json /api/* 404s rendered a 126KB HTML page instead of JSON, so | |
| 12 | * API and MCP clients failed opaquely. | |
| 13 | * provenance The container never received a build SHA, so /api/version | |
| 14 | * reported "unknown" forever: the auto-update banner could | |
| 15 | * never fire and the PWA cache key never rotated. | |
| 16 | * render Catches JS exceptions / broken images on real pages. | |
| 17 | * overflow /docs/agents was 3406px wide in a 1440 viewport; /login | |
| 18 | * overflowed at 390px. | |
| 19 | * auth-gate Authenticated-only paths must not render to anonymous users. | |
| 20 | * | |
| 21 | * SOFT gates warn but do not block. | |
| 22 | * | |
| 23 | * Usage: | |
| 24 | * node scripts/production-readiness.mjs [--base https://gluecron.com] | |
| 25 | * [--expect-sha <sha>] | |
| 26 | * [--private-repo owner/name] | |
| 27 | * [--json out.json] | |
| 28 | * | |
| 29 | * Exit codes: 0 = all HARD gates pass · 1 = a HARD gate failed · 2 = crashed. | |
| 30 | * | |
| 31 | * Deliberately does NOT authenticate. Everything here must hold for an | |
| 32 | * anonymous visitor, which is exactly the threat model for opening signups. | |
| 33 | */ | |
| 34 | ||
| 35 | import { chromium } from '@playwright/test'; | |
| 36 | import { writeFileSync } from 'fs'; | |
| 37 | ||
| 38 | const argv = process.argv.slice(2); | |
| 39 | const arg = (name, fallback = null) => { | |
| 40 | const i = argv.indexOf(`--${name}`); | |
| 41 | return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; | |
| 42 | }; | |
| 43 | ||
| 44 | const BASE = (arg('base', process.env.SMOKE_HOST || 'https://gluecron.com')).replace(/\/$/, ''); | |
| 45 | const EXPECT_SHA = arg('expect-sha', process.env.EXPECT_SHA); | |
| 46 | const PRIVATE_REPO = arg('private-repo', process.env.PRIVATE_REPO); // "owner/name" | |
| 47 | const JSON_OUT = arg('json'); | |
| 48 | ||
| 49 | // Pages sampled for render/overflow gates. Keep this list small and | |
| 50 | // representative — it runs on every deploy. | |
| 51 | const PAGES = [ | |
| 52 | '/', '/explore', '/pricing', '/docs', '/docs/agents', '/docs/api', | |
| 53 | '/login', '/register', '/marketplace', '/enterprise', | |
| 54 | ]; | |
| 55 | // Must never render for an anonymous caller. | |
| 56 | const AUTH_ONLY = ['/dashboard', '/settings', '/admin', '/settings/tokens']; | |
| 57 | const PERF_BUDGET_MS = 3000; // soft | |
| 58 | ||
| 59 | const results = []; | |
| 60 | const add = (gate, hard, ok, detail) => results.push({ gate, hard, ok, detail }); | |
| 61 | ||
| 62 | async function main() { | |
| 63 | console.log(`[readiness] target: ${BASE}\n`); | |
| 64 | ||
| 65 | // ── HARD: build provenance ──────────────────────────────────────────── | |
| 66 | try { | |
| 67 | const r = await fetch(`${BASE}/api/version`); | |
| 68 | const v = await r.json(); | |
| 69 | const sha = String(v.sha || ''); | |
| 70 | const bad = !sha || sha === 'unknown' || sha === 'dev'; | |
| 71 | if (bad) { | |
| 72 | add('provenance', true, false, | |
| 73 | `/api/version reports sha="${sha}" — the build SHA never reached the running process, so deploy detection and PWA cache-busting are both inert`); | |
| 74 | } else if (EXPECT_SHA && !EXPECT_SHA.startsWith(v.shaFull) && !v.shaFull?.startsWith(sha)) { | |
| 75 | add('provenance', true, false, `live sha ${v.shaFull} != expected ${EXPECT_SHA} — deploy did not land`); | |
| 76 | } else { | |
| 77 | add('provenance', true, true, `sha=${sha} builtAt=${v.builtAt}`); | |
| 78 | } | |
| 79 | } catch (e) { | |
| 80 | add('provenance', true, false, `/api/version unreachable: ${e.message}`); | |
| 81 | } | |
| 82 | ||
| 83 | // ── HARD: /api/* must speak JSON, never HTML ────────────────────────── | |
| 84 | try { | |
| 85 | const r = await fetch(`${BASE}/api/definitely-not-a-real-endpoint-${Date.now()}`); | |
| 86 | const ct = r.headers.get('content-type') || ''; | |
| 87 | const body = (await r.text()).slice(0, 400); | |
| 88 | const isHtml = ct.includes('text/html') || body.trimStart().startsWith('<'); | |
| 89 | add('api-json', true, !isHtml, | |
| 90 | isHtml | |
| 91 | ? `unknown /api/* path returned ${r.status} as ${ct || 'HTML'} — API and MCP clients cannot parse this` | |
| 92 | : `unknown /api/* path returned ${r.status} as ${ct}`); | |
| 93 | } catch (e) { | |
| 94 | add('api-json', true, false, `probe failed: ${e.message}`); | |
| 95 | } | |
| 96 | ||
| 97 | // ── HARD: a private repo must not be disclosed anonymously ──────────── | |
| 98 | if (PRIVATE_REPO) { | |
| 99 | const [o, n] = PRIVATE_REPO.split('/'); | |
| 100 | const surfaces = [`/api/repos/${o}/${n}`, `/${o}/${n}`, `/api/v2/repos/${o}/${n}`]; | |
| 101 | const leaks = []; | |
| 102 | for (const path of surfaces) { | |
| 103 | try { | |
| 104 | const r = await fetch(`${BASE}${path}`); | |
| 105 | if (r.status !== 200) continue; | |
| 106 | const body = await r.text(); | |
| 107 | // A 200 alone is not proof; look for fields only the real row carries. | |
| 108 | if (/"isPrivate"|"diskPath"|"ownerId"/.test(body)) { | |
| 109 | leaks.push(`${path} -> 200 disclosing repo metadata`); | |
| 110 | } | |
| 111 | } catch { /* unreachable surface is not a leak */ } | |
| 112 | } | |
| 113 | add('privacy', true, leaks.length === 0, | |
| 114 | leaks.length ? leaks.join('; ') : `no anonymous disclosure across ${surfaces.length} surfaces`); | |
| 115 | } else { | |
| 116 | add('privacy', true, true, 'SKIPPED — pass --private-repo owner/name to enable (strongly recommended)'); | |
| 117 | } | |
| 118 | ||
| 119 | // ── HARD: auth-only pages must not render anonymously ───────────────── | |
| 120 | const rendered = []; | |
| 121 | for (const p of AUTH_ONLY) { | |
| 122 | try { | |
| 123 | const r = await fetch(`${BASE}${p}`, { redirect: 'manual' }); | |
| 124 | // 2xx that actually renders the page is the failure. 3xx/401/404 are fine. | |
| 125 | if (r.status >= 200 && r.status < 300) { | |
| 126 | const body = await r.text(); | |
| 127 | if (!/sign in|log ?in|password/i.test(body.slice(0, 4000))) rendered.push(`${p} -> ${r.status}`); | |
| 128 | } | |
| 129 | } catch { /* ignore */ } | |
| 130 | } | |
| 131 | add('auth-gate', true, rendered.length === 0, | |
| 132 | rendered.length ? `rendered to anonymous caller: ${rendered.join(', ')}` : `${AUTH_ONLY.length} paths correctly gated`); | |
| 133 | ||
| 134 | // ── Render + overflow gates (real browser) ──────────────────────────── | |
| 135 | const browser = await chromium.launch(); | |
| 136 | const jsErrors = [], overflows = [], brokenImgs = [], slow = [], noH1 = []; | |
| 137 | ||
| 138 | for (const vp of [{ w: 1440, h: 900 }, { w: 390, h: 844 }]) { | |
| 139 | const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } }); | |
| 140 | for (const path of PAGES) { | |
| 141 | const page = await ctx.newPage(); | |
| 142 | const errs = []; | |
| 143 | page.on('pageerror', (e) => errs.push(String(e).slice(0, 200))); | |
| 144 | const t0 = Date.now(); | |
| 145 | try { | |
| 146 | await page.goto(BASE + path, { waitUntil: 'domcontentloaded', timeout: 45000 }); | |
| 147 | try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch {} | |
| 148 | const ms = Date.now() - t0; | |
| 149 | const probe = await page.evaluate(() => ({ | |
| 150 | scrollW: document.documentElement.scrollWidth, | |
| 151 | innerW: window.innerWidth, | |
| 152 | h1: document.querySelectorAll('h1').length, | |
| 153 | broken: Array.from(document.images) | |
| 154 | .filter((im) => im.complete && im.naturalWidth === 0) | |
| 155 | .map((im) => im.src.slice(0, 120)), | |
| 156 | })); | |
| 157 | if (probe.scrollW > probe.innerW + 2) { | |
| 158 | overflows.push(`${path} @${vp.w}: ${probe.scrollW}px (+${probe.scrollW - probe.innerW})`); | |
| 159 | } | |
| 160 | if (probe.broken.length) brokenImgs.push(`${path}: ${probe.broken.length}`); | |
| 161 | if (vp.w === 1440) { | |
| 162 | if (probe.h1 === 0) noH1.push(path); | |
| 163 | if (ms > PERF_BUDGET_MS) slow.push(`${path} ${ms}ms`); | |
| 164 | } | |
| 165 | } catch (e) { | |
| 166 | errs.push(`navigation failed: ${String(e).slice(0, 160)}`); | |
| 167 | } | |
| 168 | if (errs.length) jsErrors.push(`${path} @${vp.w}: ${errs[0]}`); | |
| 169 | await page.close(); | |
| 170 | } | |
| 171 | await ctx.close(); | |
| 172 | } | |
| 173 | await browser.close(); | |
| 174 | ||
| 175 | add('render', true, jsErrors.length === 0, | |
| 176 | jsErrors.length ? jsErrors.slice(0, 5).join(' | ') : `${PAGES.length} pages x2 viewports, no uncaught JS errors`); | |
| 177 | add('overflow', true, overflows.length === 0, | |
| 178 | overflows.length ? overflows.slice(0, 6).join(' | ') : 'no horizontal overflow at 1440 or 390'); | |
| 179 | add('images', false, brokenImgs.length === 0, | |
| 180 | brokenImgs.length ? brokenImgs.join(' | ') : 'no broken images'); | |
| 181 | add('headings', false, noH1.length === 0, | |
| 182 | noH1.length ? `${noH1.length} page(s) with no <h1>: ${noH1.slice(0, 6).join(', ')}` : 'every sampled page has an h1'); | |
| 183 | add('perf', false, slow.length === 0, | |
| 184 | slow.length ? `over ${PERF_BUDGET_MS}ms: ${slow.join(', ')}` : `all sampled pages under ${PERF_BUDGET_MS}ms`); | |
| 185 | ||
| 186 | // ── Report ──────────────────────────────────────────────────────────── | |
| 187 | const pad = (s, n) => String(s).padEnd(n); | |
| 188 | console.log(pad('GATE', 13) + pad('KIND', 7) + pad('RESULT', 8) + 'DETAIL'); | |
| 189 | console.log('-'.repeat(100)); | |
| 190 | for (const r of results) { | |
| 191 | console.log( | |
| 192 | pad(r.gate, 13) + pad(r.hard ? 'HARD' : 'soft', 7) + | |
| 193 | pad(r.ok ? 'PASS' : 'FAIL', 8) + r.detail | |
| 194 | ); | |
| 195 | } | |
| 196 | ||
| 197 | const hardFails = results.filter((r) => r.hard && !r.ok); | |
| 198 | const softFails = results.filter((r) => !r.hard && !r.ok); | |
| 199 | console.log(''); | |
| 200 | console.log(`[readiness] HARD ${results.filter(r => r.hard && r.ok).length}/${results.filter(r => r.hard).length} passed · soft warnings: ${softFails.length}`); | |
| 201 | ||
| 202 | if (JSON_OUT) { | |
| 203 | writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2)); | |
| 204 | console.log(`[readiness] wrote ${JSON_OUT}`); | |
| 205 | } | |
| 206 | ||
| 207 | if (hardFails.length) { | |
| 208 | console.error(`\n[readiness] NOT READY — ${hardFails.length} hard gate(s) failed:`); | |
| 209 | for (const f of hardFails) console.error(` - ${f.gate}: ${f.detail}`); | |
| 210 | process.exit(1); | |
| 211 | } | |
| 212 | console.log('\n[readiness] all hard gates passed'); | |
| 213 | process.exit(0); | |
| 214 | } | |
| 215 | ||
| 216 | main().catch((e) => { | |
| 217 | console.error('[readiness] crashed:', e); | |
| 218 | process.exit(2); | |
| 219 | }); |