38e31d633c
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.
413 lines
13 KiB
TypeScript
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 };
|