Ctrl K
ComunidadGuías…

src/lib/bootstrap.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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.");
  }
}