diff --git a/.env.example b/.env.example index 67b4e98..ff96e1a 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ -DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres \ No newline at end of file +DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres + +# Optional: first admin for an empty instance. `bun run db:seed` creates an admin only when no user has role=admin yet (same username/password rules as registration). +BOOTSTRAP_ADMIN_USERNAME= +BOOTSTRAP_ADMIN_PASSWORD= \ No newline at end of file diff --git a/packages/drizzle-pkg/seed.ts b/packages/drizzle-pkg/seed.ts index 6ed7f28..169241b 100644 --- a/packages/drizzle-pkg/seed.ts +++ b/packages/drizzle-pkg/seed.ts @@ -1,25 +1,86 @@ -import './env'; -import { seed } from "drizzle-seed"; -import { usersTable } from "./lib/schema/auth"; +import "./env"; +import { hash } from "bcryptjs"; +import { eq, sql } from "drizzle-orm"; import { dbGlobal } from "./lib/db"; +import { users } from "./lib/schema/auth"; + +/** Match `server/service/auth/index.ts` */ +const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/; +const MIN_PASSWORD_LENGTH = 6; + +/** + * Public slug is optional: use lowercased username only if it satisfies a URL-style slug + * (lowercase alphanumeric only, 3–20 chars). Usernames with underscores or mixed rules + * that do not fit stay null; the admin can set `publicSlug` later via profile APIs. + */ +const PUBLIC_SLUG_REGEX = /^[a-z0-9]{3,20}$/; + +function derivePublicSlug(username: string): string | null { + const lower = username.toLowerCase(); + return PUBLIC_SLUG_REGEX.test(lower) ? lower : null; +} + +async function getNextUserId() { + const [row] = await dbGlobal + .select({ + maxId: sql`COALESCE(MAX(${users.id}), 0)`, + }) + .from(users); + return (row?.maxId ?? 0) + 1; +} async function main() { - await seed(dbGlobal, { usersTable }).refine((f) => ({ - usersTable: { - columns: { - name: f.fullName(), - age: f.int({ minValue: 18, maxValue: 60 }), - email: f.email(), - }, - count: 10, - }, - })); - console.log('Seed complete!'); + const [existingAdmin] = await dbGlobal + .select({ id: users.id }) + .from(users) + .where(eq(users.role, "admin")) + .limit(1); + + if (existingAdmin) { + console.log("Bootstrap skipped: admin exists"); process.exit(0); + } + + const username = process.env.BOOTSTRAP_ADMIN_USERNAME; + const password = process.env.BOOTSTRAP_ADMIN_PASSWORD; + + if (!username || !password) { + console.warn( + "Bootstrap skipped: set BOOTSTRAP_ADMIN_USERNAME and BOOTSTRAP_ADMIN_PASSWORD", + ); + process.exit(0); + } + + if (!USERNAME_REGEX.test(username) || password.length < MIN_PASSWORD_LENGTH) { + console.warn( + "Bootstrap skipped: invalid BOOTSTRAP_ADMIN_USERNAME or BOOTSTRAP_ADMIN_PASSWORD", + ); + process.exit(0); + } + + const passwordHash = await hash(password, 10); + const userId = await getNextUserId(); + const publicSlug = derivePublicSlug(username); + + try { + await dbGlobal.insert(users).values({ + id: userId, + username, + password: passwordHash, + role: "admin", + status: "active", + publicSlug, + }); + console.log("Bootstrap complete: admin user created"); + } catch (err) { + console.warn("Bootstrap skipped: could not insert admin (unique conflict or DB error)", err); + process.exit(0); + } + + process.exit(0); } -main().catch(e => { - console.error(e); - process.exit(1); +main().catch((e) => { + console.error(e); + process.exit(1); }); -