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'; | |
| 45ac593 | 36 | import { writeFileSync, readdirSync, readFileSync, statSync } from 'fs'; |
| 37 | import { join } from 'path'; | |
| 38de014 | 38 | |
| 39 | const argv = process.argv.slice(2); | |
| 40 | const arg = (name, fallback = null) => { | |
| 41 | const i = argv.indexOf(`--${name}`); | |
| 42 | return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; | |
| 43 | }; | |
| 44 | ||
| 45 | const BASE = (arg('base', process.env.SMOKE_HOST || 'https://gluecron.com')).replace(/\/$/, ''); | |
| 46 | const EXPECT_SHA = arg('expect-sha', process.env.EXPECT_SHA); | |
| 47 | const PRIVATE_REPO = arg('private-repo', process.env.PRIVATE_REPO); // "owner/name" | |
| 45ac593 | 48 | const PUBLIC_REPO = arg('public-repo', process.env.PUBLIC_REPO); // "owner/name" |
| 49 | const OWNER_PAT = process.env.GLUECRON_PAT; // owner of PRIVATE_REPO | |
| 50 | const NONMEMBER_PAT = process.env.NONMEMBER_PAT; // any account with no access | |
| 38de014 | 51 | const JSON_OUT = arg('json'); |
| 52 | ||
| 45ac593 | 53 | /** |
| 54 | * Every repo-scoped route the app registers, read from source at run time. | |
| 55 | * | |
| 56 | * Deliberately NOT a hand-maintained list. The 24 private-repo subpages that | |
| 57 | * were leaking on 2026-07-28 leaked precisely because each route owned its own | |
| 58 | * privacy check and nobody kept a central list in step. A gate with a literal | |
| 59 | * array would drift the same way — a route added tomorrow would be untested | |
| 60 | * and silently uncovered. Parsing the router registrations means new routes are | |
| 61 | * covered the moment they exist. | |
| 62 | */ | |
| 63 | function repoScopedGetRoutes(root = 'src/routes') { | |
| 64 | const files = []; | |
| 65 | const walk = (d) => { | |
| 66 | for (const e of readdirSync(d)) { | |
| 67 | const p = join(d, e); | |
| 68 | if (statSync(p).isDirectory()) walk(p); | |
| 69 | else if (e.endsWith('.ts') || e.endsWith('.tsx')) files.push(p); | |
| 70 | } | |
| 71 | }; | |
| 72 | try { walk(root); } catch { return []; } | |
| 73 | ||
| 74 | const re = /\.(get)\(\s*(["'`])(\/:owner\/:repo[^"'`\n]*)\2/g; | |
| 75 | const paths = new Set(); | |
| 76 | for (const f of files) { | |
| 77 | const src = readFileSync(f, 'utf8'); | |
| 78 | let m; | |
| 79 | re.lastIndex = 0; | |
| 80 | while ((m = re.exec(src))) { | |
| 81 | let p = m[3]; | |
| 82 | // Skip the git Smart HTTP surface: `:repo.git` has its own auth and | |
| 83 | // returns 401, not a page. | |
| 84 | if (p.includes('.git')) continue; | |
| 85 | // Drop Hono regex constraints: "/:ref{.+$}" -> "/:ref". | |
| 86 | p = p.replace(/\{[^}]*\}/g, ''); | |
| 87 | // Routes needing a synthetic id we cannot fabricate are still worth | |
| 88 | // probing — a bogus id should 404, so a 200 is a leak either way. | |
| 89 | paths.add(p); | |
| 90 | } | |
| 91 | } | |
| 92 | return [...paths].sort(); | |
| 93 | } | |
| 94 | ||
| 95 | /** Fill :owner/:repo, and give any remaining params a placeholder. */ | |
| 96 | function materialize(routePath, owner, repo) { | |
| 97 | return routePath | |
| 98 | .replace('/:owner/:repo', `/${owner}/${repo}`) | |
| 99 | .replace(/\/:[A-Za-z_]+\??/g, '/_probe') | |
| 100 | .replace(/\/\*$/, '/_probe'); | |
| 101 | } | |
| 102 | ||
| 38de014 | 103 | // Pages sampled for render/overflow gates. Keep this list small and |
| 104 | // representative — it runs on every deploy. | |
| 105 | const PAGES = [ | |
| 106 | '/', '/explore', '/pricing', '/docs', '/docs/agents', '/docs/api', | |
| 107 | '/login', '/register', '/marketplace', '/enterprise', | |
| 108 | ]; | |
| 109 | // Must never render for an anonymous caller. | |
| 110 | const AUTH_ONLY = ['/dashboard', '/settings', '/admin', '/settings/tokens']; | |
| 111 | const PERF_BUDGET_MS = 3000; // soft | |
| 112 | ||
| 113 | const results = []; | |
| 114 | const add = (gate, hard, ok, detail) => results.push({ gate, hard, ok, detail }); | |
| 115 | ||
| 116 | async function main() { | |
| 117 | console.log(`[readiness] target: ${BASE}\n`); | |
| 118 | ||
| 119 | // ── HARD: build provenance ──────────────────────────────────────────── | |
| 120 | try { | |
| 121 | const r = await fetch(`${BASE}/api/version`); | |
| 122 | const v = await r.json(); | |
| 123 | const sha = String(v.sha || ''); | |
| 124 | const bad = !sha || sha === 'unknown' || sha === 'dev'; | |
| 125 | if (bad) { | |
| 126 | add('provenance', true, false, | |
| 127 | `/api/version reports sha="${sha}" — the build SHA never reached the running process, so deploy detection and PWA cache-busting are both inert`); | |
| 128 | } else if (EXPECT_SHA && !EXPECT_SHA.startsWith(v.shaFull) && !v.shaFull?.startsWith(sha)) { | |
| 129 | add('provenance', true, false, `live sha ${v.shaFull} != expected ${EXPECT_SHA} — deploy did not land`); | |
| 130 | } else { | |
| 131 | add('provenance', true, true, `sha=${sha} builtAt=${v.builtAt}`); | |
| 132 | } | |
| 133 | } catch (e) { | |
| 134 | add('provenance', true, false, `/api/version unreachable: ${e.message}`); | |
| 135 | } | |
| 136 | ||
| 137 | // ── HARD: /api/* must speak JSON, never HTML ────────────────────────── | |
| 138 | try { | |
| 139 | const r = await fetch(`${BASE}/api/definitely-not-a-real-endpoint-${Date.now()}`); | |
| 140 | const ct = r.headers.get('content-type') || ''; | |
| 141 | const body = (await r.text()).slice(0, 400); | |
| 142 | const isHtml = ct.includes('text/html') || body.trimStart().startsWith('<'); | |
| 143 | add('api-json', true, !isHtml, | |
| 144 | isHtml | |
| 145 | ? `unknown /api/* path returned ${r.status} as ${ct || 'HTML'} — API and MCP clients cannot parse this` | |
| 146 | : `unknown /api/* path returned ${r.status} as ${ct}`); | |
| 147 | } catch (e) { | |
| 148 | add('api-json', true, false, `probe failed: ${e.message}`); | |
| 149 | } | |
| 150 | ||
| 151 | // ── HARD: a private repo must not be disclosed anonymously ──────────── | |
| 152 | if (PRIVATE_REPO) { | |
| 153 | const [o, n] = PRIVATE_REPO.split('/'); | |
| 154 | const surfaces = [`/api/repos/${o}/${n}`, `/${o}/${n}`, `/api/v2/repos/${o}/${n}`]; | |
| 155 | const leaks = []; | |
| 156 | for (const path of surfaces) { | |
| 157 | try { | |
| 158 | const r = await fetch(`${BASE}${path}`); | |
| 159 | if (r.status !== 200) continue; | |
| 160 | const body = await r.text(); | |
| 161 | // A 200 alone is not proof; look for fields only the real row carries. | |
| 162 | if (/"isPrivate"|"diskPath"|"ownerId"/.test(body)) { | |
| 163 | leaks.push(`${path} -> 200 disclosing repo metadata`); | |
| 164 | } | |
| 165 | } catch { /* unreachable surface is not a leak */ } | |
| 166 | } | |
| 167 | add('privacy', true, leaks.length === 0, | |
| 168 | leaks.length ? leaks.join('; ') : `no anonymous disclosure across ${surfaces.length} surfaces`); | |
| 169 | } else { | |
| 170 | add('privacy', true, true, 'SKIPPED — pass --private-repo owner/name to enable (strongly recommended)'); | |
| 171 | } | |
| 172 | ||
| 45ac593 | 173 | // ── HARD: authorization matrix over EVERY repo-scoped route ─────────── |
| 174 | // | |
| 175 | // The gate that would have caught most of 2026-07-27/28. On that date the | |
| 176 | // three narrow `privacy` surfaces above all passed while 24 other | |
| 177 | // repo-scoped subpages served a private repo to anonymous visitors, because | |
| 178 | // nothing enumerated the full surface. | |
| 179 | // | |
| 180 | // Checks BOTH directions on purpose. "Private is blocked" alone would pass | |
| 181 | // trivially if a change broke every repo page, so the public repo must keep | |
| 182 | // rendering. A one-directional privacy test is how over-blocking ships. | |
| 183 | if (PRIVATE_REPO && PUBLIC_REPO) { | |
| 184 | const [po, pn] = PRIVATE_REPO.split('/'); | |
| 185 | const [uo, un] = PUBLIC_REPO.split('/'); | |
| 186 | const routes = repoScopedGetRoutes(); | |
| 187 | const leaks = []; | |
| 188 | const overblocked = []; | |
| 189 | const errors = []; | |
| 190 | ||
| 191 | const probe = async (path, token) => { | |
| 192 | const headers = token ? { authorization: `Bearer ${token}` } : {}; | |
| 193 | try { | |
| 194 | const r = await fetch(`${BASE}${path}`, { headers, redirect: 'manual' }); | |
| 195 | return r.status; | |
| 196 | } catch { | |
| 197 | return 0; | |
| 198 | } | |
| 199 | }; | |
| 200 | ||
| 201 | for (const route of routes) { | |
| 202 | // 1. Private repo must not render to an anonymous caller. | |
| 203 | const anon = await probe(materialize(route, po, pn), null); | |
| 204 | if (anon === 200) leaks.push(`anon ${route} -> 200`); | |
| 205 | ||
| 206 | // 2. ...nor to a signed-in account with no access, when one is supplied. | |
| 207 | if (NONMEMBER_PAT) { | |
| 208 | const nm = await probe(materialize(route, po, pn), NONMEMBER_PAT); | |
| 209 | if (nm === 200) leaks.push(`non-member ${route} -> 200`); | |
| 210 | } | |
| 211 | ||
| 212 | // 3. The OWNER must always reach their own repo. Probing this | |
| 213 | // anonymously would be wrong: plenty of repo routes legitimately | |
| 214 | // require a session (the AI surfaces — /ask, /chat, /claude — all | |
| 215 | // 302 to login by design). Over-blocking means the owner is shut | |
| 216 | // out, which is what a too-aggressive privacy gate actually breaks. | |
| 217 | if (OWNER_PAT) { | |
| 218 | const own = await probe(materialize(route, uo, un), OWNER_PAT); | |
| 219 | if (own === 302 || own === 401 || own === 403) { | |
| 220 | overblocked.push(`owner ${route} -> ${own}`); | |
| 221 | } | |
| 222 | // A placeholder id must 404, never crash the handler. | |
| 223 | if (own >= 500) errors.push(`${route} -> ${own}`); | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | const detail = []; | |
| 228 | if (leaks.length) detail.push(`LEAK: ${leaks.slice(0, 8).join(', ')}`); | |
| 229 | if (overblocked.length) detail.push(`OVER-BLOCKED: ${overblocked.slice(0, 8).join(', ')}`); | |
| 230 | if (errors.length) detail.push(`5xx: ${errors.slice(0, 8).join(', ')}`); | |
| 231 | const covered = `${routes.length} repo routes x ${NONMEMBER_PAT ? 3 : 2} identities`; | |
| 232 | add('authz-matrix', true, leaks.length === 0 && overblocked.length === 0 && errors.length === 0, | |
| 233 | detail.length ? detail.join(' | ') : `${covered}, no leaks or over-blocking` | |
| 234 | + (NONMEMBER_PAT ? '' : ' (set NONMEMBER_PAT for the non-member row)')); | |
| 235 | } else { | |
| 236 | add('authz-matrix', true, true, | |
| 237 | 'SKIPPED — needs --private-repo AND --public-repo (this is the gate that catches leaks; enable it)'); | |
| 238 | } | |
| 239 | ||
| 38de014 | 240 | // ── HARD: auth-only pages must not render anonymously ───────────────── |
| 241 | const rendered = []; | |
| 242 | for (const p of AUTH_ONLY) { | |
| 243 | try { | |
| 244 | const r = await fetch(`${BASE}${p}`, { redirect: 'manual' }); | |
| 245 | // 2xx that actually renders the page is the failure. 3xx/401/404 are fine. | |
| 246 | if (r.status >= 200 && r.status < 300) { | |
| 247 | const body = await r.text(); | |
| 248 | if (!/sign in|log ?in|password/i.test(body.slice(0, 4000))) rendered.push(`${p} -> ${r.status}`); | |
| 249 | } | |
| 250 | } catch { /* ignore */ } | |
| 251 | } | |
| 252 | add('auth-gate', true, rendered.length === 0, | |
| 253 | rendered.length ? `rendered to anonymous caller: ${rendered.join(', ')}` : `${AUTH_ONLY.length} paths correctly gated`); | |
| 254 | ||
| 255 | // ── Render + overflow gates (real browser) ──────────────────────────── | |
| 256 | const browser = await chromium.launch(); | |
| 257 | const jsErrors = [], overflows = [], brokenImgs = [], slow = [], noH1 = []; | |
| 258 | ||
| 259 | for (const vp of [{ w: 1440, h: 900 }, { w: 390, h: 844 }]) { | |
| 260 | const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } }); | |
| 261 | for (const path of PAGES) { | |
| 262 | const page = await ctx.newPage(); | |
| 263 | const errs = []; | |
| 264 | page.on('pageerror', (e) => errs.push(String(e).slice(0, 200))); | |
| 265 | const t0 = Date.now(); | |
| 266 | try { | |
| 267 | await page.goto(BASE + path, { waitUntil: 'domcontentloaded', timeout: 45000 }); | |
| 268 | try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch {} | |
| 269 | const ms = Date.now() - t0; | |
| 270 | const probe = await page.evaluate(() => ({ | |
| 271 | scrollW: document.documentElement.scrollWidth, | |
| 272 | innerW: window.innerWidth, | |
| 273 | h1: document.querySelectorAll('h1').length, | |
| 274 | broken: Array.from(document.images) | |
| 275 | .filter((im) => im.complete && im.naturalWidth === 0) | |
| 276 | .map((im) => im.src.slice(0, 120)), | |
| 277 | })); | |
| 278 | if (probe.scrollW > probe.innerW + 2) { | |
| 279 | overflows.push(`${path} @${vp.w}: ${probe.scrollW}px (+${probe.scrollW - probe.innerW})`); | |
| 280 | } | |
| 281 | if (probe.broken.length) brokenImgs.push(`${path}: ${probe.broken.length}`); | |
| 282 | if (vp.w === 1440) { | |
| 283 | if (probe.h1 === 0) noH1.push(path); | |
| 284 | if (ms > PERF_BUDGET_MS) slow.push(`${path} ${ms}ms`); | |
| 285 | } | |
| 286 | } catch (e) { | |
| 287 | errs.push(`navigation failed: ${String(e).slice(0, 160)}`); | |
| 288 | } | |
| 289 | if (errs.length) jsErrors.push(`${path} @${vp.w}: ${errs[0]}`); | |
| 290 | await page.close(); | |
| 291 | } | |
| 292 | await ctx.close(); | |
| 293 | } | |
| 294 | await browser.close(); | |
| 295 | ||
| 296 | add('render', true, jsErrors.length === 0, | |
| 297 | jsErrors.length ? jsErrors.slice(0, 5).join(' | ') : `${PAGES.length} pages x2 viewports, no uncaught JS errors`); | |
| 298 | add('overflow', true, overflows.length === 0, | |
| 299 | overflows.length ? overflows.slice(0, 6).join(' | ') : 'no horizontal overflow at 1440 or 390'); | |
| 300 | add('images', false, brokenImgs.length === 0, | |
| 301 | brokenImgs.length ? brokenImgs.join(' | ') : 'no broken images'); | |
| 302 | add('headings', false, noH1.length === 0, | |
| 303 | noH1.length ? `${noH1.length} page(s) with no <h1>: ${noH1.slice(0, 6).join(', ')}` : 'every sampled page has an h1'); | |
| 304 | add('perf', false, slow.length === 0, | |
| 305 | slow.length ? `over ${PERF_BUDGET_MS}ms: ${slow.join(', ')}` : `all sampled pages under ${PERF_BUDGET_MS}ms`); | |
| 306 | ||
| 307 | // ── Report ──────────────────────────────────────────────────────────── | |
| 308 | const pad = (s, n) => String(s).padEnd(n); | |
| 309 | console.log(pad('GATE', 13) + pad('KIND', 7) + pad('RESULT', 8) + 'DETAIL'); | |
| 310 | console.log('-'.repeat(100)); | |
| 311 | for (const r of results) { | |
| 312 | console.log( | |
| 313 | pad(r.gate, 13) + pad(r.hard ? 'HARD' : 'soft', 7) + | |
| 314 | pad(r.ok ? 'PASS' : 'FAIL', 8) + r.detail | |
| 315 | ); | |
| 316 | } | |
| 317 | ||
| 318 | const hardFails = results.filter((r) => r.hard && !r.ok); | |
| 319 | const softFails = results.filter((r) => !r.hard && !r.ok); | |
| 320 | console.log(''); | |
| 321 | console.log(`[readiness] HARD ${results.filter(r => r.hard && r.ok).length}/${results.filter(r => r.hard).length} passed · soft warnings: ${softFails.length}`); | |
| 322 | ||
| 323 | if (JSON_OUT) { | |
| 324 | writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2)); | |
| 325 | console.log(`[readiness] wrote ${JSON_OUT}`); | |
| 326 | } | |
| 327 | ||
| 328 | if (hardFails.length) { | |
| 329 | console.error(`\n[readiness] NOT READY — ${hardFails.length} hard gate(s) failed:`); | |
| 330 | for (const f of hardFails) console.error(` - ${f.gate}: ${f.detail}`); | |
| 331 | process.exit(1); | |
| 332 | } | |
| 333 | console.log('\n[readiness] all hard gates passed'); | |
| 334 | process.exit(0); | |
| 335 | } | |
| 336 | ||
| 337 | main().catch((e) => { | |
| 338 | console.error('[readiness] crashed:', e); | |
| 339 | process.exit(2); | |
| 340 | }); |