89 lines · 4.2 KB · TypeScript
RawDownload
import { randomBytes } from "node:crypto"; import type { Client } from "@libsql/client"; import type { SelfHostConfig } from "../config";import type { Auth } from "./better-auth"; // ---------------------------------------------------------------------------// Idempotent first-boot bootstrap: ensure the single organization and a// bootstrap admin exist. Uses server-side auth.api calls (no session, no CLI)// and queries the freshly-migrated Better Auth tables directly (through// SelfHostDb's libSQL client — the SAME file Better Auth migrated, proving the// cross-connection invariant) to stay idempotent across restarts. Returns the// resolved org id/name, which the session-pin hook and the AuthProvider's// org-name cache read.// --------------------------------------------------------------------------- export const seedOrgAndAdmin = async (  auth: Auth,  client: Client,  config: SelfHostConfig,): Promise<{ organizationId: string; organizationName: string }> => {  // Idempotent: once the single organization exists, boot is past first-run.  // This instance is SINGLE-org, so adopt whatever organization exists rather  // than looking it up by slug — matching on slug would silently create a  // second org (forking the instance) the first boot after EXECUTOR_ORG_SLUG  // changes. A changed slug is a rename of the one org, applied here.  // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT columns are the schema contract for the Better Auth `organization` row read off the libSQL client  const existingOrg = (    await client.execute({      sql: "SELECT id, name, slug FROM organization ORDER BY createdAt ASC LIMIT 1",      args: [],    })  ).rows[0] as unknown as { id: string; name: string; slug: string } | undefined;  if (existingOrg) {    if (existingOrg.slug !== config.orgSlug) {      await client.execute({        sql: "UPDATE organization SET slug = ? WHERE id = ?",        args: [config.orgSlug, existingOrg.id],      });    }    return { organizationId: existingOrg.id, organizationName: existingOrg.name };  }   // Headless bootstrap: when BOTH admin email and password are set, pre-create  // that admin as the org owner (CI / infra-as-code). Otherwise fall through to  // the turnkey path so the first browser visitor claims the instance.  if (config.bootstrapAdminEmail && config.bootstrapAdminPassword) {    // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT column is the schema contract for the Better Auth `user` row read off the libSQL client    const existingUser = (      await client.execute({        sql: "SELECT id FROM user WHERE email = ?",        args: [config.bootstrapAdminEmail],      })    ).rows[0] as unknown as { id: string } | undefined;    let adminId = existingUser?.id;    if (!adminId) {      const created = await auth.api.createUser({        body: {          email: config.bootstrapAdminEmail,          password: config.bootstrapAdminPassword,          name: config.bootstrapAdminName,          role: "admin",        },      });      adminId = created.user.id;    }    // Pass userId so the org is created with no session and the admin becomes    // its owner (creates the membership row).    const org = await auth.api.createOrganization({      body: { name: config.organizationName, slug: config.orgSlug, userId: adminId },    });    if (!org) {      // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: org creation must succeed for a usable instance      throw new Error("Failed to create the bootstrap organization");    }    return { organizationId: org.id, organizationName: config.organizationName };  }   // Turnkey first-run: create the single organization with NO members. The  // first person to open the app signs up ungated and becomes the owner (the  // signup gate enforces this — an org with zero members is unclaimed).  const organizationId = randomBytes(16).toString("hex");  await client.execute({    sql: "INSERT INTO organization (id, name, slug, createdAt) VALUES (?, ?, ?, ?)",    args: [organizationId, config.organizationName, config.orgSlug, new Date().toISOString()],  });  return { organizationId, organizationName: config.organizationName };};