Files
TX/artifacts/api-server/src/lib/setupService.ts
T
Riyadh 38e31d633c feat(setup): Stage 1 first-time setup wizard backend (no UI)
Task #534 — backend, infra, tooling. UI ships in Stage 2 (#535).

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.
- Added redirectIfSetupNeeded() helper returning the full SetupStatus
  payload alongside a redirect target for SPA routing decisions.
- Zod validation, bcrypt hashing, in-memory rate limiter on 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{,.skip}. The web service no
  longer publishes a port directly — Caddy is the only public ingress.
- Caddy entrypoint picks Caddyfile.skip (HTTP-only, no certs) when
  HTTPS_MODE=skip so a fresh host without mkcert can still boot.
- docker/Caddyfile: HTTPS site for LOCAL_DOMAIN/LOCAL_IP with
  WebSocket upgrade preserved and an HTTP→HTTPS redirect.
- start.sh: preserved. Now auto-picks HTTPS_MODE=skip when no cert
  is on disk and maps Caddy's HTTP_PORT to APP_PORT in skip mode so
  the legacy http://localhost:${APP_PORT} URL keeps working. In
  local/byo mode it prints the https://${LOCAL_DOMAIN} URL.
- .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).

Tests
- artifacts/api-server/tests/setup-wizard.test.mjs: 7/7 pass.
- scripts/tests/local-setup.test.mjs: 2/2 pass.

Constraints honored: no force-push, no destructive ops, start.sh
preserved & still works, 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.
2026-05-14 07:59:50 +00:00

413 lines
13 KiB
TypeScript

import bcrypt from "bcryptjs";
import { eq } from "drizzle-orm";
import { z } from "zod/v4";
import { db, pool } from "@workspace/db";
import {
systemSettingsTable,
usersTable,
rolesTable,
userRolesTable,
} from "@workspace/db";
// Read app version from package.json at startup. Falls back to "0.0.0".
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
let cachedVersion: string | null = null;
function readAppVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const here = path.dirname(fileURLToPath(import.meta.url));
// Walk up from dist/ or src/ to the api-server package.json.
for (const candidate of [
path.resolve(here, "../../package.json"),
path.resolve(here, "../package.json"),
path.resolve(process.cwd(), "package.json"),
]) {
try {
const raw = readFileSync(candidate, "utf8");
const parsed = JSON.parse(raw) as { version?: string };
if (parsed.version) {
cachedVersion = parsed.version;
return cachedVersion;
}
} catch {
// try next candidate
}
}
} catch {
// ignore
}
cachedVersion = "0.0.0";
return cachedVersion;
}
export const SetupCompleteBody = z.object({
admin: z.object({
username: z
.string()
.min(3)
.max(50)
.regex(/^[a-zA-Z0-9_.-]+$/, "username_invalid"),
email: z.string().email().max(255),
password: z.string().min(8).max(200),
displayNameAr: z.string().min(1).max(200).optional(),
displayNameEn: z.string().min(1).max(200).optional(),
}),
baseUrl: z.string().url().optional(),
localDomain: z.string().min(1).max(200).optional(),
localIp: z.string().min(1).max(64).optional(),
httpsMode: z.enum(["local", "byo", "skip"]).default("local").optional(),
});
export type SetupCompleteInput = z.infer<typeof SetupCompleteBody>;
export const SetupValidateBody = SetupCompleteBody.partial({
admin: true,
}).extend({
admin: SetupCompleteBody.shape.admin.partial(),
});
export type SetupStatus = {
installed: boolean;
setupRequired: boolean;
appVersion: string;
checks: {
db: "ok" | "error";
storage: "ok" | "unknown";
https: "ok" | "skip" | "unknown";
baseUrl: string | null;
};
};
async function getSystemSettings(): Promise<{
installed: boolean;
baseUrl: string | null;
httpsMode: string;
} | null> {
const rows = await db.select().from(systemSettingsTable).limit(1);
const row = rows[0];
if (!row) return null;
return {
installed: row.installed,
baseUrl: row.baseUrl,
httpsMode: row.httpsMode,
};
}
async function adminExists(): Promise<boolean> {
const rows = 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);
return rows.length > 0;
}
export async function readStatus(): Promise<SetupStatus> {
let dbCheck: "ok" | "error" = "ok";
let installed = false;
let baseUrl: string | null = null;
let httpsMode = process.env.HTTPS_MODE ?? "local";
try {
const sys = await getSystemSettings();
if (sys) {
installed = sys.installed;
baseUrl = sys.baseUrl;
httpsMode = sys.httpsMode;
}
} catch {
dbCheck = "error";
}
let setupRequired = false;
if (dbCheck === "ok") {
if (installed) {
setupRequired = false;
} else {
try {
setupRequired = !(await adminExists());
} catch {
dbCheck = "error";
setupRequired = false;
}
}
}
const storage =
process.env.STORAGE_DRIVER === "local" || process.env.STORAGE_DRIVER === "s3"
? "ok"
: "unknown";
const https =
httpsMode === "skip"
? "skip"
: (process.env.PUBLIC_BASE_URL ?? "").startsWith("https://")
? "ok"
: "unknown";
return {
installed,
setupRequired,
appVersion: readAppVersion(),
checks: {
db: dbCheck,
storage,
https,
baseUrl: baseUrl ?? process.env.PUBLIC_BASE_URL ?? null,
},
};
}
export type ValidationErrors = Record<string, string>;
export async function validateProposed(
input: unknown,
): Promise<{ ok: true } | { ok: false; errors: ValidationErrors }> {
const parsed = SetupValidateBody.safeParse(input);
if (!parsed.success) {
const errors: ValidationErrors = {};
for (const issue of parsed.error.issues) {
errors[issue.path.join(".") || "_"] = issue.message;
}
return { ok: false, errors };
}
const data = parsed.data;
const errors: ValidationErrors = {};
if (data.admin?.username) {
const existing = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.username, data.admin.username))
.limit(1);
if (existing.length > 0) errors["admin.username"] = "username_taken";
}
if (data.admin?.email) {
const existing = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(eq(usersTable.email, data.admin.email))
.limit(1);
if (existing.length > 0) errors["admin.email"] = "email_taken";
}
if (Object.keys(errors).length > 0) return { ok: false, errors };
return { ok: true };
}
export type CompleteResult =
| { ok: true; userId: number }
| { ok: false; status: 409 | 400; error: string; errors?: ValidationErrors };
export async function completeInstall(
rawInput: unknown,
): Promise<CompleteResult> {
const parsed = SetupCompleteBody.safeParse(rawInput);
if (!parsed.success) {
const errors: ValidationErrors = {};
for (const issue of parsed.error.issues) {
errors[issue.path.join(".") || "_"] = issue.message;
}
return { ok: false, status: 400, error: "validation_failed", errors };
}
const input = parsed.data;
const passwordHash = await bcrypt.hash(input.admin.password, 10);
// Wrap the whole flow in a single SQL transaction so that admin
// creation + system_settings flip + role/group assignments either
// all succeed or all roll back.
const client = await pool.connect();
try {
await client.query("BEGIN");
// Serialize concurrent /api/setup/complete callers. Without this two
// parallel requests could both observe installed=false / no admin and
// both insert distinct first admins. The advisory lock is held until
// COMMIT/ROLLBACK so the second caller blocks, then re-reads the
// gates below and bails with 409.
// Constant lock key dedicated to first-time setup; arbitrary but stable.
await client.query(`SELECT pg_advisory_xact_lock(8473123001)`);
// Re-check both gates inside the txn so a parallel request can't
// sneak past the open setup window.
const sysRows = await client.query<{
installed: boolean;
}>(`SELECT installed FROM system_settings WHERE id = 1 LIMIT 1`);
if (sysRows.rows[0]?.installed) {
await client.query("ROLLBACK");
return { ok: false, status: 409, error: "already_installed" };
}
const adminCount = await client.query<{ id: number }>(
`SELECT u.id FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'admin' LIMIT 1`,
);
if ((adminCount.rowCount ?? 0) > 0) {
await client.query("ROLLBACK");
return { ok: false, status: 409, error: "already_installed" };
}
// Username/email uniqueness re-check inside the txn.
const dup = await client.query<{ id: number }>(
`SELECT id FROM users WHERE username = $1 OR email = $2 LIMIT 1`,
[input.admin.username, input.admin.email],
);
if ((dup.rowCount ?? 0) > 0) {
await client.query("ROLLBACK");
return {
ok: false,
status: 400,
error: "validation_failed",
errors: { "admin.username": "taken_or_email_taken" },
};
}
// Ensure the admin role exists (it normally does after seed).
await client.query(
`INSERT INTO roles (name, description_ar, description_en, is_system)
VALUES ('admin', 'مدير النظام', 'System Administrator', 1)
ON CONFLICT (name) DO NOTHING`,
);
const roleRow = await client.query<{ id: number }>(
`SELECT id FROM roles WHERE name = 'admin' LIMIT 1`,
);
const adminRoleId = roleRow.rows[0]?.id;
if (!adminRoleId) {
await client.query("ROLLBACK");
return { ok: false, status: 400, error: "admin_role_missing" };
}
const inserted = await client.query<{ id: number }>(
`INSERT INTO users
(username, email, password_hash, display_name_ar, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, $5, 'ar', true)
RETURNING id`,
[
input.admin.username,
input.admin.email,
passwordHash,
input.admin.displayNameAr ?? "مدير النظام",
input.admin.displayNameEn ?? "System Admin",
],
);
const newUserId = inserted.rows[0]!.id;
await client.query(
`INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[newUserId, adminRoleId],
);
// Best-effort: also map into Admins + Everyone groups if they exist.
await client.query(
`INSERT INTO user_groups (user_id, group_id)
SELECT $1, id FROM groups WHERE name IN ('Admins','Everyone')
ON CONFLICT DO NOTHING`,
[newUserId],
);
// Upsert system_settings row → installed=true.
await client.query(
`INSERT INTO system_settings
(id, installed, installed_at, base_url, local_domain, local_ip, https_mode, app_version, updated_at)
VALUES (1, true, NOW(), $1, $2, $3, $4, $5, NOW())
ON CONFLICT (id) DO UPDATE SET
installed = true,
installed_at = COALESCE(system_settings.installed_at, NOW()),
base_url = EXCLUDED.base_url,
local_domain = EXCLUDED.local_domain,
local_ip = EXCLUDED.local_ip,
https_mode = EXCLUDED.https_mode,
app_version = EXCLUDED.app_version,
updated_at = NOW()`,
[
input.baseUrl ?? process.env.PUBLIC_BASE_URL ?? null,
input.localDomain ?? process.env.LOCAL_DOMAIN ?? null,
input.localIp ?? process.env.LOCAL_IP ?? null,
input.httpsMode ?? process.env.HTTPS_MODE ?? "local",
readAppVersion(),
],
);
await client.query("COMMIT");
return { ok: true, userId: newUserId };
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
/* ignore */
}
throw err;
} finally {
client.release();
}
}
// Bootstrap idempotently called from index.ts at server start. Ensures
// system_settings(id=1) exists and backfills installed=true for legacy
// installs where an admin already exists. Migration-safe: runs on every
// boot regardless of whether the seed script was invoked.
export async function ensureSystemSettingsBootstrap(): Promise<void> {
const client = await pool.connect();
try {
await client.query(
`INSERT INTO system_settings (id, installed)
VALUES (1, false)
ON CONFLICT (id) DO NOTHING`,
);
const sys = await client.query<{ installed: boolean }>(
`SELECT installed FROM system_settings WHERE id = 1 LIMIT 1`,
);
if (sys.rows[0]?.installed) return;
const adminCount = await client.query<{ id: number }>(
`SELECT u.id FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE r.name = 'admin' LIMIT 1`,
);
if ((adminCount.rowCount ?? 0) > 0) {
await client.query(
`UPDATE system_settings
SET installed = true,
installed_at = COALESCE(installed_at, NOW()),
updated_at = NOW()
WHERE id = 1`,
);
}
} finally {
client.release();
}
}
// Should the SPA router redirect to /setup? Mirrors readStatus().
export async function isSetupOpen(): Promise<boolean> {
try {
const sys = await getSystemSettings();
if (sys?.installed) return false;
return !(await adminExists());
} catch {
return false;
}
}
// Returns the full SetupStatus + a single redirect decision so callers
// don't have to make two requests.
export async function redirectIfSetupNeeded(): Promise<{
shouldRedirect: boolean;
target: "/setup" | null;
status: SetupStatus;
}> {
const status = await readStatus();
const shouldRedirect =
status.checks.db === "ok" && !status.installed && status.setupRequired;
return {
shouldRedirect,
target: shouldRedirect ? "/setup" : null,
status,
};
}
// Exported for use by tests; unused at runtime.
export const _internal = { adminExists, getSystemSettings, readAppVersion };