Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

production-readiness.mjsBlame340 lines · 1 contributor
38de014ccantynz-alt1#!/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
35import { chromium } from '@playwright/test';
45ac593ccantynz-alt36import { writeFileSync, readdirSync, readFileSync, statSync } from 'fs';
37import { join } from 'path';
38de014ccantynz-alt38
39const argv = process.argv.slice(2);
40const arg = (name, fallback = null) => {
41 const i = argv.indexOf(`--${name}`);
42 return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
43};
44
45const BASE = (arg('base', process.env.SMOKE_HOST || 'https://gluecron.com')).replace(/\/$/, '');
46const EXPECT_SHA = arg('expect-sha', process.env.EXPECT_SHA);
47const PRIVATE_REPO = arg('private-repo', process.env.PRIVATE_REPO); // "owner/name"
45ac593ccantynz-alt48const PUBLIC_REPO = arg('public-repo', process.env.PUBLIC_REPO); // "owner/name"
49const OWNER_PAT = process.env.GLUECRON_PAT; // owner of PRIVATE_REPO
50const NONMEMBER_PAT = process.env.NONMEMBER_PAT; // any account with no access
38de014ccantynz-alt51const JSON_OUT = arg('json');
52
45ac593ccantynz-alt53/**
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 */
63function 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. */
96function 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
38de014ccantynz-alt103// Pages sampled for render/overflow gates. Keep this list small and
104// representative — it runs on every deploy.
105const PAGES = [
106 '/', '/explore', '/pricing', '/docs', '/docs/agents', '/docs/api',
107 '/login', '/register', '/marketplace', '/enterprise',
108];
109// Must never render for an anonymous caller.
110const AUTH_ONLY = ['/dashboard', '/settings', '/admin', '/settings/tokens'];
111const PERF_BUDGET_MS = 3000; // soft
112
113const results = [];
114const add = (gate, hard, ok, detail) => results.push({ gate, hard, ok, detail });
115
116async 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
45ac593ccantynz-alt173 // ── 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
38de014ccantynz-alt240 // ── 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
337main().catch((e) => {
338 console.error('[readiness] crashed:', e);
339 process.exit(2);
340});