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:
riyadhafraa
2026-05-14 07:32:58 +00:00
parent 7932b3f1db
commit a3ebff2afa
13 changed files with 1365 additions and 53 deletions
+112 -45
View File
@@ -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);