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

fix: P0 audit sweep — kill silent failures, stop deploy-pill loop, fix all test/type errors

fix: P0 audit sweep — kill silent failures, stop deploy-pill loop, fix all test/type errors

Implements every P0 + one P2 from the May 15 deep audit. Closes the
"the website has a lot of errors" thread of frustration.

## P0 #1 — Global softAuth
`src/app.tsx` — softAuth is now `app.use("*")` instead of per-route.
Every downstream middleware (rate-limit, future auth-aware logging,
metrics) can now read c.get("user") cheaply. Fixes the rate-limit
authedMultiplier:4 which was dead code on /api/* because softAuth had
never run by the time the bucket was checked.

## P0 #2 — runScheduledWorkflowsTick fail-open
`src/lib/scheduled-workflows.ts` — coerce candidates to [] if it's not
iterable after the DB try/catch. A leaked module-mock from another
test was returning {} which crashed `for (const w of candidates)` with
TypeError. The single recurring test failure is now green.

## P0 #3 + #10 + #11 — Bootstrap surfaces real errors
`src/routes/admin-self-host.tsx` — three changes:
  (a) REAL_DEPS.spawn now redirects stdout+stderr to
      /var/log/gluecron-bootstrap.log (truncated per run, then
      appended). The old "ignore" pipes meant every failure was
      invisible.
  (b) Pre-check existsSync on bunCmd + scriptPath before spawning.
      Missing binary → friendly error redirect, not silent "success".
  (c) Success redirect message now points at the log path so the
      operator knows where to look.

## P0 #5 — UI hint: SELF_HOST_REPO needs systemctl restart
`src/routes/admin-self-host.tsx` — when SELF_HOST_REPO is unset, the
env card now shows a yellow hint box explaining the value is read at
service start, with the exact command to refresh it. Stops the
"I appended to /etc/gluecron.env but the page still says Unset"
confusion.

## P0 #6 — Type errors in self-host.test.ts
`src/hooks/post-receive.ts` — __setSelfHostSpawnForTests now accepts
`fn | null`; null resets to Bun.spawn. Matches the test contract.
`src/__tests__/self-host.test.ts` — explicit annotation on .find
callback parameter.

## Deploy-pill loop (the "looping in background" symptom)
`src/views/layout.tsx` — SSE reconnect was a tight 1500ms loop; on
any proxy timeout or connection blip the pill re-rendered every 1.5s,
producing the visible interruption. Switched to exponential backoff
(2s → 60s cap) with reset on successful message receipt.

## P2 #15 — On-disk hook sources /etc/gluecron.env
`scripts/self-host-bootstrap.ts` — SELF_HOST_HOOK_BODY now sources
/etc/gluecron.env before reading GLUECRON_SELF_DEPLOY_SCRIPT. systemd
EnvironmentFile only propagates to the gluecron service, not to git
hooks invoked by direct SSH receive-pack — without this source the
hook used baked-in defaults that silently disagreed with operator env.

## Test results
- bun test → 1994 pass / 0 fail / 2 skip (up from 1993/1/2)
- bunx tsc --noEmit → clean (the 3 pre-existing self-host.test.ts type
  errors are also closed)

https://claude.ai/code/session_01QFLWDxWw65DX6enMcS5Lwe
Test User committed on May 15, 2026Parent: a41e675
7 files changed+11913bf19c50156d3f2b6bf524b02472f2aff8cc8635b
7 changed files+119−13
Modifiedscripts/self-host-bootstrap.ts+10−0View fileUnifiedSplit
301301# SELF_HOST_REPO matches. This hook is the equivalent breadcrumb for SSH
302302# receive-pack and external direct-push scenarios.
303303set -euo pipefail
304# Source the operator env file so GLUECRON_SELF_DEPLOY_SCRIPT and any other
305# overrides set by the operator are visible to this shell hook. systemd's
306# EnvironmentFile only flows to the gluecron service, not to git hooks
307# invoked by direct receive-pack — without this source the hook used the
308# baked-in defaults which silently disagreed with /etc/gluecron.env.
309if [ -f /etc/gluecron.env ]; then
310 set -a
311 . /etc/gluecron.env
312 set +a
313fi
304314SELF_DEPLOY="\${GLUECRON_SELF_DEPLOY_SCRIPT:-/opt/gluecron/scripts/self-deploy.sh}"
305315LOG="\${GLUECRON_SELF_DEPLOY_LOG:-/var/log/gluecron-self-deploy.log}"
306316if [ -x "$SELF_DEPLOY" ]; then
Modifiedsrc/__tests__/self-host.test.ts+7−1View fileUnifiedSplit
466466 expect(result.steps.repoRow?.created).toBe(false);
467467 expect(inserts.length).toBe(0);
468468 // `git init --bare` should NOT have been called when HEAD already exists.
469 expect(calls.find((c) => c.cmd.includes("init"))).toBeUndefined();
469 expect(
470 calls.find((c: { cmd: string[] }) => c.cmd.includes("init"))
471 ).toBeUndefined();
470472 expect(result.steps.bareRepoCreated).toBe(false);
471473 });
472474
625627 captured.push({ cmd });
626628 return { unref: () => {} } as any;
627629 },
630 // Pretend both the bun binary and the script exist so the pre-spawn
631 // path-checks (admin-self-host.tsx) pass. Production gets the real
632 // existsSync.
633 fsExists: () => true,
628634 getEnv: () => ({}),
629635 });
630636 const res = await app.request(
Modifiedsrc/app.tsx+7−0View fileUnifiedSplit
125125import { csrfToken, csrfProtect } from "./middleware/csrf";
126126
127127import type { AuthEnv } from "./middleware/auth";
128import { softAuth } from "./middleware/auth";
128129
129130const app = new Hono<AuthEnv>();
130131
138139 return logger()(c, next);
139140});
140141app.use("/api/*", cors());
142// Global softAuth — populates c.get("user") for every downstream middleware
143// + route. This was previously per-route, which meant rate-limit middleware
144// (and anything else inspecting auth state) always saw a null user. Keep
145// individual routes free to add requireAuth on top for hard gating; this
146// just establishes the user object cheaply.
147app.use("*", softAuth);
141148
142149// Force-revalidate HTML on every request — kills browser cache holding stale
143150// pre-redesign markup. JSON / static assets keep their own cache rules; only
Modifiedsrc/hooks/post-receive.ts+9−3View fileUnifiedSplit
154154// BLOCK W — DI seam so the test suite can capture the spawn call without
155155// actually shelling out to /opt/gluecron/scripts/self-deploy.sh. Production
156156// callers go straight to Bun.spawn.
157let __selfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
157const __defaultSelfHostSpawn: (cmd: string[], opts: any) => any = (cmd, opts) =>
158158 Bun.spawn(cmd, opts);
159export function __setSelfHostSpawnForTests(fn: typeof __selfHostSpawn) {
160 __selfHostSpawn = fn;
159let __selfHostSpawn: (cmd: string[], opts: any) => any = __defaultSelfHostSpawn;
160/**
161 * Test-only: replace the spawn impl. Pass `null` to reset to Bun.spawn.
162 */
163export function __setSelfHostSpawnForTests(
164 fn: typeof __selfHostSpawn | null
165): void {
166 __selfHostSpawn = fn ?? __defaultSelfHostSpawn;
161167}
162168
163169/**
Modifiedsrc/lib/scheduled-workflows.ts+9−0View fileUnifiedSplit
176176 result.errors += 1;
177177 }
178178
179 // Defensive: if a leaked mock or DB driver returns a non-iterable, coerce
180 // to an empty array so `for (const w of candidates)` doesn't throw. This
181 // closes the cross-suite test-pollution failure where another test file's
182 // mock.module("../db", ...) leaked into this code path.
183 if (!Array.isArray(candidates)) {
184 candidates = [];
185 result.errors += 1;
186 }
187
179188 for (const w of candidates) {
180189 if (result.fired >= MAX_RUNS_PER_TICK) break;
181190 result.considered += 1;
Modifiedsrc/routes/admin-self-host.tsx+61−7View fileUnifiedSplit
4545 getEnv: () => Record<string, string | undefined>;
4646}
4747
48/**
49 * Bootstrap log path. The POST handler redirects stdout + stderr here so the
50 * operator can `tail -f` it (or we can read the last N lines on the next
51 * page render to surface errors). Previously every output stream was set to
52 * "ignore", which meant the operator saw "Bootstrap dispatched" toast even
53 * when the script crashed with bun-not-found / DATABASE_URL-missing /
54 * GitHub-clone-failed. P0 from the May 15 audit.
55 */
56export const BOOTSTRAP_LOG_PATH = "/var/log/gluecron-bootstrap.log";
57
4858const REAL_DEPS: Deps = {
4959 audit: realAudit,
50 spawn: (cmd, _opts) =>
51 Bun.spawn(cmd, {
52 stdout: "ignore",
53 stderr: "ignore",
54 stdin: "ignore",
55 }),
60 spawn: (cmd, _opts) => {
61 // Open the log file for append; if the open fails (perm issue, missing
62 // /var/log) fall back to inherit so output at least goes to journalctl.
63 let stdout: any = "inherit";
64 let stderr: any = "inherit";
65 try {
66 const log = Bun.file(BOOTSTRAP_LOG_PATH);
67 // Truncate the previous run's output so the operator sees only the
68 // current attempt. `Bun.write` is sync-ish and returns a promise we
69 // don't need to await — the spawn happens regardless.
70 void Bun.write(
71 BOOTSTRAP_LOG_PATH,
72 `[${new Date().toISOString()}] bootstrap dispatched: ${cmd.join(" ")}\n`
73 );
74 stdout = log.writer();
75 stderr = log.writer();
76 } catch {
77 // Fall back to inherit
78 }
79 return Bun.spawn(cmd, { stdout, stderr, stdin: "ignore" });
80 },
5681 fsExists: existsSync,
5782 getEnv: () => process.env as Record<string, string | undefined>,
5883};
334359 </span>
335360 </li>
336361 </ul>
362 {!envState.selfHostRepoSet && (
363 <div style="margin-top:12px;padding:10px 12px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);border-radius:6px;font-size:12px;line-height:1.5">
364 <strong style="color:#f59e0b">Hint:</strong>{" "}
365 <code>SELF_HOST_REPO</code> is read from{" "}
366 <code>/etc/gluecron.env</code> when the gluecron service starts.
367 If you just appended it via SSH, the running process won't see
368 it until you run:
369 <pre style="margin:8px 0 0 0;padding:6px 8px;background:var(--bg);border-radius:4px;font-size:11px;overflow-x:auto">systemctl restart gluecron</pre>
370 </div>
371 )}
337372 <div style="margin-top:14px;font-size:12px;color:var(--text-muted)">
338373 Overall:{" "}
339374 <Pill
459494 const scriptPath =
460495 _deps.getEnv().GLUECRON_BOOTSTRAP_SCRIPT ||
461496 "/opt/gluecron/scripts/self-host-bootstrap.ts";
497
498 // P0 audit #10/#11 — pre-check the binary + script paths exist before
499 // spawning. The old handler spawned blindly with stderr discarded, so a
500 // missing bun produced a "success" toast and a permanently broken state.
501 if (!_deps.fsExists(bunCmd)) {
502 return redirectWith(
503 c,
504 "error",
505 `Bootstrap aborted: bun binary not found at ${bunCmd}. Set GLUECRON_BUN_PATH or install bun on the box.`
506 );
507 }
508 if (!_deps.fsExists(scriptPath)) {
509 return redirectWith(
510 c,
511 "error",
512 `Bootstrap aborted: script not found at ${scriptPath}. The deploy may be incomplete.`
513 );
514 }
515
462516 const child = _deps.spawn([bunCmd, "run", scriptPath], { detached: true });
463517 try {
464518 (child as any)?.unref?.();
478532 return redirectWith(
479533 c,
480534 "success",
481 "Bootstrap dispatched — watch the system journal for progress."
535 `Bootstrap dispatched. Output streams to ${BOOTSTRAP_LOG_PATH} — refresh in ~30s to see status.`
482536 );
483537 } catch (err) {
484538 const message = err instanceof Error ? err.message : String(err);
Modifiedsrc/views/layout.tsx+16−2View fileUnifiedSplit
458458 if (typeof EventSource === 'undefined') return;
459459 subscribed = true;
460460 var es;
461 var delay = 1500;
461 // Exponential backoff with cap. Previously a tight 1500ms reconnect
462 // produced visible looping in the nav whenever the proxy timed out
463 // or the connection blipped — the bottom-of-page deploy pill
464 // re-rendered the placeholder every 1.5s. Cap at 60s and reset on
465 // successful message receipt.
466 var delay = 2000;
467 var DELAY_MAX = 60000;
468 function bump(){
469 delay = Math.min(delay * 2, DELAY_MAX);
470 }
471 function resetDelay(){
472 delay = 2000;
473 }
462474 function connect(){
463475 try { es = new EventSource('/live-events/platform:deploys'); }
464 catch(e){ setTimeout(connect, delay); return; }
476 catch(e){ bump(); setTimeout(connect, delay); return; }
465477 es.onmessage = function(m){
478 resetDelay();
466479 try {
467480 var d = JSON.parse(m.data);
468481 if (d && d.run_id) {
476489 };
477490 es.onerror = function(){
478491 try { es.close(); } catch(e){}
492 bump();
479493 setTimeout(connect, delay);
480494 };
481495 }
482496