import { createHash, randomBytes } from "node:crypto"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, gt, isNull } from "drizzle-orm"; import { createDb, instanceUserRoles, invites } from "@paperclipai/db"; import { loadPaperclipEnvFile } from "../config/env.js"; import { readConfig, resolveConfigPath } from "../config/store.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } function createInviteToken() { return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; } function resolveDbUrl(configPath?: string, explicitDbUrl?: string) { if (explicitDbUrl) return explicitDbUrl; const config = readConfig(configPath); if (process.env.DATABASE_URL) return process.env.DATABASE_URL; if (config?.database.mode === "postgres" && config.database.connectionString) { return config.database.connectionString; } if (config?.database.mode === "embedded-postgres") { const port = config.database.embeddedPostgresPort ?? 54329; return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; } return null; } function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, ""); const fromEnv = process.env.PAPERCLIP_PUBLIC_URL ?? process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL ?? process.env.BETTER_AUTH_BASE_URL; if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, ""); const config = readConfig(configPath); if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { return config.auth.publicBaseUrl.replace(/\/+$/, ""); } const host = config?.server.host ?? "localhost"; const port = config?.server.port ?? 3100; const publicHost = host === "0.0.0.0" ? "localhost" : host; return `http://${publicHost}:${port}`; } export async function bootstrapCeoInvite(opts: { config?: string; force?: boolean; expiresHours?: number; baseUrl?: string; dbUrl?: string; }) { const configPath = resolveConfigPath(opts.config); loadPaperclipEnvFile(configPath); const config = readConfig(configPath); if (!config) { p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); return; } if (config.server.deploymentMode !== "authenticated") { p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode."); return; } const dbUrl = resolveDbUrl(configPath, opts.dbUrl); if (!dbUrl) { p.log.error( "Could not resolve database connection for bootstrap.", ); return; } const db = createDb(dbUrl); const closableDb = db as typeof db & { $client?: { end?: (options?: { timeout?: number }) => Promise; }; }; try { const existingAdminCount = await db .select() .from(instanceUserRoles) .where(eq(instanceUserRoles.role, "instance_admin")) .then((rows) => rows.length); if (existingAdminCount > 0 && !opts.force) { p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite."); return; } const now = new Date(); 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 = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72)); const created = await db .insert(invites) .values({ inviteType: "bootstrap_ceo", tokenHash: hashToken(token), allowedJoinTypes: "human", expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000), invitedByUserId: "system", }) .returning() .then((rows) => rows[0]); const baseUrl = resolveBaseUrl(configPath, opts.baseUrl); const inviteUrl = `${baseUrl}/invite/${token}`; p.log.success("Created bootstrap CEO invite."); p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`); p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`); } catch (err) { p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`); p.log.info("If using embedded-postgres, start the Paperclip server and run this command again."); } finally { await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); } }