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

nav-audit.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

nav-audit.tsBlame317 lines · 1 contributor
4cd6791ccantynz-alt1// nav-audit — press every tab, on every page, and confirm it actually renders.
2//
3// The gap this closes: prior "audits" checked API endpoints (doctor.ts),
4// the git/PR write flow (agent-journey.ts), or curl'd a few status codes and
5// mis-triaged 404s as "expected". None of them pressed every navigation
6// destination and confirmed the page renders. This does — and it's meant to
7// run before ANY "production ready" claim, and against every repo you import.
8//
9// Two modes:
10// (default) audit the Gluecron self-host repo's every tab
11// --repo owner/name audit one specific repo's tabs
12// --all-repos audit EVERY repo on the instance
13// --crawl <baseUrl> generic same-origin link crawl of ANY site
14// (works on vapron.ai, gluecron.com, any platform)
15//
16// Env:
17// GLUECRON_HOST target instance (default https://gluecron.com)
18// GLUECRON_PAT bearer token — needed to reach private repos / admin
19// NAV_SLOW_MS "slow render" threshold (default 8000)
20//
21// Exit: 0 iff every checked page returns 2xx/3xx and renders without an
22// error body. Non-zero (and a FAIL table) otherwise.
23
24const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
25const PAT = process.env.GLUECRON_PAT?.trim() || "";
26const SLOW_MS = Number(process.env.NAV_SLOW_MS || 8000);
27
28const argv = process.argv.slice(2);
29const flag = (name: string): string | undefined => {
30 const i = argv.indexOf(name);
31 return i >= 0 ? argv[i + 1] : undefined;
32};
33
34type Row = {
35 group: string;
36 label: string;
37 url: string;
38 status: number;
39 ms: number;
40 ok: boolean;
41 note: string;
1d74c37ccantynz-alt42 body?: string; // captured for 2xx so --crawl can extract links without re-fetching
4cd6791ccantynz-alt43};
44
1d74c37ccantynz-alt45// SECURITY: only ever send the Gluecron PAT to the Gluecron origin. In
46// --crawl mode against another platform (vapron.ai, etc.) we must NOT leak
47// the token to a different host.
48let HOST_ORIGIN = "";
49try {
50 HOST_ORIGIN = new URL(HOST).origin;
51} catch {
52 /* HOST malformed — no auth will be attached */
53}
54function authHeaders(url: string): Record<string, string> {
55 if (!PAT) return {};
56 try {
57 if (new URL(url).origin === HOST_ORIGIN) return { authorization: `Bearer ${PAT}` };
58 } catch {
59 /* unparseable URL — send nothing */
60 }
61 return {};
4cd6791ccantynz-alt62}
63
64// A 200 that renders an error page still counts as broken. Sniff the title +
65// obvious error markers.
66function errorBody(html: string): string {
67 const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "").trim();
68 if (/(^|\s)(not found|error|forbidden|something went wrong)(\s|$)/i.test(title)) {
69 return `error title: "${title}"`;
70 }
71 if (/>\s*4\d\d\s*<|Page not found|Internal Server Error|Application error/i.test(html)) {
72 return "error content in body";
73 }
74 return "";
75}
76
77async function hit(group: string, label: string, path: string): Promise<Row> {
78 const url = path.startsWith("http") ? path : HOST + path;
79 const t0 = Date.now();
80 try {
1d74c37ccantynz-alt81 const res = await fetch(url, { headers: authHeaders(url), redirect: "manual" });
4cd6791ccantynz-alt82 const ms = Date.now() - t0;
83 let note = "";
84 let ok = res.status >= 200 && res.status < 400;
1d74c37ccantynz-alt85 let body: string | undefined;
4cd6791ccantynz-alt86 if (res.status >= 200 && res.status < 300) {
1d74c37ccantynz-alt87 body = await res.text();
4cd6791ccantynz-alt88 const eb = errorBody(body);
89 if (eb) {
90 ok = false;
91 note = eb;
92 }
93 } else if (res.status >= 400) {
94 ok = false;
95 note = `HTTP ${res.status}`;
96 }
97 if (ok && ms > SLOW_MS) {
98 ok = false;
99 note = `slow render ${ms}ms (> ${SLOW_MS}ms)`;
100 }
1d74c37ccantynz-alt101 return { group, label, url, status: res.status, ms, ok, note, body };
4cd6791ccantynz-alt102 } catch (err) {
103 return {
104 group,
105 label,
106 url,
107 status: 0,
108 ms: Date.now() - t0,
109 ok: false,
110 note: `fetch failed: ${(err as Error).message}`,
111 };
112 }
113}
114
115// ── Known Gluecron destinations (repo-scoped templates + global) ─────────────
116
117const REPO_TABS: Array<[string, string]> = [
118 ["Code", ""],
119 ["Issues", "/issues"],
120 ["Pull Requests", "/pulls"],
121 ["Actions", "/actions"],
122 ["Security", "/security/vulnerabilities"],
123 ["Insights", "/insights"],
124 ["Settings", "/settings"],
125 ["AI:Explain", "/explain"],
126 ["AI:Ask", "/ask"],
127 ["AI:Workspace", "/workspace"],
128 ["AI:Spec", "/spec"],
129 ["AI:Tests", "/ai/tests"],
130 ["AI:NL Search", "/search/nl"],
131 ["AI:Debt Map", "/debt-map"],
132 ["AI:Archaeology", "/archaeology"],
133 ["More:Commits", "/commits"],
134 ["More:Discussions", "/discussions"],
135 ["More:Wiki", "/wiki"],
136 ["More:Projects", "/projects"],
137 ["More:Releases", "/releases"],
138 ["More:Contributors", "/contributors"],
139 ["More:Pulse", "/pulse"],
140 ["More:Gates", "/gates"],
141 ["More:Deployments", "/cloud-deployments"],
142 ["More:Pipeline", "/pipeline"],
143 ["More:Agents", "/agents"],
144 ["More:Traffic", "/traffic"],
145];
146
147const INSIGHT_REPORTS: Array<[string, string]> = [
148 ["Insights hub", "/insights"],
149 ["DORA", "/insights/dora"],
150 ["Velocity", "/insights/velocity"],
151 ["Health", "/insights/health"],
152 ["Hot files", "/insights/hotfiles"],
153 ["Bus factor", "/insights/bus-factor"],
154 ["Test gaps", "/insights/test-gaps"],
155 ["Engineering", "/insights/engineering"],
156 ["Coupling", "/coupling"],
157 ["Dependencies", "/dependencies"],
158 ["Repo health", "/health"],
159];
160
161const SETTINGS_PAGES = [
162 "/settings", "/settings/keys", "/settings/signing-keys", "/settings/2fa",
163 "/settings/passkeys", "/settings/sessions", "/settings/tokens",
164 "/settings/notifications", "/settings/replies", "/settings/billing",
165 "/settings/sponsors", "/settings/applications", "/settings/apps",
166 "/settings/authorizations", "/settings/agents", "/settings/deploy-targets",
167 "/settings/integrations", "/settings/incident-hooks", "/settings/audit",
168];
169
170const ADMIN_PAGES = [
171 "/admin", "/admin/command", "/admin/ops", "/admin/health", "/admin/env-health",
172 "/admin/status", "/admin/self-host", "/admin/diagnose", "/admin/users",
173 "/admin/repos", "/admin/flags", "/admin/security", "/admin/soc2",
174 "/admin/deletions", "/admin/stripe", "/admin/google-oauth", "/admin/github-oauth",
175 "/admin/sso", "/admin/ai-costs", "/admin/growth", "/admin/autopilot",
176 "/admin/digests", "/admin/integrations", "/admin/advancement", "/admin/deploys",
177 "/admin/autopilot/health",
178];
179
180const GLOBAL_PAGES = [
181 "/", "/explore", "/dashboard", "/notifications", "/activity", "/pricing",
182 "/status", "/help", "/docs", "/changelog",
183];
184
185type Spec = { group: string; label: string; path: string };
186
187function repoSpecs(ownerRepo: string): Spec[] {
188 const base = `/${ownerRepo}`;
189 return [
190 ...REPO_TABS.map(([label, sub]) => ({ group: `repo:${ownerRepo}`, label, path: base + sub })),
191 ...INSIGHT_REPORTS.map(([label, sub]) => ({ group: `insights:${ownerRepo}`, label, path: base + sub })),
192 ];
193}
194
195function globalSpecs(): Spec[] {
196 return [
197 ...GLOBAL_PAGES.map((p) => ({ group: "global", label: p, path: p })),
198 ...SETTINGS_PAGES.map((p) => ({ group: "settings", label: p, path: p })),
199 ...ADMIN_PAGES.map((p) => ({ group: "admin", label: p, path: p })),
200 ];
201}
202
203// Bounded-concurrency runner so ~90+ pages finish in seconds, not minutes.
204async function runConcurrent(specs: Spec[], concurrency = 10): Promise<Row[]> {
205 const rows: Row[] = new Array(specs.length);
206 let next = 0;
207 async function worker() {
208 while (true) {
209 const i = next++;
210 if (i >= specs.length) return;
211 const s = specs[i];
212 rows[i] = await hit(s.group, s.label, s.path);
213 }
214 }
215 await Promise.all(Array.from({ length: Math.min(concurrency, specs.length) }, worker));
216 return rows;
217}
218
219async function listAllRepos(): Promise<string[]> {
220 // Best-effort: enumerate the authed user's repos, then their org repos.
221 const out = new Set<string>();
222 try {
1d74c37ccantynz-alt223 const me = await fetch(HOST + "/api/v2/user", { headers: authHeaders(HOST + "/api/v2/user") }).then((r) => r.json());
4cd6791ccantynz-alt224 if (me?.username) {
1d74c37ccantynz-alt225 const reposUrl = HOST + `/api/v2/users/${encodeURIComponent(me.username)}/repos`;
226 const repos = await fetch(reposUrl, { headers: authHeaders(reposUrl) }).then((r) => r.json());
4cd6791ccantynz-alt227 if (Array.isArray(repos)) for (const r of repos) if (r?.name) out.add(`${me.username}/${r.name}`);
228 }
229 } catch { /* fall through */ }
230 return [...out];
231}
232
233// ── Generic same-origin crawler (works on ANY site) ──────────────────────────
234
235const DANGER = /(logout|sign-?out|delete|destroy|revoke|remove|\/raw\/|\/git-|uninstall|disable|reset|purge|cancel|deactivate)/i;
236
237async function crawlSite(startUrl: string, maxPages = 150): Promise<Row[]> {
238 const origin = new URL(startUrl).origin;
239 const seen = new Set<string>();
240 const queue: string[] = [startUrl];
241 const rows: Row[] = [];
242 while (queue.length && seen.size < maxPages) {
243 const url = queue.shift()!;
244 if (seen.has(url)) continue;
245 seen.add(url);
246 const row = await hit("crawl", new URL(url).pathname || "/", url);
247 rows.push(row);
1d74c37ccantynz-alt248 // Only expand links from pages that rendered. Reuse the body hit()
249 // already captured — no second fetch of a (possibly slow) page.
250 if (row.status >= 200 && row.status < 300 && !row.note && row.body) {
4cd6791ccantynz-alt251 try {
1d74c37ccantynz-alt252 const html = row.body;
4cd6791ccantynz-alt253 const hrefs = [...html.matchAll(/href="([^"#?]+)"/gi)].map((m) => m[1]);
254 for (const href of hrefs) {
255 if (DANGER.test(href)) continue;
256 let abs: string;
257 try {
258 abs = new URL(href, url).toString();
259 } catch {
260 continue;
261 }
262 if (!abs.startsWith(origin)) continue;
263 if (DANGER.test(abs)) continue;
264 if (!seen.has(abs) && !queue.includes(abs)) queue.push(abs);
265 }
266 } catch { /* ignore link-extract errors */ }
267 }
268 }
269 return rows;
270}
271
272// ── Report ───────────────────────────────────────────────────────────────────
273
274function report(rows: Row[]): number {
275 const fails = rows.filter((r) => !r.ok);
276 const slow = rows.filter((r) => r.ok && r.ms > SLOW_MS / 2);
277 console.log(`\n# nav-audit — ${HOST} — ${new Date().toISOString()}`);
278 console.log(`# ${rows.length} pages checked · PAT ${PAT ? "present" : "absent"}\n`);
279 if (fails.length) {
280 console.log(`## FAILURES (${fails.length})\n`);
281 console.log("status | ms | group / label → url | note");
282 console.log("-------+-------+---------------------+-----");
283 for (const r of fails) {
284 console.log(`${String(r.status).padStart(6)} | ${String(r.ms).padStart(5)} | ${r.group} / ${r.label} → ${r.url} | ${r.note}`);
285 }
286 console.log("");
287 }
288 if (slow.length) {
289 console.log(`## SLOW (ok but > ${Math.round(SLOW_MS / 2)}ms) — worth a look\n`);
290 for (const r of slow) console.log(` ${String(r.ms).padStart(6)}ms ${r.group} / ${r.label}`);
291 console.log("");
292 }
293 const passed = rows.length - fails.length;
294 console.log(fails.length === 0 ? `nav-audit: ALL GREEN — ${passed}/${rows.length} pages render` : `nav-audit: ${fails.length} FAILURE(S) — ${passed}/${rows.length} render`);
295 return fails.length === 0 ? 0 : 1;
296}
297
298async function main() {
299 let rows: Row[] = [];
300 const crawlUrl = flag("--crawl");
301 if (crawlUrl) {
302 console.log(`crawling ${crawlUrl} (same-origin, GET-only, safe)…`);
303 rows = await crawlSite(crawlUrl);
304 } else if (argv.includes("--all-repos")) {
305 const repos = await listAllRepos();
306 console.log(`auditing ${repos.length} repos + global/settings/admin…`);
307 const specs = [...globalSpecs(), ...repos.flatMap(repoSpecs)];
308 rows = await runConcurrent(specs);
309 } else {
310 const repo = flag("--repo") || "ccantynz/Gluecron.com";
311 console.log(`auditing repo ${repo} + global/settings/admin…`);
312 rows = await runConcurrent([...globalSpecs(), ...repoSpecs(repo)]);
313 }
314 process.exit(report(rows));
315}
316
317main();