// [nexus] Auto-create a bootstrap_ceo invite on first boot so the web UI can // redirect the first user to /invite/{token} without any terminal command. // Mirrors the logic in cli/src/commands/auth-bootstrap-ceo.ts; the CLI command // remains available for headless / SSH-only setups. import { createHash, randomBytes } from "node:crypto"; import { and, count, eq, gt, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { instanceUserRoles, invites } from "@paperclipai/db"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } function createInviteToken() { return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; } /** * If no instance_admin user exists, create a bootstrap_ceo invite and return * the relative path the browser should navigate to in order to create the * first admin account. Returns null if an admin already exists (no-op). * * Safe to call on every boot — when an admin already exists it's a single * count query with no writes. */ export async function ensureBootstrapInvite(db: Db): Promise { const adminCount = await db .select({ count: count() }) .from(instanceUserRoles) .where(eq(instanceUserRoles.role, "instance_admin")) .then((rows) => Number(rows[0]?.count ?? 0)); if (adminCount > 0) return null; const now = new Date(); // Revoke any stale live invites so our fresh token is the only match. // We can't recover the raw token from the stored hash, so the previous // invite is unreachable and must be replaced on restart. await db .update(invites) .set({ revokedAt: now, updatedAt: now }) .where( and( eq(invites.inviteType, "bootstrap_ceo"), isNull(invites.revokedAt), isNull(invites.acceptedAt), gt(invites.expiresAt, now), ), ); const token = createInviteToken(); const expiresHours = 72; await db.insert(invites).values({ inviteType: "bootstrap_ceo", tokenHash: hashToken(token), allowedJoinTypes: "human", expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000), invitedByUserId: "system", }); return `/invite/${token}`; }