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

feat(platform): self-bootstrap + emergency-PAT API — kill the chicken-and-egg

feat(platform): self-bootstrap + emergency-PAT API — kill the chicken-and-egg

Two surgical fixes that make Gluecron actually self-sufficient. Tonight
exposed that the platform's "self-hosting" promise was a paper tiger:
the canonical repo didn't exist on disk, so push-to-deploy was a 404;
PAT creation was UI-only, so when the UI broke (SW reload loop), there
was no bootstrap path. We ended up doing direct-to-Neon SQL inserts
and GitHub-Action sidesteps. Never again.

## Self-bootstrap on boot (`src/lib/self-bootstrap.ts`)

If the canonical bare repo (default `ccantynz/Gluecron.com.git`)
doesn't exist on disk at boot, run the existing
`scripts/self-host-bootstrap.ts` flow inline: insert the repositories
row, `git init --bare`, mirror-clone from the GitHub source URL,
install the post-receive hook. Idempotent — runs once per host then
becomes a no-op.

After this lands on any Fly machine, the canonical exists, future
pushes route through Gluecron's own post-receive → self-deploy.sh.
GitHub Actions becomes break-glass, not the primary path.

Env knobs (all optional):
  SELF_BOOTSTRAP_DISABLED=1   — skip entirely
  SELF_BOOTSTRAP_OWNER        — default "ccantynz"
  SELF_BOOTSTRAP_NAME         — default "Gluecron.com"
  SELF_BOOTSTRAP_SOURCE       — default the GitHub mirror

Fire-and-forget from `src/index.ts` — never blocks startup, swallows
all errors with a single warn line. The explicit script still exists
for verbose manual runs.

## Emergency PAT API (`POST /api/admin/emergency-pat`)

Tonight we discovered the only path to mint a PAT was through the
web UI, which was unreachable because of the SW loop. No API. No
break-glass. The "fix" was a tonight-only script that did raw SQL
against the production DB.

This endpoint replaces all of that. Gated by `EMERGENCY_PAT_SECRET`
env var (returns 503 if unset — never silently usable). Bearer auth
on the secret. Issues a PAT for the named user, falls back to
site-admin or oldest-user. Returns JSON with the raw token shown
once.

Crontech-callable from day one:
  curl -X POST $URL/api/admin/emergency-pat \
    -H "Authorization: Bearer $EMERGENCY_PAT_SECRET" \
    -d '{"username":"ccantynz","scopes":"admin"}'

## What's NOT in this commit

Resisting kitchen-sink. Platform-health aggregator + recovery-mode
auto-heal land in a follow-up. The two changes here are enough to
close the chicken-and-egg — Crontech can call the emergency-PAT API
to provision itself, and Gluecron bootstraps its own canonical so
push-to-deploy works going forward.

## Tests
- bun test → 1994 pass / 0 fail / 2 skip
- bunx tsc → clean

https://claude.ai/code/session_01QFLWDxWw65DX6enMcS5Lwe
Test User committed on May 15, 2026Parent: 338f3dc
3 files changed+240096942a6a63800d379d194db1ecf28524d84a779e
3 changed files+240−0
Modifiedsrc/index.ts+10−0View fileUnifiedSplit
66import { ensureDemoContent } from "./lib/demo-seed";
77import { ensureDemoActivity } from "./lib/demo-activity-seed";
88import { ensureEnvSiteAdmin } from "./lib/admin-bootstrap";
9import { maybeSelfBootstrap } from "./lib/self-bootstrap";
910import { notifySystemdReady } from "./lib/systemd-notify";
1011
1112// Ensure repos directory exists
1213await mkdir(config.gitReposPath, { recursive: true });
1314
15// Self-bootstrap: if Gluecron's own canonical repo (`ccantynz/Gluecron.com.git`
16// by default) doesn't exist on disk yet, initialize it from the GitHub mirror
17// and install the post-receive hook. This is the platform's self-healing path
18// — once it runs successfully on a host, future deploys flow through Gluecron
19// itself with no external CI tooling. Fire-and-forget; never blocks startup.
20void maybeSelfBootstrap().catch((err) => {
21 console.warn(`[self-bootstrap] swallowed: ${(err as Error).message}`);
22});
23
1424// Start the Actions-equivalent workflow worker (Block C1). Polls
1525// workflow_runs for queued rows and executes them sequentially.
1626startWorker();
Addedsrc/lib/self-bootstrap.ts+115−0View fileUnifiedSplit
1/**
2 * Self-bootstrap — fires on boot if Gluecron's own canonical repo
3 * doesn't yet exist on disk. Idempotent, safe to call every boot.
4 *
5 * This closes the chicken-and-egg gap where:
6 * - The site self-hosts on gluecron.com/ccantynz/Gluecron.com.git
7 * - But that bare repo was never initialized on the Fly volume
8 * - So every `git push` to it returned "Repository not found"
9 * - So nothing ever deployed through Gluecron's own canonical path
10 *
11 * After this boots once, the canonical repo exists, the post-receive
12 * hook is installed, and Gluecron is fully self-sufficient — future
13 * deploys do not require GitHub Actions, flyctl, or any external tool.
14 *
15 * Configuration (env vars, all optional):
16 * SELF_BOOTSTRAP_DISABLED=1 — skip entirely
17 * SELF_BOOTSTRAP_OWNER — default "ccantynz"
18 * SELF_BOOTSTRAP_NAME — default "Gluecron.com"
19 * SELF_BOOTSTRAP_SOURCE — default github mirror URL
20 *
21 * Never throws out of boot. Returns silently on any failure (logged).
22 * Run the explicit script (`bun run scripts/self-host-bootstrap.ts`)
23 * for verbose output and exit codes.
24 */
25
26import { existsSync } from "fs";
27import { mkdir, writeFile, chmod, rm } from "fs/promises";
28import { join } from "path";
29import { tmpdir } from "os";
30import {
31 runBootstrap,
32 sh,
33 type BootstrapArgs,
34 type BootstrapDeps,
35} from "../../scripts/self-host-bootstrap";
36
37const DEFAULT_OWNER = "ccantynz";
38const DEFAULT_NAME = "Gluecron.com";
39const DEFAULT_SOURCE = "https://github.com/ccantynz-alt/Gluecron.com.git";
40
41export async function maybeSelfBootstrap(): Promise<void> {
42 if (process.env.SELF_BOOTSTRAP_DISABLED === "1") {
43 return;
44 }
45
46 // Lazy imports — these touch the live DB / config, and we want
47 // src/lib/self-bootstrap.ts itself to be cheap to import in tests.
48 const { db } = await import("../db");
49 const schemaMod = await import("../db/schema");
50 const { config } = await import("./config");
51
52 const owner = process.env.SELF_BOOTSTRAP_OWNER || DEFAULT_OWNER;
53 const name = process.env.SELF_BOOTSTRAP_NAME || DEFAULT_NAME;
54 const source = process.env.SELF_BOOTSTRAP_SOURCE || DEFAULT_SOURCE;
55
56 // Fast path: if the bare repo already exists on disk, the bootstrap
57 // is a no-op and we skip even the DB roundtrip. The explicit script
58 // is still idempotent if you want to re-verify everything.
59 const barePath = join(config.gitReposPath, owner, `${name}.git`);
60 if (existsSync(join(barePath, "HEAD"))) {
61 return;
62 }
63
64 const args: BootstrapArgs = { owner, name, source, dryRun: false };
65
66 // Quiet log shim — boot output is precious; only print on completion
67 // or failure. The verbose script-mode logs go to stdout when you run
68 // scripts/self-host-bootstrap.ts directly.
69 const lines: string[] = [];
70 const push = (level: string, msg: string) => lines.push(`[${level}] ${msg}`);
71
72 const deps: BootstrapDeps = {
73 db,
74 schema: {
75 users: schemaMod.users,
76 repositories: schemaMod.repositories,
77 siteAdmins: schemaMod.siteAdmins,
78 },
79 reposPath: config.gitReposPath,
80 sh,
81 fsExists: existsSync,
82 fsMkdir: mkdir,
83 fsWrite: (p, body) => writeFile(p, body, "utf8"),
84 fsChmod: chmod,
85 fsRm: rm,
86 log: {
87 say: (m) => push("·", m),
88 ok: (m) => push("ok", m),
89 warn: (m) => push("warn", m),
90 bad: (m) => push("err", m),
91 info: (m) => push("info", m),
92 },
93 tmpRoot: tmpdir(),
94 };
95
96 try {
97 const result = await runBootstrap(args, deps);
98 if (result.ok) {
99 console.log(
100 `[self-bootstrap] canonical ${owner}/${name} initialized from ${source}`
101 );
102 } else {
103 console.warn(
104 `[self-bootstrap] failed: ${result.error || "unknown"} — see scripts/self-host-bootstrap.ts to re-run with verbose output`
105 );
106 // Print the captured log lines so the operator can diagnose
107 // without grepping process state.
108 for (const l of lines.slice(-20)) console.warn(` ${l}`);
109 }
110 } catch (err) {
111 console.warn(
112 `[self-bootstrap] crashed: ${(err as Error).message} — boot continues normally`
113 );
114 }
115}
Modifiedsrc/routes/tokens.tsx+115−0View fileUnifiedSplit
206206 return c.redirect("/settings/tokens?success=Token+revoked");
207207});
208208
209/**
210 * Emergency PAT issuance — break-glass for when the web UI is broken
211 * (service-worker loop, css busted, whatever) and an operator needs
212 * a token to push a fix.
213 *
214 * Auth: bearer of the `EMERGENCY_PAT_SECRET` env var (set on the host).
215 * If the env var is unset, the endpoint returns 503 — we don't want it
216 * silently usable with an empty secret. This is the ONLY token route
217 * that isn't behind a normal session, by design.
218 *
219 * Issues a PAT for the user named in the JSON body's `username` field,
220 * defaulting to the site admin / oldest user (same heuristic the
221 * self-host bootstrap uses).
222 *
223 * Returns JSON: { user, token } — the token is shown ONCE.
224 *
225 * Use:
226 * curl -X POST https://gluecron.com/api/admin/emergency-pat \
227 * -H "Authorization: Bearer $EMERGENCY_PAT_SECRET" \
228 * -H "content-type: application/json" \
229 * -d '{"name":"break-glass","scopes":"admin"}'
230 */
231tokens.post("/api/admin/emergency-pat", async (c) => {
232 const secret = process.env.EMERGENCY_PAT_SECRET;
233 if (!secret) {
234 return c.json(
235 { error: "emergency PAT endpoint not configured (EMERGENCY_PAT_SECRET unset)" },
236 503
237 );
238 }
239 const provided = (c.req.header("authorization") || "").replace(/^Bearer\s+/i, "").trim();
240 if (provided !== secret) {
241 return c.json({ error: "invalid emergency secret" }, 401);
242 }
243
244 let body: { username?: string; name?: string; scopes?: string };
245 try {
246 body = await c.req.json();
247 } catch {
248 body = {};
249 }
250 const name = (body.name || "emergency-pat").trim();
251 const scopes = (body.scopes || "admin").trim();
252
253 // Resolve target user: explicit username → site admin → oldest user.
254 const { users, siteAdmins } = await import("../db/schema");
255 const { eq: eqOp, asc } = await import("drizzle-orm");
256 let target:
257 | { id: string; username: string }
258 | undefined;
259
260 if (body.username) {
261 const [u] = await db
262 .select({ id: users.id, username: users.username })
263 .from(users)
264 .where(eqOp(users.username, body.username))
265 .limit(1);
266 target = u;
267 }
268 if (!target) {
269 try {
270 const [u] = await db
271 .select({ id: users.id, username: users.username })
272 .from(siteAdmins)
273 .innerJoin(users, eqOp(siteAdmins.userId, users.id))
274 .limit(1);
275 target = u;
276 } catch {
277 // siteAdmins table may not exist on stale schemas — fall through.
278 }
279 }
280 if (!target) {
281 const [u] = await db
282 .select({ id: users.id, username: users.username })
283 .from(users)
284 .orderBy(asc(users.createdAt))
285 .limit(1);
286 target = u;
287 }
288 if (!target) {
289 return c.json({ error: "no user available to issue PAT for" }, 404);
290 }
291
292 // Token + hash — same algorithm the web flow uses.
293 const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
294 const token =
295 "glc_" +
296 Array.from(tokenBytes)
297 .map((b) => b.toString(16).padStart(2, "0"))
298 .join("");
299 const hashBuf = await crypto.subtle.digest(
300 "SHA-256",
301 new TextEncoder().encode(token)
302 );
303 const tokenHash = Array.from(new Uint8Array(hashBuf))
304 .map((b) => b.toString(16).padStart(2, "0"))
305 .join("");
306
307 await db.insert(apiTokens).values({
308 userId: target.id,
309 name,
310 tokenHash,
311 tokenPrefix: token.slice(0, 12),
312 scopes,
313 });
314
315 return c.json({
316 user: { id: target.id, username: target.username },
317 token,
318 name,
319 scopes,
320 note: "Token is shown once. Store it now.",
321 });
322});
323
209324// API endpoint
210325tokens.get("/api/user/tokens", async (c) => {
211326 const user = c.get("user")!;
212327