import { randomInt } from "node:crypto"; import { db } from "@/lib/db"; import { sweepArchive } from "@/lib/data/archive"; import { generatePassphrase } from "@/lib/passphrase"; import { CLAIMED, PLACEHOLDER_OWNER_EMAIL, ensureDefaultConnection, hashPassword } from "@/lib/auth"; /** * First-boot provisioning. * * Self-hosting should not begin with "invent a password and paste it into an * environment variable". On first start this creates the owner account, makes * up a strong password, and prints it once to the server log. The only variable * you have to set anywhere is DATABASE_URL, and your host fills that in for you. * * Claiming the instance at boot rather than through an open /setup page also * closes the window where a stranger who found the URL could claim it first. */ function banner(lines: string[]) { const width = Math.max(...lines.map((line) => line.length), 58); const rule = "═".repeat(width + 2); // Written straight to stdout so it survives whatever log formatting is in play. process.stdout.write( `\n╔${rule}╗\n` + lines.map((line) => `║ ${line.padEnd(width)} ║`).join("\n") + `\n╚${rule}╝\n\n`, ); } function publicUrl() { const domain = process.env.RAILWAY_PUBLIC_DOMAIN || process.env.PUBLIC_URL; if (!domain) return null; return domain.startsWith("http") ? domain : `https://${domain}`; } async function ownerEmail() { const configured = process.env.ADMIN_EMAIL?.trim().toLowerCase(); if (configured) return configured; return PLACEHOLDER_OWNER_EMAIL; } /** * Creates the owner if nobody has claimed the instance yet. Adopts the * placeholder row left by the multi-tenant migration, so data from a * single-user install changes hands instead of being orphaned. */ export async function ensureOwner() { const claimed = await db.user.count({ where: CLAIMED }); if (claimed > 0) { await maybeResetOwnerPassword(); return; } const email = await ownerEmail(); // APP_PASSWORD is honoured if you set one, but you never have to. const provided = process.env.APP_PASSWORD?.trim(); const password = provided || generatePassphrase(); const placeholder = await db.user.findFirst({ where: { passwordHash: "", googleId: null } }); const data = { email, name: "", passwordHash: hashPassword(password), role: "SUPER_ADMIN" as const, isActive: true, emailProvenAt: new Date(), // The password sits in the platform's deploy log for as long as the log // is kept, so it opens the door once: the first sign-in replaces it. mustChangePassword: !provided, }; const owner = placeholder ? await db.user.update({ where: { id: placeholder.id }, data }) : await db.user.create({ data }); await ensureDefaultConnection(owner.id); const url = publicUrl(); banner([ "Hired is ready — this is your owner account.", "", ...(url ? [` Sign in ${url}`] : []), ` Email ${email}`, ` Password ${password}`, "", provided ? " (password taken from your APP_PASSWORD variable)" : " This password was generated for you and is shown ONCE.", provided ? " Change both from Settings once you're in." : " It works once: signing in asks for your own email and password.", "", " Lost it? Set RESET_OWNER_PASSWORD=1 and redeploy to get a new one.", ]); } /** * Recovery hatch for a self-hoster who closed the log. Set * RESET_OWNER_PASSWORD=1, redeploy, read the new password, then remove the * variable. Signs every existing session out. */ async function maybeResetOwnerPassword() { const flag = process.env.RESET_OWNER_PASSWORD?.trim().toLowerCase(); if (!flag || flag === "0" || flag === "false") return; const owner = await db.user.findFirst({ where: { role: "SUPER_ADMIN", ...CLAIMED }, orderBy: { createdAt: "asc" }, }); if (!owner) return; const password = generatePassphrase(); await db.user.update({ where: { id: owner.id }, // Printed to the log like the first one, so it is replaced on first use too. data: { passwordHash: hashPassword(password), isActive: true, mustChangePassword: true }, }); await db.session.deleteMany({ where: { userId: owner.id } }); const url = publicUrl(); banner([ "Owner password reset.", "", ...(url ? [` Sign in ${url}`] : []), ` Email ${owner.email}`, ` Password ${password}`, "", " Remove the RESET_OWNER_PASSWORD variable now, or it will", " reset again on the next deploy.", ]); } /** Never let provisioning take the server down; log and carry on. */ export async function bootstrap() { // Anything whose window ran out while the instance was down. Same rule as // ensureOwner: a failure here must not take the server with it. void sweepArchive().catch(() => {}); try { await ensureOwner(); } catch (error) { console.error( "[bootstrap] Could not provision the owner account:", error instanceof Error ? error.message : error, ); console.error("[bootstrap] Visit /setup to create it by hand."); } }