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
4 files changed+94−12d985e599063bae9aa4ad0727173b146c730d27b
4 changed files+94−1
Modified.github/workflows/fly-deploy.yml+3−1View fileUnifiedSplit
@@ -56,13 +56,15 @@ jobs:
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
@@ -4,6 +4,7 @@ import { config } from "./lib/config";
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 });
@@ -16,6 +17,11 @@ startWorker();
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
@@ -0,0 +1,79 @@
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
@@ -153,6 +153,12 @@ auth.post("/register", async (c) => {
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