Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitc74cc92unknown_key

Merge pull request #80 from ccantynz-alt/claude/review-readme-docs-ulqPK

Merge pull request #80 from ccantynz-alt/claude/review-readme-docs-ulqPK

Claude/review readme docs ulq pk
CC LABS App committed on May 15, 2026Parents: 9e6fd14 826eccf
9 files changed+72414c74cc92b3317bea2ff5437c6e1c263a5540fbeef
9 changed files+724−14
Modified.github/workflows/hetzner-deploy.yml+14−0View fileUnifiedSplit
343343
344344 notify_step "db-migrate" "succeeded" "$(( ( $(date +%s) - DM_START ) * 1000 ))"
345345
346 # ─── (c.5) Pin BUILD_SHA into systemd before restart.
347 # Without this, src/routes/pwa.ts falls back to a stable
348 # "dev-stable" version string and the browser service worker
349 # never invalidates between real deploys. The drop-in survives
350 # daemon-reload; old contents are overwritten on every deploy.
351 mkdir -p /etc/systemd/system/gluecron.service.d
352 cat > /etc/systemd/system/gluecron.service.d/build-sha.conf <<EOF
353 [Service]
354 Environment="BUILD_SHA=$new_sha"
355 Environment="BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
356 EOF
357 systemctl daemon-reload
358 echo "==> pinned BUILD_SHA=${new_sha:0:12} in systemd drop-in"
359
346360 # ─── (d) Zero-downtime restart. Blocks until sd_notify(READY=1).
347361 notify_step "restart-service" "in_progress"
348362 RS_START=$(date +%s)
Modifiedscripts/self-deploy.sh+17−0View fileUnifiedSplit
167167 fi
168168fi
169169
170# ── 6.5 Pin BUILD_SHA into the systemd unit so the running process can
171# report it and the SW versioning rotates exactly per-deploy. Drop-in
172# survives daemon-reload; the OLD file is overwritten on every deploy.
173# Without this, src/routes/pwa.ts falls back to a stable "dev-stable"
174# string and the browser SW never invalidates between real deploys.
175if [ "$DEPLOY_FAILED" = "0" ]; then
176 DROPIN_DIR=/etc/systemd/system/gluecron.service.d
177 mkdir -p "$DROPIN_DIR"
178 cat > "$DROPIN_DIR/build-sha.conf" <<EOF
179[Service]
180Environment="BUILD_SHA=$NEW_SHA"
181Environment="BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
182EOF
183 systemctl daemon-reload >>"$LOG" 2>&1 || log " ! daemon-reload non-fatal warning"
184 log " v pinned BUILD_SHA=${NEW_SHA:0:12} in systemd drop-in"
185fi
186
170187# ── 7. systemctl restart (blocks on sd_notify READY=1) ────────────────────
171188if [ "$DEPLOY_FAILED" = "0" ]; then
172189 RS_START=$(date +%s)
Modifiedsrc/app.tsx+19−2View fileUnifiedSplit
7070import adminDeploysPageRoutes from "./routes/admin-deploys-page";
7171import adminOpsRoutes from "./routes/admin-ops";
7272import adminSelfHostRoutes from "./routes/admin-self-host";
73import adminDiagnoseRoutes from "./routes/admin-diagnose";
7374import advisoriesRoutes from "./routes/advisories";
7475import aiChangelogRoutes from "./routes/ai-changelog";
7576import aiExplainRoutes from "./routes/ai-explain";
157158 c.header("cache-control", "private, no-cache, must-revalidate");
158159 }
159160});
160// Rate-limit API + auth endpoints (generous default)
161app.use("/api/*", rateLimit(120, 60_000, "api"));
161// Rate-limit API + auth endpoints.
162//
163// `/api/*`: anonymous IPs are capped at 200/min; authenticated users get 4×
164// (800/min) so an admin clicking around the operator console doesn't
165// exhaust the anonymous budget. Three dashboard-plumbing endpoints are
166// exempt entirely so they never count against the bucket:
167// /api/version — layout polls every 15s to detect new deploys
168// /api/notifications/count — nav bell unread-count fetcher
169// /pwa/vapid-public-key — fetched once per push-notification opt-in
170app.use(
171 "/api/*",
172 rateLimit(200, 60_000, "api", {
173 authedMultiplier: 4,
174 skipPaths: ["/api/version", "/api/notifications/count", "/pwa/vapid-public-key"],
175 })
176);
162177app.use("/login", rateLimit(20, 60_000, "login"));
163178app.use("/register", rateLimit(10, 60_000, "register"));
164179// BLOCK P1 — throttle forgot-password to deter enumeration + mail spam.
335350app.route("/", adminOpsRoutes);
336351// BLOCK W — Self-host status + bootstrap dashboard.
337352app.route("/", adminSelfHostRoutes);
353// BLOCK X — AI health-scan diagnose page (/admin/diagnose).
354app.route("/", adminDiagnoseRoutes);
338355
339356// Insights (time-travel, dependencies, rollback)
340357app.route("/", insightRoutes);
Modifiedsrc/middleware/rate-limit.ts+46−6View fileUnifiedSplit
2424 }
2525}, 60_000);
2626
27interface RateLimitOptions {
28 /**
29 * Endpoints that should never count against the bucket. Compared against
30 * c.req.path with .startsWith(). Used for high-frequency dashboard
31 * plumbing (e.g. `/api/version` is polled every 15s by the layout) so
32 * normal navigation doesn't exhaust an admin's `/api/*` budget.
33 */
34 skipPaths?: string[];
35 /**
36 * Multiplier applied when c.get("user") is set by an upstream softAuth.
37 * 1 = anonymous limit. Default 4 so an authed user gets 4x the bucket
38 * (admins doing admin work shouldn't hit /api/* limits during normal
39 * navigation; anonymous traffic still gets the strict cap).
40 */
41 authedMultiplier?: number;
42}
43
2744/**
2845 * Create a rate limiter middleware.
29 * @param maxRequests Maximum requests per window
46 * @param maxRequests Maximum requests per window for ANONYMOUS callers
3047 * @param windowMs Window duration in milliseconds
3148 * @param keyPrefix Prefix for the rate limit key (allows different limits per route group)
49 * @param opts Optional skip-paths and authed multiplier.
3250 */
3351export function rateLimit(
3452 maxRequests: number,
3553 windowMs: number,
36 keyPrefix = "global"
54 keyPrefix = "global",
55 opts: RateLimitOptions = {}
3756) {
57 const skipPaths = opts.skipPaths || [];
58 const authedMultiplier = opts.authedMultiplier ?? 4;
3859 return createMiddleware(async (c, next) => {
3960 // In test env, expose informational rate-limit headers but do not actually
4061 // enforce limits — the shared in-memory store leaks across test files and
5475 return next();
5576 }
5677
78 // Skip-path exemption — applied to dashboard plumbing endpoints that the
79 // layout polls on a fixed cadence and that we don't want consuming an
80 // authenticated user's per-IP budget.
81 const path = c.req.path;
82 for (const prefix of skipPaths) {
83 if (path === prefix || path.startsWith(prefix + "/") || path.startsWith(prefix + "?")) {
84 return next();
85 }
86 }
87
5788 const ip =
5889 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
5990 c.req.header("x-real-ip") ||
6091 "unknown";
6192
93 // Effective cap: authed users get a multiplier so a logged-in admin
94 // clicking around doesn't share the anonymous limit. We DON'T key the
95 // bucket per-user — keeping it per-IP is the strongest defence against
96 // a stolen-session bot — but a logged-in session signals "human at the
97 // keyboard", so we lift the ceiling.
98 const user = c.get("user" as never) as { id?: string } | null | undefined;
99 const effectiveMax = user ? maxRequests * authedMultiplier : maxRequests;
100
62101 const key = `${keyPrefix}:${ip}`;
63102 const now = Date.now();
64103 let entry = store.get(key);
70109
71110 entry.count++;
72111
73 // Set rate limit headers
74 c.header("X-RateLimit-Limit", String(maxRequests));
75 c.header("X-RateLimit-Remaining", String(Math.max(0, maxRequests - entry.count)));
112 // Set rate limit headers (report the effective cap so clients see the
113 // bucket they're actually subject to).
114 c.header("X-RateLimit-Limit", String(effectiveMax));
115 c.header("X-RateLimit-Remaining", String(Math.max(0, effectiveMax - entry.count)));
76116 c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000)));
77117
78 if (entry.count > maxRequests) {
118 if (entry.count > effectiveMax) {
79119 c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000)));
80120 return c.json(
81121 {
Addedsrc/routes/admin-diagnose.tsx+596−0View fileUnifiedSplit
1/**
2 * /admin/diagnose — comprehensive AI health scan.
3 *
4 * Single page the site admin opens to see, at a glance, every config knob
5 * the platform depends on and whether it is wired up. Each row is one
6 * check; status is green / yellow / red with a one-line "what to do".
7 *
8 * Categories covered:
9 * - Email delivery (EMAIL_PROVIDER, RESEND_API_KEY)
10 * - AI (ANTHROPIC_API_KEY presence)
11 * - GateTest integration (URL + API key)
12 * - Service worker SHA (BUILD_SHA pinned vs dev-stable fallback)
13 * - Database (DATABASE_URL well-formed, latest migration applied)
14 * - Canonical URL (APP_BASE_URL matches request host)
15 * - Self-host (SELF_HOST_REPO declared + post-receive hook present)
16 * - Auto-merge (branch_protection.enable_auto_merge for main)
17 * - Synthetic monitor (any RED checks in last hour)
18 * - Email smoke (POST /admin/diagnose/test-email fires a test)
19 *
20 * Gating: requireAuth + isSiteAdmin via the same gate() pattern as
21 * /admin/ops + /admin/status. No data leaks to non-admins.
22 */
23
24import { Hono } from "hono";
25import { eq, and, desc, gt } from "drizzle-orm";
26import { readdir } from "fs/promises";
27import { join } from "path";
28import {
29 branchProtection,
30 repositories,
31 syntheticChecks,
32 users,
33} from "../db/schema";
34import { db } from "../db";
35import { Layout } from "../views/layout";
36import { softAuth } from "../middleware/auth";
37import type { AuthEnv } from "../middleware/auth";
38import { isSiteAdmin } from "../lib/admin";
39import { config } from "../lib/config";
40import { sendEmail } from "../lib/email";
41import { latestMigration } from "../lib/post-deploy-smoke";
42
43type CheckStatus = "green" | "yellow" | "red";
44
45interface CheckResult {
46 category: string;
47 name: string;
48 status: CheckStatus;
49 detail: string;
50 fix?: string;
51}
52
53const diagnose = new Hono<AuthEnv>();
54diagnose.use("*", softAuth);
55
56async function gate(c: any): Promise<{ user: any } | Response> {
57 const user = c.get("user");
58 if (!user) return c.redirect("/login?next=/admin/diagnose");
59 if (!(await isSiteAdmin(user.id))) {
60 return c.html(
61 <Layout title="Forbidden" user={user}>
62 <div class="empty-state">
63 <h2>403 — Not a site admin</h2>
64 <p>You don't have permission to view this page.</p>
65 </div>
66 </Layout>,
67 403
68 );
69 }
70 return { user };
71}
72
73// ─── Individual checks ───────────────────────────────────────────────────
74
75function checkEmail(): CheckResult {
76 if (config.emailProvider !== "resend") {
77 return {
78 category: "Email",
79 name: "Provider",
80 status: "red",
81 detail: `EMAIL_PROVIDER=${config.emailProvider} — verification + magic-link emails go to stderr, not inboxes.`,
82 fix: "Set EMAIL_PROVIDER=resend in /etc/gluecron.env, then `systemctl restart gluecron`.",
83 };
84 }
85 if (!config.resendApiKey) {
86 return {
87 category: "Email",
88 name: "Provider",
89 status: "red",
90 detail: "EMAIL_PROVIDER=resend but RESEND_API_KEY is empty — every send will fail.",
91 fix: "Add RESEND_API_KEY=re_xxx to /etc/gluecron.env, then restart.",
92 };
93 }
94 return {
95 category: "Email",
96 name: "Provider",
97 status: "green",
98 detail: `Resend wired (from: ${config.emailFrom}). Use the test button below to confirm.`,
99 };
100}
101
102function checkAnthropic(): CheckResult {
103 if (!config.anthropicApiKey) {
104 return {
105 category: "AI",
106 name: "Anthropic API key",
107 status: "yellow",
108 detail: "ANTHROPIC_API_KEY unset — AI PR review + AI deploy-failure analysis disabled.",
109 fix: "Add ANTHROPIC_API_KEY=sk-ant-xxx to /etc/gluecron.env.",
110 };
111 }
112 return {
113 category: "AI",
114 name: "Anthropic API key",
115 status: "green",
116 detail: `Key present (length ${config.anthropicApiKey.length}).`,
117 };
118}
119
120function checkGateTest(): CheckResult {
121 const hasUrl = !!process.env.GATETEST_URL;
122 const hasKey = !!process.env.GATETEST_API_KEY;
123 if (!hasUrl && !hasKey) {
124 return {
125 category: "GateTest",
126 name: "Scanner integration",
127 status: "yellow",
128 detail: "Unconfigured — push-time GateTest scans skip silently.",
129 fix: "Set GATETEST_URL + GATETEST_API_KEY in /etc/gluecron.env to enable per-push scans.",
130 };
131 }
132 if (hasUrl && !hasKey) {
133 return {
134 category: "GateTest",
135 name: "Scanner integration",
136 status: "red",
137 detail: "GATETEST_URL set but GATETEST_API_KEY empty — calls will 401.",
138 fix: "Add GATETEST_API_KEY to /etc/gluecron.env.",
139 };
140 }
141 return {
142 category: "GateTest",
143 name: "Scanner integration",
144 status: "green",
145 detail: `Configured — pushes POST to ${process.env.GATETEST_URL}.`,
146 };
147}
148
149function checkBuildSha(): CheckResult {
150 const sha = process.env.BUILD_SHA?.trim();
151 if (sha) {
152 return {
153 category: "Deploy",
154 name: "BUILD_SHA pinned",
155 status: "green",
156 detail: `${sha.slice(0, 12)} — service worker rotates per deploy.`,
157 };
158 }
159 return {
160 category: "Deploy",
161 name: "BUILD_SHA pinned",
162 status: "yellow",
163 detail: "BUILD_SHA unset — falling back to dev-stable. Browsers won't see new deploys reflected in the SW cache.",
164 fix: "Latest scripts/self-deploy.sh + hetzner-deploy.yml pin this automatically; trigger a deploy.",
165 };
166}
167
168function checkAppBaseUrl(c: any): CheckResult {
169 const expected = config.appBaseUrl;
170 const host = c.req.header("host") || "";
171 const proto =
172 c.req.header("x-forwarded-proto") ||
173 (c.req.url.startsWith("https://") ? "https" : "http");
174 const actual = `${proto}://${host}`;
175 if (!expected || expected === "http://localhost:3000") {
176 return {
177 category: "Config",
178 name: "APP_BASE_URL canonical",
179 status: "yellow",
180 detail: `APP_BASE_URL is "${expected}" — outbound email links + WebAuthn origin will be wrong.`,
181 fix: "Set APP_BASE_URL=https://gluecron.com in /etc/gluecron.env.",
182 };
183 }
184 if (host && !expected.endsWith(host)) {
185 return {
186 category: "Config",
187 name: "APP_BASE_URL canonical",
188 status: "yellow",
189 detail: `APP_BASE_URL=${expected} but request arrived at ${actual}. WebAuthn passkeys issued for one host can't be used at the other.`,
190 fix: "Align APP_BASE_URL with the host you actually serve from.",
191 };
192 }
193 return {
194 category: "Config",
195 name: "APP_BASE_URL canonical",
196 status: "green",
197 detail: expected,
198 };
199}
200
201function checkDatabase(): CheckResult {
202 const url = config.databaseUrl;
203 if (!url) {
204 return {
205 category: "Database",
206 name: "Connection string",
207 status: "red",
208 detail: "DATABASE_URL unset — every page that queries the DB will 500.",
209 fix: "Set DATABASE_URL in /etc/gluecron.env.",
210 };
211 }
212 let masked = url;
213 try {
214 const u = new URL(url);
215 masked = `${u.protocol}//${u.username ? "***" : ""}@${u.host}${u.pathname}`;
216 } catch {
217 // unparseable
218 return {
219 category: "Database",
220 name: "Connection string",
221 status: "red",
222 detail: "DATABASE_URL is not a valid URL.",
223 fix: "Fix the URL format: postgres://user:pass@host:port/dbname",
224 };
225 }
226 return {
227 category: "Database",
228 name: "Connection string",
229 status: "green",
230 detail: masked,
231 };
232}
233
234async function checkMigrations(): Promise<CheckResult> {
235 try {
236 const drizzleDir = join(process.cwd(), "drizzle");
237 const files = (await readdir(drizzleDir)).filter((f) => f.endsWith(".sql"));
238 const latest = latestMigration(files);
239 if (!latest) {
240 return {
241 category: "Database",
242 name: "Migrations applied",
243 status: "yellow",
244 detail: "No migration files found in drizzle/.",
245 };
246 }
247 const rows = (await db.execute(
248 `SELECT name FROM _migrations ORDER BY name DESC LIMIT 1` as never
249 )) as any;
250 const list = rows?.rows ?? (Array.isArray(rows) ? rows : []);
251 const applied: string | undefined = list[0]?.name;
252 if (!applied) {
253 return {
254 category: "Database",
255 name: "Migrations applied",
256 status: "red",
257 detail: "_migrations table is empty.",
258 fix: "Run `bun run db:migrate` on the box.",
259 };
260 }
261 if (applied !== latest) {
262 return {
263 category: "Database",
264 name: "Migrations applied",
265 status: "red",
266 detail: `DB at ${applied}, drizzle/ has ${latest}.`,
267 fix: "Run `bun run db:migrate` on the box, or redeploy (the workflow runs it).",
268 };
269 }
270 return {
271 category: "Database",
272 name: "Migrations applied",
273 status: "green",
274 detail: `Latest: ${applied}`,
275 };
276 } catch (err) {
277 return {
278 category: "Database",
279 name: "Migrations applied",
280 status: "yellow",
281 detail: `Couldn't read migration state: ${(err as Error).message.slice(0, 100)}`,
282 };
283 }
284}
285
286async function checkAutoMerge(): Promise<CheckResult> {
287 try {
288 const [owner] = await db
289 .select({ id: users.id })
290 .from(users)
291 .where(eq(users.username, "ccantynz"))
292 .limit(1);
293 if (!owner) {
294 return {
295 category: "Auto-merge",
296 name: "main protection",
297 status: "yellow",
298 detail: "Owner user 'ccantynz' not found.",
299 };
300 }
301 const [repo] = await db
302 .select({ id: repositories.id })
303 .from(repositories)
304 .where(
305 and(
306 eq(repositories.ownerId, owner.id),
307 eq(repositories.name, "Gluecron.com")
308 )
309 )
310 .limit(1);
311 if (!repo) {
312 return {
313 category: "Auto-merge",
314 name: "main protection",
315 status: "yellow",
316 detail: "Repository row for ccantynz/Gluecron.com not found.",
317 };
318 }
319 const [bp] = await db
320 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
321 .from(branchProtection)
322 .where(
323 and(
324 eq(branchProtection.repositoryId, repo.id),
325 eq(branchProtection.pattern, "main")
326 )
327 )
328 .limit(1);
329 if (!bp) {
330 return {
331 category: "Auto-merge",
332 name: "main protection",
333 status: "yellow",
334 detail: "No branch_protection row for main yet.",
335 fix: "Visit /ccantynz/Gluecron.com/gates/protection to configure.",
336 };
337 }
338 return {
339 category: "Auto-merge",
340 name: "main protection",
341 status: bp.enableAutoMerge ? "green" : "yellow",
342 detail: bp.enableAutoMerge
343 ? "Auto-merge ENABLED on main."
344 : "Auto-merge DISABLED on main.",
345 fix: bp.enableAutoMerge
346 ? undefined
347 : "Visit /admin/ops to enable.",
348 };
349 } catch (err) {
350 return {
351 category: "Auto-merge",
352 name: "main protection",
353 status: "yellow",
354 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
355 };
356 }
357}
358
359async function checkSyntheticMonitor(): Promise<CheckResult> {
360 try {
361 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
362 const reds = await db
363 .select({ name: syntheticChecks.checkName })
364 .from(syntheticChecks)
365 .where(
366 and(
367 eq(syntheticChecks.status, "red"),
368 gt(syntheticChecks.checkedAt, oneHourAgo)
369 )
370 )
371 .orderBy(desc(syntheticChecks.checkedAt))
372 .limit(10);
373 if (reds.length === 0) {
374 return {
375 category: "Monitor",
376 name: "Synthetic checks (1h)",
377 status: "green",
378 detail: "All synthetic checks green in the last hour.",
379 };
380 }
381 const names = Array.from(new Set(reds.map((r) => r.name))).slice(0, 5);
382 return {
383 category: "Monitor",
384 name: "Synthetic checks (1h)",
385 status: "red",
386 detail: `Red in last hour: ${names.join(", ")}.`,
387 fix: "Open /admin/status for the full row table.",
388 };
389 } catch (err) {
390 return {
391 category: "Monitor",
392 name: "Synthetic checks (1h)",
393 status: "yellow",
394 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
395 };
396 }
397}
398
399function checkSelfHost(): CheckResult {
400 const repo = process.env.SELF_HOST_REPO;
401 if (!repo) {
402 return {
403 category: "Self-host",
404 name: "Bootstrap",
405 status: "yellow",
406 detail: "SELF_HOST_REPO unset — pushes to this repo don't trigger self-deploy.",
407 fix: "Run scripts/self-host-bootstrap.ts on the box and add SELF_HOST_REPO=ccantynz/Gluecron.com to /etc/gluecron.env.",
408 };
409 }
410 return {
411 category: "Self-host",
412 name: "Bootstrap",
413 status: "green",
414 detail: `Self-hosting ${repo} — push to main fires self-deploy.sh.`,
415 };
416}
417
418// ─── Page handler ────────────────────────────────────────────────────────
419
420function pill(status: CheckStatus): any {
421 const map: Record<CheckStatus, { bg: string; fg: string; label: string }> = {
422 green: { bg: "rgba(52,211,153,0.16)", fg: "#34d399", label: "✓ OK" },
423 yellow: { bg: "rgba(245,158,11,0.16)", fg: "#f59e0b", label: "! WARN" },
424 red: { bg: "rgba(248,113,113,0.16)", fg: "#f87171", label: "× FAIL" },
425 };
426 const s = map[status];
427 return (
428 <span
429 style={`display:inline-flex;align-items:center;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${s.bg};color:${s.fg};white-space:nowrap`}
430 >
431 {s.label}
432 </span>
433 );
434}
435
436diagnose.get("/admin/diagnose", async (c) => {
437 const g = await gate(c);
438 if (g instanceof Response) return g;
439 const { user } = g;
440
441 const results: CheckResult[] = [
442 checkEmail(),
443 checkAnthropic(),
444 checkGateTest(),
445 checkBuildSha(),
446 checkAppBaseUrl(c),
447 checkDatabase(),
448 await checkMigrations(),
449 await checkAutoMerge(),
450 await checkSyntheticMonitor(),
451 checkSelfHost(),
452 ];
453
454 const counts = {
455 green: results.filter((r) => r.status === "green").length,
456 yellow: results.filter((r) => r.status === "yellow").length,
457 red: results.filter((r) => r.status === "red").length,
458 };
459 const headline =
460 counts.red > 0
461 ? `${counts.red} failing`
462 : counts.yellow > 0
463 ? `${counts.yellow} needs attention`
464 : "All green";
465 const headlineColor =
466 counts.red > 0
467 ? "var(--red, #cf222e)"
468 : counts.yellow > 0
469 ? "#d97706"
470 : "var(--green, #2da44e)";
471
472 const flash = c.req.query("test_email");
473
474 return c.html(
475 <Layout title="Diagnose — admin" user={user}>
476 <div style="max-width:920px;margin:0 auto;padding:var(--space-5) var(--space-3)">
477 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
478 <h1 style="margin:0">Diagnose</h1>
479 <a href="/admin" class="btn btn-sm">
480 Back to admin
481 </a>
482 </div>
483 <p style="color:var(--text-muted);margin-bottom:var(--space-4)">
484 Read-only health scan of every config knob the platform depends on.
485 One row per check. Green = wired, yellow = degraded, red = broken.
486 </p>
487
488 <div
489 style={`background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-3) var(--space-4);margin-bottom:var(--space-4);display:flex;align-items:center;gap:var(--space-3)`}
490 >
491 <span
492 style={`display:inline-block;width:14px;height:14px;border-radius:50%;background:${headlineColor}`}
493 />
494 <strong style="font-size:18px">{headline}</strong>
495 <span style="color:var(--text-muted);font-size:13px">
496 {counts.green} green · {counts.yellow} warn · {counts.red} fail
497 </span>
498 </div>
499
500 {flash && (
501 <div
502 class={flash === "ok" ? "banner" : "auth-error"}
503 style="margin-bottom:var(--space-4)"
504 >
505 {flash === "ok"
506 ? "Test email dispatched. If the provider is 'log' you'll see it in journalctl, not your inbox."
507 : `Test email failed: ${decodeURIComponent(flash)}`}
508 </div>
509 )}
510
511 <div
512 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:var(--space-4)"
513 >
514 {results.map((r, i) => (
515 <div
516 style={`padding:var(--space-3) var(--space-4);${i < results.length - 1 ? "border-bottom:1px solid var(--border);" : ""}display:grid;grid-template-columns:120px 80px 1fr;gap:var(--space-3);align-items:start`}
517 >
518 <div>
519 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em">
520 {r.category}
521 </div>
522 <div style="font-weight:600;font-size:14px;margin-top:2px">
523 {r.name}
524 </div>
525 </div>
526 <div>{pill(r.status)}</div>
527 <div>
528 <div style="font-size:13px;line-height:1.45">{r.detail}</div>
529 {r.fix && (
530 <div
531 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
532 >
533 → {r.fix}
534 </div>
535 )}
536 </div>
537 </div>
538 ))}
539 </div>
540
541 <div
542 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-3) var(--space-4)"
543 >
544 <h3
545 style="margin:0 0 var(--space-2) 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
546 >
547 Test email delivery
548 </h3>
549 <p style="font-size:13px;color:var(--text-muted);margin:0 0 var(--space-3) 0">
550 Fires a one-line test email to <strong>{user.email}</strong> using
551 the configured provider. If EMAIL_PROVIDER=log it appears in
552 journalctl; if resend, in your inbox in &lt;30s.
553 </p>
554 <form method="post" action="/admin/diagnose/test-email" style="margin:0">
555 <input
556 type="hidden"
557 name="_csrf"
558 value={(c.get("csrfToken") as string | undefined) || ""}
559 />
560 <button type="submit" class="btn btn-sm btn-primary">
561 Send test email
562 </button>
563 </form>
564 </div>
565 </div>
566 </Layout>
567 );
568});
569
570diagnose.post("/admin/diagnose/test-email", async (c) => {
571 const g = await gate(c);
572 if (g instanceof Response) return g;
573 const { user } = g;
574 if (!user.email) {
575 return c.redirect(
576 `/admin/diagnose?test_email=${encodeURIComponent("admin has no email on record")}`
577 );
578 }
579 const stamp = new Date().toISOString();
580 const result = await sendEmail({
581 to: user.email,
582 subject: "Gluecron — diagnose test email",
583 text:
584 `This is a test email from /admin/diagnose at ${stamp}.\n\n` +
585 `If you received this in your inbox, EMAIL_PROVIDER=resend is wired correctly.\n` +
586 `If you only see it in journalctl, EMAIL_PROVIDER is still 'log'.\n`,
587 });
588 if (!result.ok) {
589 return c.redirect(
590 `/admin/diagnose?test_email=${encodeURIComponent(result.error || result.skipped || "unknown failure")}`
591 );
592 }
593 return c.redirect("/admin/diagnose?test_email=ok");
594});
595
596export default diagnose;
Modifiedsrc/routes/admin.tsx+3−0View fileUnifiedSplit
122122 <a href="/admin/ops" class="btn btn-primary">
123123 Operations
124124 </a>
125 <a href="/admin/diagnose" class="btn">
126 Diagnose
127 </a>
125128 <a href="/admin/users" class="btn">
126129 Manage users
127130 </a>
Modifiedsrc/routes/dashboard.tsx+7−0View fileUnifiedSplit
197197 <span>
198198 You've requested too many verification emails. Try again later.
199199 </span>
200 ) : verifyQuery === "not_configured" ? (
201 <span>
202 Email delivery isn't configured on this instance yet — your
203 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
204 <code>RESEND_API_KEY</code>. Until then the verification link
205 is written to the server log.
206 </span>
200207 ) : (
201208 <span>Verify your email to keep using Gluecron.</span>
202209 )}
Modifiedsrc/routes/email-verification.tsx+9−0View fileUnifiedSplit
1616import { Layout } from "../views/layout";
1717import { softAuth, requireAuth } from "../middleware/auth";
1818import type { AuthEnv } from "../middleware/auth";
19import { config } from "../lib/config";
1920import {
2021 consumeVerificationToken,
2122 startEmailVerification,
109110 return c.redirect("/dashboard?verify=rate_limited");
110111 }
111112
113 // If the operator hasn't wired a real email provider, fire the verification
114 // anyway (it still writes a row + logs to stderr so the admin can grab the
115 // token), but redirect to a state the banner can describe honestly. Lying
116 // "Sent! Check your inbox" when the inbox will never receive it is the
117 // exact frustration the user flagged.
112118 void startEmailVerification(user.id, email);
119 if (config.emailProvider !== "resend" || !config.resendApiKey) {
120 return c.redirect("/dashboard?verify=not_configured");
121 }
113122 return c.redirect("/dashboard?verify=sent");
114123});
115124
Modifiedsrc/routes/pwa.ts+13−6View fileUnifiedSplit
117117 * version to actually reach returning visitors.
118118 *
119119 * BUILD_SHA is read from `process.env.BUILD_SHA` at request time so the
120 * deploy pipeline can rotate it without a rebuild. Falls back to a stable
121 * per-process `dev-<pid>` string when unset; a one-shot warn() is logged
122 * so operators notice misconfigured deploys.
120 * deploy pipeline can rotate it without a rebuild. Falls back to a STABLE
121 * `dev-stable` constant when unset — the previous `dev-<pid>` fallback
122 * changed on every systemd restart, which invalidated the SW on every
123 * restart and triggered the layout's updatefound→reload hook, producing
124 * visible page flashing on long-lived admin tabs. Use a constant fallback
125 * so the SW only rotates when BUILD_SHA actually changes (real deploy).
126 * A one-shot warn() is logged so operators still notice misconfigured
127 * deploys.
123128 */
124129
125130// One-shot warning latch — exported only for tests to reset between cases.
137142 "[pwa] BUILD_SHA env not set — service worker will fall back to a dev-mode version string. Set BUILD_SHA in the deploy environment so cache-busting pins to the deploy SHA."
138143 );
139144 }
140 // Dev fallback: stable per-process so reloads don't churn the cache
141 // while developing locally, but distinct from any real SHA.
142 return `dev-${process.pid}`;
145 // Dev fallback: STABLE across systemd restarts (no `${process.pid}` —
146 // that was rotating on every restart and forcing browser reloads via
147 // the layout's updatefound hook). Distinct prefix `dev-` so operators
148 // can tell at a glance the deploy pipeline didn't set BUILD_SHA.
149 return "dev-stable";
143150}
144151
145152export function buildVersionedServiceWorker(version: string): string {
146153