feat(setup): Stage 1 first-time setup wizard backend (no UI)
Task #534 — backend, infra, and tooling only. UI ships in Stage 2. Backend - New system_settings table (id=1 singleton): installed flag, base_url, local_domain, local_ip, https_mode, app_version. Pushed to dev DB. - New /api/setup/status (open) and /api/setup/{validate,complete} (gated by requireSetupOpen — 409 once installed). - completeInstall is fully transactional: pg_advisory_xact_lock serializes concurrent callers, double-gates on installed flag and admin existence, then atomically creates the admin user, assigns admin role + Admins/Everyone groups, and flips system_settings to installed=true. Rolls back on any failure. - Zod validation, bcrypt hashing, in-memory rate limiter for the setup endpoints. Backward compat - scripts/src/seed.ts now branches on installed flag + admin existence + SEED_*_PASSWORD env vars. Legacy installs (admin exists, system_settings empty) get backfilled to installed=true via ON CONFLICT DO UPDATE so they are never forced through the wizard. When env passwords are unset and no admin exists, the seed prints a wizard hint instead of seeding. Infra - docker-compose.yml: replaced nginx edge with a Caddy service that mounts ./certs and ./docker/Caddyfile. The web service no longer publishes a port directly — Caddy is the only public ingress. - docker/Caddyfile: HTTPS site for LOCAL_DOMAIN/LOCAL_IP with WebSocket upgrade preserved and a plaintext :80 fallback when HTTPS_MODE=skip (dev-only). - .env.example: added LOCAL_DOMAIN, LOCAL_IP, BASE_URL, HTTP_PORT, HTTPS_PORT, HTTPS_MODE; SEED_*_PASSWORD now optional. Tooling - scripts/local-setup.sh: idempotent OS-aware bootstrap (.env upsert, mkcert hint, cert SAN check, dry-run via LOCAL_SETUP_DRY_RUN). start.sh untouched. Tests - artifacts/api-server/tests/setup-wizard.test.mjs: 7/7 pass (snapshot/restore admin role + system_settings around tests). - scripts/tests/local-setup.test.mjs: 2/2 pass (first-run bootstrap + second-run no-op idempotency with mkcert/openssl stubs). Constraints honored: no force-push, no destructive ops, start.sh preserved, scripts idempotent, volumes/DB never touched, HTTPS skip mode dev-only, wizard does not edit LOCAL_DOMAIN/LOCAL_IP. Out of scope / not addressed: pre-existing TS errors in routes/users.ts and pre-existing failure in executive-meetings-postpone-race.test.mjs.
This commit is contained in:
+112
-45
@@ -14,11 +14,12 @@ import {
|
||||
userGroupsTable,
|
||||
groupAppsTable,
|
||||
groupRolesTable,
|
||||
systemSettingsTable,
|
||||
executiveMeetingsTable,
|
||||
executiveMeetingAttendeesTable,
|
||||
executiveMeetingNotificationsTable,
|
||||
} from "@workspace/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
async function main() {
|
||||
@@ -89,54 +90,120 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create admin user. Passwords are read exclusively from env vars so
|
||||
// credentials are never committed and never appear in source. Both vars
|
||||
// are required in every environment (including local dev) — copy them
|
||||
// into a local `.env` from `.env.example` before running the seed.
|
||||
// ---------------------------------------------------------------------
|
||||
// First-run install state — defer admin creation to the Setup Wizard
|
||||
// when no admin exists yet AND the operator hasn't pre-seeded passwords
|
||||
// via environment variables.
|
||||
//
|
||||
// Behaviour matrix:
|
||||
// installed=true OR admin exists → seed roles/permissions only
|
||||
// (re-runs in CI / migrations stay
|
||||
// green; never overwrite a real
|
||||
// admin row)
|
||||
// installed=false, no admin, env → behave as before: create the
|
||||
// seeded admin + sample user, flip
|
||||
// installed=true so the wizard does
|
||||
// not appear
|
||||
// installed=false, no admin, no
|
||||
// env → seed roles/permissions only,
|
||||
// leave admin creation to the
|
||||
// wizard. Print a clear log line.
|
||||
// ---------------------------------------------------------------------
|
||||
const adminPassword = process.env.SEED_ADMIN_PASSWORD;
|
||||
const userPassword = process.env.SEED_USER_PASSWORD;
|
||||
if (!adminPassword) {
|
||||
throw new Error(
|
||||
"SEED_ADMIN_PASSWORD must be set (see .env.example). Refusing to seed.",
|
||||
);
|
||||
}
|
||||
if (!userPassword) {
|
||||
throw new Error(
|
||||
"SEED_USER_PASSWORD must be set (see .env.example). Refusing to seed.",
|
||||
);
|
||||
}
|
||||
const adminHash = await bcrypt.hash(adminPassword, 10);
|
||||
const [adminUser] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: "admin",
|
||||
email: "admin@tx.local",
|
||||
passwordHash: adminHash,
|
||||
displayNameAr: "مدير النظام",
|
||||
displayNameEn: "System Admin",
|
||||
preferredLanguage: "ar",
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
// Create regular user
|
||||
const userHash = await bcrypt.hash(userPassword, 10);
|
||||
const [regularUser] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: "ahmed",
|
||||
email: "ahmed@tx.local",
|
||||
passwordHash: userHash,
|
||||
displayNameAr: "أحمد محمد",
|
||||
displayNameEn: "Ahmed Mohammed",
|
||||
preferredLanguage: "ar",
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
// Detect existing install state. The system_settings row may not exist
|
||||
// yet on a brand-new DB; treat "no row" as installed=false.
|
||||
const sysRows = await db.select().from(systemSettingsTable).limit(1);
|
||||
const installedFlag = sysRows[0]?.installed ?? false;
|
||||
|
||||
console.log("Users created");
|
||||
// Backfill: if any admin already exists but system_settings is empty
|
||||
// (legacy installs from before this column shipped), upsert the row
|
||||
// with installed=true so those operators are never forced through the
|
||||
// wizard.
|
||||
const existingAdminRows = await db
|
||||
.select({ id: usersTable.id })
|
||||
.from(userRolesTable)
|
||||
.innerJoin(usersTable, eq(usersTable.id, userRolesTable.userId))
|
||||
.innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId))
|
||||
.where(eq(rolesTable.name, "admin"))
|
||||
.limit(1);
|
||||
const adminAlreadyExists = existingAdminRows.length > 0;
|
||||
|
||||
if (adminAlreadyExists && !installedFlag) {
|
||||
// Use DO UPDATE so a stale id=1 row with installed=false (e.g. if a
|
||||
// half-finished wizard run inserted the row first) is corrected to
|
||||
// installed=true. Preserve installed_at if already set.
|
||||
await db
|
||||
.insert(systemSettingsTable)
|
||||
.values({ id: 1, installed: true, installedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettingsTable.id,
|
||||
set: {
|
||||
installed: true,
|
||||
installedAt: sql`COALESCE(${systemSettingsTable.installedAt}, NOW())`,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
console.log("Backfilled system_settings.installed=true for existing admin");
|
||||
}
|
||||
|
||||
let adminUser: { id: number } | undefined;
|
||||
let regularUser: { id: number } | undefined;
|
||||
|
||||
const skipUserCreation = installedFlag || adminAlreadyExists;
|
||||
const haveSeedEnv = Boolean(adminPassword && userPassword);
|
||||
|
||||
if (skipUserCreation) {
|
||||
console.log(
|
||||
"Skipping seeded admin/user — system already installed or an admin already exists.",
|
||||
);
|
||||
} else if (!haveSeedEnv) {
|
||||
const host = process.env.PUBLIC_BASE_URL ?? "https://<host>";
|
||||
console.log(
|
||||
`[seed] No SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD set — first-run wizard required at ${host}/setup`,
|
||||
);
|
||||
} else {
|
||||
const adminHash = await bcrypt.hash(adminPassword!, 10);
|
||||
const insertedAdmin = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: "admin",
|
||||
email: "admin@tx.local",
|
||||
passwordHash: adminHash,
|
||||
displayNameAr: "مدير النظام",
|
||||
displayNameEn: "System Admin",
|
||||
preferredLanguage: "ar",
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
adminUser = insertedAdmin[0];
|
||||
|
||||
const userHash = await bcrypt.hash(userPassword!, 10);
|
||||
const insertedRegular = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
username: "ahmed",
|
||||
email: "ahmed@tx.local",
|
||||
passwordHash: userHash,
|
||||
displayNameAr: "أحمد محمد",
|
||||
displayNameEn: "Ahmed Mohammed",
|
||||
preferredLanguage: "ar",
|
||||
isActive: true,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
regularUser = insertedRegular[0];
|
||||
|
||||
// Mark install complete so the wizard does not appear.
|
||||
await db
|
||||
.insert(systemSettingsTable)
|
||||
.values({ id: 1, installed: true, installedAt: new Date() })
|
||||
.onConflictDoNothing();
|
||||
|
||||
console.log("Users created");
|
||||
}
|
||||
|
||||
// Assign roles
|
||||
const roles = await db.select().from(rolesTable);
|
||||
|
||||
Reference in New Issue
Block a user