/** * Core bootstrap logic for creating a superuser and initial organization * * This is extracted from the CLI bootstrap command to make it reusable * and testable. It operates on a database transaction. */ import type { Database } from "@reviq/db-schema"; import type { Kysely, Transaction } from "kysely"; import { hashPassword } from "@reviq/server-utils"; import { generateToken, hashToken } from "./token.js"; /** * Input for the bootstrap operation */ export interface BootstrapInput { /** Email address for the superuser */ email: string; /** Password for the superuser */ password: string; /** Optional organization slug (defaults to "reviq") */ orgSlug?: string; /** Optional organization display name (defaults to "RevIQ") */ orgDisplayName?: string; /** Optional token name (defaults to "CLI bootstrap token") */ tokenName?: string; /** Optional token expiration in days (defaults to 365) */ tokenExpirationDays?: number; /** Delete existing user and org if they exist (defaults to false) */ dangerouslyOverwriteExisting?: boolean; } /** * Result of the bootstrap operation */ export interface BootstrapResult { /** The created user */ user: { id: number; email: string; }; /** The created organization */ org: { id: number; slug: string; }; /** The created API token (raw token, not hashed) */ token: string; } /** * Execute the bootstrap operation within a transaction * * Creates: * - A superuser with the given email and password * - An organization with the superuser as owner * - An API token for the superuser * * @param trx - Database transaction (use db.transaction() or pass a Transaction) * @param input - Bootstrap configuration * @returns The created user, org, and API token * @throws Error if user already exists or validation fails */ export const executeBootstrap = async ( trx: Kysely | Transaction, input: BootstrapInput, ): Promise => { const { email, password, orgSlug = "reviq", orgDisplayName = "RevIQ", tokenName = "CLI bootstrap token", tokenExpirationDays = 365, dangerouslyOverwriteExisting = false, } = input; // Validate password length if (password.length < 8) { throw new Error("Password must be at least 8 characters"); } // Validate email format (basic check) if (!email.includes("@")) { throw new Error("Invalid email address"); } const normalizedEmail = email.toLowerCase(); // Handle overwrite mode - delete existing user and org if (dangerouslyOverwriteExisting) { // Delete existing user and related records const existingUser = await trx .selectFrom("users") .where("email", "=", normalizedEmail) .select("id") .executeTakeFirst(); if (existingUser) { // Delete all user-related records (FK constraints) await trx .deleteFrom("api_tokens") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("email_verifications") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("login_requests") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("passkeys") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("password_resets") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("sessions") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("user_devices") .where("user_id", "=", existingUser.id) .execute(); await trx .deleteFrom("org_members") .where("user_id", "=", existingUser.id) .execute(); // Delete invites created by this user await trx .deleteFrom("org_invites") .where("invited_by", "=", existingUser.id) .execute(); // Delete the user await trx.deleteFrom("users").where("id", "=", existingUser.id).execute(); } // Delete existing org and related records const existingOrg = await trx .selectFrom("orgs") .where("slug", "=", orgSlug) .select("id") .executeTakeFirst(); if (existingOrg) { // Delete all org-related records (FK constraints) await trx .deleteFrom("org_invites") .where("org_id", "=", existingOrg.id) .execute(); await trx .deleteFrom("org_members") .where("org_id", "=", existingOrg.id) .execute(); await trx .deleteFrom("org_sites") .where("org_id", "=", existingOrg.id) .execute(); // Delete the org await trx.deleteFrom("orgs").where("id", "=", existingOrg.id).execute(); } } else { // Check if user already exists (normal mode) const existing = await trx .selectFrom("users") .where("email", "=", normalizedEmail) .select("id") .executeTakeFirst(); if (existing) { throw new Error(`User with email ${email} already exists`); } } // Hash the password const passwordHash = await hashPassword(password); // Create superuser const [user] = await trx .insertInto("users") .values({ email: normalizedEmail, password_hash: passwordHash, is_superuser: true, email_verified_at: new Date(), }) .returning(["id", "email"]) .execute(); if (!user) { throw new Error("Failed to create user"); } // Create organization const [org] = await trx .insertInto("orgs") .values({ slug: orgSlug, display_name: orgDisplayName, }) .returning(["id", "slug"]) .execute(); if (!org) { throw new Error("Failed to create organization"); } // Add user as owner of the org await trx .insertInto("org_members") .values({ org_id: org.id, user_id: user.id, role: "owner", }) .execute(); // Generate API token const token = generateToken(); const tokenHashValue = hashToken(token); await trx .insertInto("api_tokens") .values({ user_id: user.id, token_hash: tokenHashValue, name: tokenName, expires_at: new Date( Date.now() + tokenExpirationDays * 24 * 60 * 60 * 1000, ), }) .execute(); return { user: { id: user.id, email: user.email, }, org: { id: org.id, slug: org.slug, }, token, }; };