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

feat(admin): SITE_ADMIN_USERNAME env-bootstrap (default ccantynz-alt)

feat(admin): SITE_ADMIN_USERNAME env-bootstrap (default ccantynz-alt)

Two-pronged grant so the named user always has site-admin without
waiting on the oldest-user-bootstrap rule:

src/lib/admin-bootstrap.ts (new)
- ensureEnvSiteAdmin() runs on every boot. Reads SITE_ADMIN_USERNAME
  (lower-cased), looks up the user, ensures a site_admins row.
  Idempotent. Logs and retries on next boot if user not yet
  registered.
- ensureEnvAdminOnRegister() called from the register flow so the
  grant lands instantly the moment the named user signs up.

src/index.ts
- Fires ensureEnvSiteAdmin() on boot (background, won't block start).

src/routes/auth.tsx
- Hook into POST /register success to call
  ensureEnvAdminOnRegister() (lazy import to avoid cycles).

.github/workflows/fly-deploy.yml
- Syncs SITE_ADMIN_USERNAME to Fly secrets on every deploy. Default
  value is ccantynz-alt so as soon as that user registers, they're
  granted admin instantly. Override via repo variable.

Scope clarification: this is site-admin (manage users, billing,
flags). Auto-repair already runs only on repos owned by the pusher;
admin status doesn't change that. ccantynz-alt's auto-repair fires
on ccantynz-alt's repos, not anyone else's.

https://claude.ai/code/session_017Do52tMX1P9jPTWXhEohXH
Claude committed on April 30, 2026Parent: 2294b01
4 files changed+9412d985e599063bae9aa4ad0727173b146c730d27b
4 changed files+94−1
Modified.github/workflows/fly-deploy.yml+3−1View fileUnifiedSplit
5656 DATABASE_URL="$DATABASE_URL" \
5757 APP_BASE_URL="$APP_BASE_URL" \
5858 DEMO_SEED_ON_BOOT="$DEMO_SEED_ON_BOOT" \
59 EMAIL_PROVIDER="$EMAIL_PROVIDER"
59 EMAIL_PROVIDER="$EMAIL_PROVIDER" \
60 SITE_ADMIN_USERNAME="$SITE_ADMIN_USERNAME"
6061 env:
6162 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
6263 DATABASE_URL: ${{ secrets.DATABASE_URL }}
6364 APP_BASE_URL: ${{ vars.APP_BASE_URL || 'https://gluecron.fly.dev' }}
6465 DEMO_SEED_ON_BOOT: ${{ vars.DEMO_SEED_ON_BOOT || '1' }}
6566 EMAIL_PROVIDER: ${{ vars.EMAIL_PROVIDER || 'log' }}
67 SITE_ADMIN_USERNAME: ${{ vars.SITE_ADMIN_USERNAME || 'ccantynz-alt' }}
6668
6769 - name: Deploy
6870 run: flyctl deploy --remote-only --app gluecron
Modifiedsrc/index.ts+6−0View fileUnifiedSplit
44import { startWorker } from "./lib/workflow-runner";
55import { startAutopilot } from "./lib/autopilot";
66import { ensureDemoContent } from "./lib/demo-seed";
7import { ensureEnvSiteAdmin } from "./lib/admin-bootstrap";
78
89// Ensure repos directory exists
910await mkdir(config.gitReposPath, { recursive: true });
1617// advisory rescans. No-op when AUTOPILOT_DISABLED=1.
1718startAutopilot();
1819
20// Site-admin bootstrap from env (SITE_ADMIN_USERNAME). Idempotent — if the
21// user exists, they get a row in site_admins; if not, logged and retried
22// on next boot. Background-fired so a slow DB doesn't block startup.
23void ensureEnvSiteAdmin().catch(() => {});
24
1925// Opt-in demo content seed on boot (DEMO_SEED_ON_BOOT=1). Idempotent, never
2026// throws — safe to run on every start.
2127if (process.env.DEMO_SEED_ON_BOOT === "1") {
Addedsrc/lib/admin-bootstrap.ts+79−0View fileUnifiedSplit
1/**
2 * Site-admin bootstrap from env. On every boot we check whether
3 * `SITE_ADMIN_USERNAME` is set and, if so, ensure that user has a row in
4 * `site_admins`. Idempotent — re-running is a cheap no-op once granted.
5 *
6 * The user must already exist in `users` for the grant to apply. If they
7 * haven't registered yet, the function logs and returns; the next boot
8 * (or the register flow, which calls `ensureEnvAdminOnRegister()`) will
9 * pick them up.
10 *
11 * This complements the bootstrap rule in `isSiteAdmin()` (oldest user is
12 * implicit admin while site_admins is empty). The env-based grant makes
13 * it deterministic across re-deploys and team handoffs.
14 */
15
16import { eq } from "drizzle-orm";
17import { db } from "../db";
18import { siteAdmins, users } from "../db/schema";
19import { grantSiteAdmin } from "./admin";
20
21/** Read SITE_ADMIN_USERNAME (lower-cased to match users.username). Returns
22 * null if unset or empty. */
23export function getEnvAdminUsername(): string | null {
24 const raw = process.env.SITE_ADMIN_USERNAME?.trim();
25 if (!raw) return null;
26 return raw.toLowerCase();
27}
28
29/** Ensure the env-named user is a site admin. Safe to call every boot. */
30export async function ensureEnvSiteAdmin(): Promise<void> {
31 const username = getEnvAdminUsername();
32 if (!username) return;
33 try {
34 const [user] = await db
35 .select({ id: users.id })
36 .from(users)
37 .where(eq(users.username, username))
38 .limit(1);
39 if (!user) {
40 console.log(
41 `[admin-bootstrap] SITE_ADMIN_USERNAME=${username} but no such user yet — will retry next boot or on register`
42 );
43 return;
44 }
45 const [existing] = await db
46 .select({ userId: siteAdmins.userId })
47 .from(siteAdmins)
48 .where(eq(siteAdmins.userId, user.id))
49 .limit(1);
50 if (existing) {
51 console.log(`[admin-bootstrap] ${username} already has admin`);
52 return;
53 }
54 const granted = await grantSiteAdmin(user.id, null);
55 if (granted) {
56 console.log(`[admin-bootstrap] granted site admin to ${username}`);
57 } else {
58 console.warn(`[admin-bootstrap] failed to grant admin to ${username}`);
59 }
60 } catch (err) {
61 console.error("[admin-bootstrap] error:", err);
62 }
63}
64
65/** Hook for the registration flow — if the just-registered user matches
66 * SITE_ADMIN_USERNAME, grant admin immediately so they don't have to
67 * wait for the next boot. */
68export async function ensureEnvAdminOnRegister(args: {
69 userId: string;
70 username: string;
71}): Promise<void> {
72 const target = getEnvAdminUsername();
73 if (!target) return;
74 if (args.username.toLowerCase() !== target) return;
75 await grantSiteAdmin(args.userId, null);
76 console.log(
77 `[admin-bootstrap] auto-granted admin to ${args.username} on register`
78 );
79}
Modifiedsrc/routes/auth.tsx+6−0View fileUnifiedSplit
153153 .values({ username, email, passwordHash, isAdmin: isFirstUser })
154154 .returning();
155155
156 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
157 // so the operator doesn't have to wait for the next boot's bootstrap pass.
158 await import("../lib/admin-bootstrap").then((m) =>
159 m.ensureEnvAdminOnRegister({ userId: user.id, username })
160 ).catch(() => {});
161
156162 // Create session
157163 const token = generateSessionToken();
158164 await db.insert(sessions).values({
159165