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.mjsBlame355 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
fc623bfccantynz-alt201 // Bounded concurrency. Serially this is 50+ routes x 3 identities of
202 // round-trip latency — over ten minutes, which is long enough that nobody
203 // runs the gate, and a gate nobody runs is worth nothing. Capped low
204 // because hammering the server produces spurious slow-render timeouts in
205 // the perf gate below.
206 const LIMIT = 6;
207 let cursor = 0;
208 const worker = async () => {
209 for (;;) {
210 const i = cursor++;
211 if (i >= routes.length) return;
212 await checkRoute(routes[i]);
213 }
214 };
215
216 async function checkRoute(route) {
45ac593ccantynz-alt217 // 1. Private repo must not render to an anonymous caller.
218 const anon = await probe(materialize(route, po, pn), null);
219 if (anon === 200) leaks.push(`anon ${route} -> 200`);
220
221 // 2. ...nor to a signed-in account with no access, when one is supplied.
222 if (NONMEMBER_PAT) {
223 const nm = await probe(materialize(route, po, pn), NONMEMBER_PAT);
224 if (nm === 200) leaks.push(`non-member ${route} -> 200`);
225 }
226
227 // 3. The OWNER must always reach their own repo. Probing this
228 // anonymously would be wrong: plenty of repo routes legitimately
229 // require a session (the AI surfaces — /ask, /chat, /claude — all
230 // 302 to login by design). Over-blocking means the owner is shut
231 // out, which is what a too-aggressive privacy gate actually breaks.
232 if (OWNER_PAT) {
233 const own = await probe(materialize(route, uo, un), OWNER_PAT);
234 if (own === 302 || own === 401 || own === 403) {
235 overblocked.push(`owner ${route} -> ${own}`);
236 }
237 // A placeholder id must 404, never crash the handler.
238 if (own >= 500) errors.push(`${route} -> ${own}`);
239 }
240 }
241
242 const detail = [];
243 if (leaks.length) detail.push(`LEAK: ${leaks.slice(0, 8).join(', ')}`);
244 if (overblocked.length) detail.push(`OVER-BLOCKED: ${overblocked.slice(0, 8).join(', ')}`);
245 if (errors.length) detail.push(`5xx: ${errors.slice(0, 8).join(', ')}`);
246 const covered = `${routes.length} repo routes x ${NONMEMBER_PAT ? 3 : 2} identities`;
247 add('authz-matrix', true, leaks.length === 0 && overblocked.length === 0 && errors.length === 0,
248 detail.length ? detail.join(' | ') : `${covered}, no leaks or over-blocking`
249 + (NONMEMBER_PAT ? '' : ' (set NONMEMBER_PAT for the non-member row)'));
250 } else {
251 add('authz-matrix', true, true,
252 'SKIPPED — needs --private-repo AND --public-repo (this is the gate that catches leaks; enable it)');
253 }
254
38de014ccantynz-alt255 // ── HARD: auth-only pages must not render anonymously ─────────────────
256 const rendered = [];
257 for (const p of AUTH_ONLY) {
258 try {
259 const r = await fetch(`${BASE}${p}`, { redirect: 'manual' });
260 // 2xx that actually renders the page is the failure. 3xx/401/404 are fine.
261 if (r.status >= 200 && r.status < 300) {
262 const body = await r.text();
263 if (!/sign in|log ?in|password/i.test(body.slice(0, 4000))) rendered.push(`${p} -> ${r.status}`);
264 }
265 } catch { /* ignore */ }
266 }
267 add('auth-gate', true, rendered.length === 0,
268 rendered.length ? `rendered to anonymous caller: ${rendered.join(', ')}` : `${AUTH_ONLY.length} paths correctly gated`);
269
270 // ── Render + overflow gates (real browser) ────────────────────────────
271 const browser = await chromium.launch();
272 const jsErrors = [], overflows = [], brokenImgs = [], slow = [], noH1 = [];
273
274 for (const vp of [{ w: 1440, h: 900 }, { w: 390, h: 844 }]) {
275 const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
276 for (const path of PAGES) {
277 const page = await ctx.newPage();
278 const errs = [];
279 page.on('pageerror', (e) => errs.push(String(e).slice(0, 200)));
280 const t0 = Date.now();
281 try {
282 await page.goto(BASE + path, { waitUntil: 'domcontentloaded', timeout: 45000 });
283 try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch {}
284 const ms = Date.now() - t0;
285 const probe = await page.evaluate(() => ({
286 scrollW: document.documentElement.scrollWidth,
287 innerW: window.innerWidth,
288 h1: document.querySelectorAll('h1').length,
289 broken: Array.from(document.images)
290 .filter((im) => im.complete && im.naturalWidth === 0)
291 .map((im) => im.src.slice(0, 120)),
292 }));
293 if (probe.scrollW > probe.innerW + 2) {
294 overflows.push(`${path} @${vp.w}: ${probe.scrollW}px (+${probe.scrollW - probe.innerW})`);
295 }
296 if (probe.broken.length) brokenImgs.push(`${path}: ${probe.broken.length}`);
297 if (vp.w === 1440) {
298 if (probe.h1 === 0) noH1.push(path);
299 if (ms > PERF_BUDGET_MS) slow.push(`${path} ${ms}ms`);
300 }
301 } catch (e) {
302 errs.push(`navigation failed: ${String(e).slice(0, 160)}`);
303 }
304 if (errs.length) jsErrors.push(`${path} @${vp.w}: ${errs[0]}`);
305 await page.close();
306 }
307 await ctx.close();
308 }
309 await browser.close();
310
311 add('render', true, jsErrors.length === 0,
312 jsErrors.length ? jsErrors.slice(0, 5).join(' | ') : `${PAGES.length} pages x2 viewports, no uncaught JS errors`);
313 add('overflow', true, overflows.length === 0,
314 overflows.length ? overflows.slice(0, 6).join(' | ') : 'no horizontal overflow at 1440 or 390');
315 add('images', false, brokenImgs.length === 0,
316 brokenImgs.length ? brokenImgs.join(' | ') : 'no broken images');
317 add('headings', false, noH1.length === 0,
318 noH1.length ? `${noH1.length} page(s) with no <h1>: ${noH1.slice(0, 6).join(', ')}` : 'every sampled page has an h1');
319 add('perf', false, slow.length === 0,
320 slow.length ? `over ${PERF_BUDGET_MS}ms: ${slow.join(', ')}` : `all sampled pages under ${PERF_BUDGET_MS}ms`);
321
322 // ── Report ────────────────────────────────────────────────────────────
323 const pad = (s, n) => String(s).padEnd(n);
324 console.log(pad('GATE', 13) + pad('KIND', 7) + pad('RESULT', 8) + 'DETAIL');
325 console.log('-'.repeat(100));
326 for (const r of results) {
327 console.log(
328 pad(r.gate, 13) + pad(r.hard ? 'HARD' : 'soft', 7) +
329 pad(r.ok ? 'PASS' : 'FAIL', 8) + r.detail
330 );
331 }
332
333 const hardFails = results.filter((r) => r.hard && !r.ok);
334 const softFails = results.filter((r) => !r.hard && !r.ok);
335 console.log('');
336 console.log(`[readiness] HARD ${results.filter(r => r.hard && r.ok).length}/${results.filter(r => r.hard).length} passed · soft warnings: ${softFails.length}`);
337
338 if (JSON_OUT) {
339 writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2));
340 console.log(`[readiness] wrote ${JSON_OUT}`);
341 }
342
343 if (hardFails.length) {
344 console.error(`\n[readiness] NOT READY — ${hardFails.length} hard gate(s) failed:`);
345 for (const f of hardFails) console.error(` - ${f.gate}: ${f.detail}`);
346 process.exit(1);
347 }
348 console.log('\n[readiness] all hard gates passed');
349 process.exit(0);
350}
351
352main().catch((e) => {
353 console.error('[readiness] crashed:', e);
354 process.exit(2);
355});