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 };};
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 };
};