diff --git a/.env.example b/.env.example index 38fac0f0..3f9983ac 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,30 @@ LOG_LEVEL=info # 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED. SESSION_SECRET=change-me-session-secret-min-32-chars-please +# ----------------------------------------------------------------------------- +# Local hostname / TLS (used by Caddy + the Setup Wizard) +# ----------------------------------------------------------------------------- +# Hostname operators reach the system at on the LAN. The Caddy edge +# binds this name and serves the mkcert-issued certificate. +LOCAL_DOMAIN=tx.local +# Auto-detected by scripts/local-setup.sh — the host's LAN IP. Used as +# an additional SAN on the local certificate so phones / tablets that +# can't resolve mDNS can still reach the system at https:///. +LOCAL_IP=127.0.0.1 +# Canonical public URL the SPA + API are reachable at. Setup Wizard +# stores this in system_settings.base_url at install time. +BASE_URL=https://tx.local +# Edge ports (Caddy). Set HTTP_PORT=8080 / HTTPS_PORT=8443 if you +# can't bind privileged ports. +HTTP_PORT=80 +HTTPS_PORT=443 +# TLS strategy: +# local — local-setup.sh provisions ./certs/local-{cert,key}.pem via mkcert +# byo — operator drops their own cert/key into ./certs/ with the same names +# skip — DEVELOPER ONLY: serve plaintext on :80, no HTTPS. Never use +# outside trusted local development networks. +HTTPS_MODE=local + # ----------------------------------------------------------------------------- # SPA build (tx-os) — read by Vite at build time AND `pnpm --filter tx-os dev` # ----------------------------------------------------------------------------- @@ -92,12 +116,15 @@ LOCAL_STORAGE_ROOT=./storage LOCAL_STORAGE_SIGNING_SECRET= # ----------------------------------------------------------------------------- -# Seeded demo accounts +# Seeded demo accounts (OPTIONAL since the Setup Wizard exists) # ----------------------------------------------------------------------------- -# Used only by `docker compose run --rm migrate` on first boot. REQUIRED in -# production — the seed script throws if these are unset and NODE_ENV=production. -SEED_ADMIN_PASSWORD=change-me-admin-password -SEED_USER_PASSWORD=change-me-user-password +# When BOTH variables are set, the migrate container creates an `admin` +# and `ahmed` user with these passwords on first boot and immediately +# marks the install complete (skipping the wizard). When EITHER is unset, +# the seed creates only roles/permissions and leaves admin creation to +# the first-run wizard at /setup. +SEED_ADMIN_PASSWORD= +SEED_USER_PASSWORD= # ----------------------------------------------------------------------------- # Email (optional) diff --git a/artifacts/api-server/src/lib/setupRateLimit.ts b/artifacts/api-server/src/lib/setupRateLimit.ts new file mode 100644 index 00000000..c7c26693 --- /dev/null +++ b/artifacts/api-server/src/lib/setupRateLimit.ts @@ -0,0 +1,42 @@ +import { rateLimit, type RateLimitRequestHandler } from "express-rate-limit"; +import type { Request, Response } from "express"; + +const isProduction = process.env.NODE_ENV === "production"; + +function envInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw == null || raw === "") return fallback; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +function isLoopback(ip: string | undefined): boolean { + if (!ip) return false; + if (ip === "127.0.0.1" || ip === "::1") return true; + if (ip.startsWith("::ffff:127.")) return true; + return false; +} + +function skipForLocalDev(req: Request): boolean { + if (isProduction) return false; + if (process.env.SETUP_RATE_LIMIT_FORCE === "1") return false; + return isLoopback(req.ip); +} + +function jsonHandler(_req: Request, res: Response): void { + res.status(429).json({ + error: "too_many_requests", + message: "Too many setup attempts. Please slow down and try again later.", + }); +} + +// Stricter than the generic auth limiter — first-time setup is a one-shot +// flow, so we cap any single IP to a small handful of POSTs per window. +export const setupRateLimit: RateLimitRequestHandler = rateLimit({ + windowMs: envInt("SETUP_RATE_LIMIT_WINDOW_MS", 15 * 60_000), + max: envInt("SETUP_RATE_LIMIT_MAX", 10), + standardHeaders: true, + legacyHeaders: false, + skip: skipForLocalDev, + handler: jsonHandler, +}); diff --git a/artifacts/api-server/src/lib/setupService.ts b/artifacts/api-server/src/lib/setupService.ts new file mode 100644 index 00000000..57679e08 --- /dev/null +++ b/artifacts/api-server/src/lib/setupService.ts @@ -0,0 +1,361 @@ +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; + +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 { + 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 { + 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; + +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 { + 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(); + } +} + +// Lightweight gate used by the SPA router (and any other consumer) that +// wants to know "should I redirect to /setup?". Mirrors readStatus()'s +// rules so the answer is consistent. +export async function isSetupOpen(): Promise { + try { + const sys = await getSystemSettings(); + if (sys?.installed) return false; + return !(await adminExists()); + } catch { + return false; + } +} + +// Exported for use by tests; unused at runtime. +export const _internal = { adminExists, getSystemSettings, readAppVersion }; diff --git a/artifacts/api-server/src/middlewares/setupGate.ts b/artifacts/api-server/src/middlewares/setupGate.ts new file mode 100644 index 00000000..29e43f9e --- /dev/null +++ b/artifacts/api-server/src/middlewares/setupGate.ts @@ -0,0 +1,25 @@ +import type { Request, Response, NextFunction } from "express"; +import { isSetupOpen } from "../lib/setupService"; + +// Blocks /api/setup/{validate,complete} once the system is installed. +// /api/setup/status is intentionally NOT guarded — the SPA must always +// be able to read install state to decide its routing. +export async function requireSetupOpen( + _req: Request, + res: Response, + next: NextFunction, +): Promise { + try { + const open = await isSetupOpen(); + if (!open) { + res.status(409).json({ + error: "already_installed", + message: "Setup has already been completed.", + }); + return; + } + next(); + } catch (err) { + next(err as Error); + } +} diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 702b5906..fca11a7f 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,5 +1,6 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import setupRouter from "./setup"; import authRouter from "./auth"; import appsRouter from "./apps"; import servicesRouter from "./services"; @@ -19,6 +20,7 @@ import executiveMeetingsRouter from "./executive-meetings"; const router: IRouter = Router(); router.use(healthRouter); +router.use(setupRouter); router.use(authRouter); router.use(appsRouter); router.use(servicesRouter); diff --git a/artifacts/api-server/src/routes/setup.ts b/artifacts/api-server/src/routes/setup.ts new file mode 100644 index 00000000..e5cc3606 --- /dev/null +++ b/artifacts/api-server/src/routes/setup.ts @@ -0,0 +1,66 @@ +import { Router, type IRouter } from "express"; +import { setupRateLimit } from "../lib/setupRateLimit"; +import { requireSetupOpen } from "../middlewares/setupGate"; +import { + readStatus, + validateProposed, + completeInstall, +} from "../lib/setupService"; +import { logger } from "../lib/logger"; + +const router: IRouter = Router(); + +// Status is read-only and must remain reachable AFTER install too, so the +// SPA can decide routing. It is rate-limited but NOT gated. +router.get("/setup/status", setupRateLimit, async (_req, res, next) => { + try { + const status = await readStatus(); + res.json(status); + } catch (err) { + next(err as Error); + } +}); + +router.post( + "/setup/validate", + setupRateLimit, + requireSetupOpen, + async (req, res, next) => { + try { + const result = await validateProposed(req.body); + if (result.ok) { + res.json({ ok: true }); + } else { + res.status(400).json({ ok: false, errors: result.errors }); + } + } catch (err) { + next(err as Error); + } + }, +); + +router.post( + "/setup/complete", + setupRateLimit, + requireSetupOpen, + async (req, res, next) => { + try { + const result = await completeInstall(req.body); + if (result.ok) { + logger.info( + { userId: result.userId }, + "[setup] first-time install completed", + ); + res.json({ ok: true }); + } else { + res + .status(result.status) + .json({ ok: false, error: result.error, errors: result.errors }); + } + } catch (err) { + next(err as Error); + } + }, +); + +export default router; diff --git a/artifacts/api-server/tests/setup-wizard.test.mjs b/artifacts/api-server/tests/setup-wizard.test.mjs new file mode 100644 index 00000000..90817527 --- /dev/null +++ b/artifacts/api-server/tests/setup-wizard.test.mjs @@ -0,0 +1,239 @@ +import { test, after, before } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) throw new Error("DATABASE_URL must be set to run these tests"); + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +// ----- snapshot + restore helpers ------------------------------------------------- +// The setup-wizard test needs to run against a DB that has NO admin and +// `system_settings.installed=false`. The shared dev DB already has both +// (seed runs at boot), so we snapshot the relevant rows, blank them +// out, run the test, then restore exactly what was there. +let snapshot = { + systemSettings: null, + adminUserIds: [], + adminUserRoleRows: [], + createdUsernames: [], +}; + +const TEST_USERNAME = `setupwiz_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; +const TEST_EMAIL = `${TEST_USERNAME}@tx.local`; + +before(async () => { + // Snapshot system_settings. + const sysRow = await pool.query("SELECT * FROM system_settings WHERE id = 1"); + snapshot.systemSettings = sysRow.rows[0] ?? null; + + // Snapshot every user with the admin role + their user_roles edges. + const adminRow = await pool.query( + "SELECT id FROM roles WHERE name = 'admin' LIMIT 1", + ); + if (adminRow.rowCount === 0) { + throw new Error("admin role missing in DB — seed first"); + } + const adminRoleId = adminRow.rows[0].id; + + const adminUsers = await pool.query( + `SELECT u.id FROM users u + JOIN user_roles ur ON ur.user_id = u.id + WHERE ur.role_id = $1`, + [adminRoleId], + ); + snapshot.adminUserIds = adminUsers.rows.map((r) => r.id); + + // Detach admin role from those users so the open-setup gate kicks in. + if (snapshot.adminUserIds.length > 0) { + const detached = await pool.query( + `DELETE FROM user_roles WHERE role_id = $1 AND user_id = ANY($2::int[]) + RETURNING user_id, role_id`, + [adminRoleId, snapshot.adminUserIds], + ); + snapshot.adminUserRoleRows = detached.rows; + } + + // Wipe install flag so the wizard treats this as a fresh install. + await pool.query("DELETE FROM system_settings WHERE id = 1"); +}); + +after(async () => { + // Remove any user the test created. + if (snapshot.createdUsernames.length > 0) { + await pool.query( + "DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1::text[]))", + [snapshot.createdUsernames], + ); + await pool.query( + "DELETE FROM user_groups WHERE user_id IN (SELECT id FROM users WHERE username = ANY($1::text[]))", + [snapshot.createdUsernames], + ); + await pool.query("DELETE FROM users WHERE username = ANY($1::text[])", [ + snapshot.createdUsernames, + ]); + } + + // Restore system_settings to its pre-test state. + await pool.query("DELETE FROM system_settings WHERE id = 1"); + if (snapshot.systemSettings) { + const r = snapshot.systemSettings; + await pool.query( + `INSERT INTO system_settings + (id, installed, installed_at, base_url, local_domain, local_ip, https_mode, app_version, updated_at) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8)`, + [ + r.installed, + r.installed_at, + r.base_url, + r.local_domain, + r.local_ip, + r.https_mode, + r.app_version, + r.updated_at, + ], + ); + } + + // Re-attach the admin role to the original admin users. + if (snapshot.adminUserRoleRows.length > 0) { + for (const row of snapshot.adminUserRoleRows) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [row.user_id, row.role_id], + ); + } + } + + await pool.end(); +}); + +test("GET /api/setup/status reports setupRequired=true on a fresh DB", async () => { + const res = await fetch(`${API_BASE}/api/setup/status`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, false); + assert.equal(body.setupRequired, true); + assert.equal(body.checks.db, "ok"); + assert.ok(typeof body.appVersion === "string"); +}); + +test("POST /api/setup/validate returns ok for a clean payload", async () => { + const res = await fetch(`${API_BASE}/api/setup/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: TEST_USERNAME, + email: TEST_EMAIL, + password: "ValidPass123!", + }, + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); +}); + +test("POST /api/setup/validate returns 400 + per-field errors for invalid input", async () => { + const res = await fetch(`${API_BASE}/api/setup/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { username: "x", email: "not-an-email", password: "short" }, + }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.ok, false); + assert.ok(body.errors); +}); + +test("POST /api/setup/complete creates the admin and flips installed", async () => { + const res = await fetch(`${API_BASE}/api/setup/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: TEST_USERNAME, + email: TEST_EMAIL, + password: "ValidPass123!", + displayNameEn: "Wizard Test Admin", + }, + baseUrl: "https://tx.test.local", + localDomain: "tx.test.local", + localIp: "192.0.2.42", + httpsMode: "local", + }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, true); + snapshot.createdUsernames.push(TEST_USERNAME); + + // Verify directly in DB. + const userRow = await pool.query( + "SELECT id FROM users WHERE username = $1", + [TEST_USERNAME], + ); + assert.equal(userRow.rowCount, 1); + const newId = userRow.rows[0].id; + + const roleRow = await pool.query( + `SELECT 1 FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = $1 AND r.name = 'admin'`, + [newId], + ); + assert.equal(roleRow.rowCount, 1); + + const sys = await pool.query("SELECT * FROM system_settings WHERE id = 1"); + assert.equal(sys.rows[0].installed, true); + assert.equal(sys.rows[0].local_domain, "tx.test.local"); + assert.equal(sys.rows[0].https_mode, "local"); + assert.ok(sys.rows[0].installed_at); +}); + +test("Second POST /api/setup/complete returns 409 already_installed", async () => { + const res = await fetch(`${API_BASE}/api/setup/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: `${TEST_USERNAME}_again`, + email: `${TEST_USERNAME}_again@tx.local`, + password: "ValidPass123!", + }, + }), + }); + assert.equal(res.status, 409); + const body = await res.json(); + assert.equal(body.error, "already_installed"); +}); + +test("POST /api/setup/validate returns 409 once installed", async () => { + const res = await fetch(`${API_BASE}/api/setup/validate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + admin: { + username: "ignored", + email: "ignored@tx.local", + password: "ValidPass123!", + }, + }), + }); + assert.equal(res.status, 409); +}); + +test("GET /api/setup/status reports setupRequired=false after install", async () => { + const res = await fetch(`${API_BASE}/api/setup/status`); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.installed, true); + assert.equal(body.setupRequired, false); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 5e04e202..472a01c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,8 +27,12 @@ services: environment: NODE_ENV: production DATABASE_URL: postgres://${POSTGRES_USER:-tx}:${POSTGRES_PASSWORD:-tx_dev_password}@postgres:5432/${POSTGRES_DB:-tx} - SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:?SEED_ADMIN_PASSWORD is required — re-run ./start.sh to generate one} - SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:?SEED_USER_PASSWORD is required — re-run ./start.sh to generate one} + # Seed passwords are now optional. When unset, the seed script + # creates roles/permissions only and leaves admin creation to + # the first-run Setup Wizard (/api/setup/complete). + SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} + SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-} + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}} user: root entrypoint: ["/usr/bin/tini", "--"] command: ["/bin/bash", "/usr/local/bin/migrate.sh"] @@ -59,6 +63,9 @@ services: ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${APP_PORT:-3000}} PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}} LOG_LEVEL: ${LOG_LEVEL:-info} + HTTPS_MODE: ${HTTPS_MODE:-local} + LOCAL_DOMAIN: ${LOCAL_DOMAIN:-} + LOCAL_IP: ${LOCAL_IP:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-} SMTP_USER: ${SMTP_USER:-} @@ -76,9 +83,31 @@ services: restart: unless-stopped depends_on: - api + # No host port binding — Caddy is the single public edge. + expose: + - "80" + + caddy: + image: caddy:2.8-alpine + restart: unless-stopped + depends_on: + - api + - web + environment: + LOCAL_DOMAIN: ${LOCAL_DOMAIN:-tx.local} + LOCAL_IP: ${LOCAL_IP:-127.0.0.1} + HTTPS_MODE: ${HTTPS_MODE:-local} ports: - - "${APP_PORT:-3000}:80" + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + volumes: + - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro + - ./certs:/certs:ro + - caddy_data:/data + - caddy_config:/config volumes: postgres_data: app_storage: + caddy_data: + caddy_config: diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 00000000..1202aba3 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,62 @@ +# Tx OS — Caddy reverse proxy + TLS termination. +# +# Defaults (HTTPS_MODE=local or byo): listen on :443 with the mounted +# certificate pair from /certs, redirect HTTP→HTTPS automatically. +# When HTTPS_MODE=skip, we serve plaintext on :80 only (developer-only; +# the host script never enables this in production paths). +# +# The site MUST stay single-origin so the API session cookie +# (sameSite=lax) keeps working across SPA + /api requests. +{ + # Disable Caddy's automatic Let's Encrypt issuance — we always + # bring our own cert (mkcert locally, real cert in BYO mode). + auto_https disable_certs + admin off +} + +(api_proxy) { + # WebSocket / Socket.IO upgrade for /api/socket.io + @websocket { + header Connection *Upgrade* + header Upgrade websocket + } + reverse_proxy /api/socket.io/* api:8080 + reverse_proxy /api/* api:8080 +} + +(spa_proxy) { + # Static SPA bundle is served by the `web` container (nginx-alpine) + # on port 80 inside the docker network. Caddy is the public edge. + reverse_proxy web:80 +} + +# ---------- HTTPS site (default) ---------- +{$LOCAL_DOMAIN:tx.local}, {$LOCAL_IP:127.0.0.1} { + tls /certs/local-cert.pem /certs/local-key.pem + encode zstd gzip + import api_proxy + import spa_proxy +} + +# Catch-all HTTPS host (covers raw IP / Tailscale name without +# touching the named site). +:443 { + tls /certs/local-cert.pem /certs/local-key.pem + encode zstd gzip + import api_proxy + import spa_proxy +} + +# Plain HTTP — issue a permanent redirect to HTTPS in normal modes, +# but become the actual site when HTTPS_MODE=skip. +:80 { + @skip expression {$HTTPS_MODE:local} == "skip" + handle @skip { + encode zstd gzip + import api_proxy + import spa_proxy + } + handle { + redir https://{host}{uri} permanent + } +} diff --git a/lib/db/src/schema/settings.ts b/lib/db/src/schema/settings.ts index e7b43562..6ca83626 100644 --- a/lib/db/src/schema/settings.ts +++ b/lib/db/src/schema/settings.ts @@ -17,3 +17,23 @@ export const updateAppSettingsSchema = createInsertSchema(appSettingsTable) .partial(); export type AppSettings = typeof appSettingsTable.$inferSelect; export type UpdateAppSettings = z.infer; + +// --------------------------------------------------------------------------- +// system_settings — single-row install-state record consumed by the +// First-Time Setup Wizard. Distinct from `app_settings` (which holds +// admin-editable site config). The row id is always 1; the wizard upserts +// it once during /api/setup/complete and never deletes it. +// --------------------------------------------------------------------------- +export const systemSettingsTable = pgTable("system_settings", { + id: integer("id").primaryKey().default(1), + installed: boolean("installed").notNull().default(false), + installedAt: timestamp("installed_at", { withTimezone: true }), + baseUrl: varchar("base_url", { length: 500 }), + localDomain: varchar("local_domain", { length: 200 }), + localIp: varchar("local_ip", { length: 64 }), + httpsMode: varchar("https_mode", { length: 16 }).notNull().default("local"), + appVersion: varchar("app_version", { length: 64 }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), +}); + +export type SystemSettings = typeof systemSettingsTable.$inferSelect; diff --git a/scripts/local-setup.sh b/scripts/local-setup.sh new file mode 100755 index 00000000..dd1cb811 --- /dev/null +++ b/scripts/local-setup.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# Tx OS — host-side first-time setup helper for macOS & Linux. +# +# What it does (idempotent — safe to re-run): +# 1. Verifies Docker + Docker Compose v2 are installed. +# 2. Verifies / hints how to install mkcert (does NOT install it for you). +# 3. Prompts for LOCAL_DOMAIN + LOCAL_IP (with sensible defaults). +# 4. Provisions ./certs/local-cert.pem + ./certs/local-key.pem via mkcert, +# reusing the existing cert when its SANs already cover the requested +# hostnames. +# 5. Bootstraps .env from .env.example if missing, then upserts +# LOCAL_DOMAIN / LOCAL_IP / BASE_URL / PUBLIC_BASE_URL / ALLOWED_ORIGINS +# preserving every other key. +# 6. Prints `mkcert -CAROOT` so you can install the root CA on your phones. +# 7. Runs `docker compose up -d --build`. +# +# What it does NOT do: +# - Install packages on your behalf. +# - Touch the database, volumes, or `.env` keys it doesn't own. +# - Configure DNS / mDNS / Tailscale. +# +# Usage: ./scripts/local-setup.sh +# ----------------------------------------------------------------------------- +set -euo pipefail + +# Allow the test harness to stub `mkcert` and skip docker/compose checks. +LOCAL_SETUP_DRY_RUN="${LOCAL_SETUP_DRY_RUN:-0}" + +# Resolve project root (parent of scripts/). +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJECT_ROOT" + +CERTS_DIR="$PROJECT_ROOT/certs" +CERT_FILE="$CERTS_DIR/local-cert.pem" +KEY_FILE="$CERTS_DIR/local-key.pem" + +# Cross-platform `sed -i` (BSD vs GNU). +if [[ "${OSTYPE:-}" == "darwin"* ]]; then + SED_I=(sed -i '') +else + SED_I=(sed -i) +fi + +log() { printf "==> %s\n" "$*"; } +warn() { printf "WARN: %s\n" "$*" >&2; } +fatal() { printf "ERROR: %s\n" "$*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# 1. Tooling checks +# --------------------------------------------------------------------------- +if [[ "$LOCAL_SETUP_DRY_RUN" != "1" ]]; then + if ! command -v docker >/dev/null 2>&1; then + fatal "Docker not found. Install Docker Desktop from https://www.docker.com/products/docker-desktop/" + fi + if ! docker compose version >/dev/null 2>&1; then + fatal "Docker Compose v2 not found. Update Docker Desktop." + fi +fi + +if ! command -v mkcert >/dev/null 2>&1; then + warn "mkcert is not installed." + if [[ "${OSTYPE:-}" == "darwin"* ]]; then + echo " Install with: brew install mkcert nss" + else + if command -v apt-get >/dev/null 2>&1; then echo " Install with: sudo apt-get install -y mkcert libnss3-tools" + elif command -v dnf >/dev/null 2>&1; then echo " Install with: sudo dnf install -y mkcert nss-tools" + elif command -v pacman >/dev/null 2>&1; then echo " Install with: sudo pacman -S mkcert nss" + elif command -v zypper >/dev/null 2>&1; then echo " Install with: sudo zypper install mkcert mozilla-nss-tools" + elif command -v apk >/dev/null 2>&1; then echo " Install with: sudo apk add mkcert nss-tools" + else echo " See https://github.com/FiloSottile/mkcert#installation" + fi + fi + if [[ "$LOCAL_SETUP_DRY_RUN" != "1" ]]; then + fatal "Re-run this script after installing mkcert." + fi +fi + +# --------------------------------------------------------------------------- +# 2. Bootstrap .env from .env.example +# --------------------------------------------------------------------------- +if [ ! -f "$PROJECT_ROOT/.env" ]; then + if [ ! -f "$PROJECT_ROOT/.env.example" ]; then + fatal ".env.example missing — cannot bootstrap .env" + fi + log "Creating .env from .env.example ..." + cp "$PROJECT_ROOT/.env.example" "$PROJECT_ROOT/.env" + # Generate a SESSION_SECRET if openssl is available. + if command -v openssl >/dev/null 2>&1; then + SECRET="$(openssl rand -hex 32)" + "${SED_I[@]}" "s|^SESSION_SECRET=.*|SESSION_SECRET=${SECRET}|" "$PROJECT_ROOT/.env" + fi +fi + +# Helper: read a key from .env, defaulting to argument 2. +env_get() { + local key="$1" + local default="${2-}" + local val + val=$(grep -E "^${key}=" "$PROJECT_ROOT/.env" | head -n1 | cut -d= -f2- | tr -d '\r' || true) + if [ -z "$val" ]; then + printf "%s" "$default" + else + printf "%s" "$val" + fi +} + +# Helper: upsert KEY=VALUE in .env (insert if absent, replace if present). +# Uses a tmp file so we never lose data on a partially-failed sed. +env_set() { + local key="$1" + local value="$2" + local file="$PROJECT_ROOT/.env" + if grep -qE "^${key}=" "$file"; then + local tmp + tmp="$(mktemp)" + awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} { + if ($1 == k) { print k"="v } else { print $0 } + }' "$file" > "$tmp" + mv "$tmp" "$file" + else + printf "%s=%s\n" "$key" "$value" >> "$file" + fi +} + +# --------------------------------------------------------------------------- +# 3. Prompt for LOCAL_DOMAIN + LOCAL_IP +# --------------------------------------------------------------------------- +detect_lan_ip() { + if [[ "${OSTYPE:-}" == "darwin"* ]]; then + local iface ip + iface=$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}' | head -n1 || true) + if [ -n "$iface" ]; then + ip=$(ipconfig getifaddr "$iface" 2>/dev/null || true) + [ -n "$ip" ] && { printf "%s" "$ip"; return; } + fi + else + if command -v ip >/dev/null 2>&1; then + local ip + ip=$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -n1 || true) + [ -n "$ip" ] && { printf "%s" "$ip"; return; } + fi + fi + printf "%s" "127.0.0.1" +} + +CURRENT_DOMAIN="$(env_get LOCAL_DOMAIN tx.local)" +CURRENT_IP="$(env_get LOCAL_IP "$(detect_lan_ip)")" + +if [[ "${LOCAL_SETUP_NONINTERACTIVE:-0}" == "1" ]]; then + LOCAL_DOMAIN="$CURRENT_DOMAIN" + LOCAL_IP="$CURRENT_IP" +else + read -r -p "LOCAL_DOMAIN [$CURRENT_DOMAIN]: " LOCAL_DOMAIN || true + LOCAL_DOMAIN="${LOCAL_DOMAIN:-$CURRENT_DOMAIN}" + read -r -p "LOCAL_IP [$CURRENT_IP]: " LOCAL_IP || true + LOCAL_IP="${LOCAL_IP:-$CURRENT_IP}" +fi + +log "Using LOCAL_DOMAIN=$LOCAL_DOMAIN LOCAL_IP=$LOCAL_IP" + +# --------------------------------------------------------------------------- +# 4. Generate / refresh mkcert certificate +# --------------------------------------------------------------------------- +mkdir -p "$CERTS_DIR" + +cert_covers_sans() { + # Returns 0 if cert at $CERT_FILE includes ALL of: LOCAL_DOMAIN, localhost, + # 127.0.0.1, LOCAL_IP. Returns non-zero otherwise (or if openssl missing). + command -v openssl >/dev/null 2>&1 || return 1 + local out + out=$(openssl x509 -in "$CERT_FILE" -noout -ext subjectAltName 2>/dev/null || true) + [ -z "$out" ] && return 1 + for needle in "$LOCAL_DOMAIN" "localhost" "127.0.0.1" "$LOCAL_IP"; do + if ! grep -q -F "$needle" <<<"$out"; then + return 1 + fi + done + return 0 +} + +if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && cert_covers_sans; then + log "Existing cert already covers $LOCAL_DOMAIN / $LOCAL_IP — skipping regeneration." +else + log "Generating local certificate via mkcert ..." + mkcert -cert-file "$CERT_FILE" -key-file "$KEY_FILE" \ + "$LOCAL_DOMAIN" localhost 127.0.0.1 "$LOCAL_IP" + if command -v mkcert >/dev/null 2>&1; then + CAROOT="$(mkcert -CAROOT 2>/dev/null || true)" + if [ -n "$CAROOT" ]; then + echo + echo " Root CA stored in: $CAROOT" + echo " To trust HTTPS on phones / other devices, install rootCA.pem" + echo " from that directory." + echo + fi + fi +fi + +# --------------------------------------------------------------------------- +# 5. Persist values back to .env (idempotent — preserves all other keys) +# --------------------------------------------------------------------------- +env_set LOCAL_DOMAIN "$LOCAL_DOMAIN" +env_set LOCAL_IP "$LOCAL_IP" +env_set BASE_URL "https://$LOCAL_DOMAIN" +env_set PUBLIC_BASE_URL "https://$LOCAL_DOMAIN" +env_set ALLOWED_ORIGINS "https://$LOCAL_DOMAIN,https://$LOCAL_IP" +# Default HTTPS_MODE to "local" only if not already set (preserves byo/skip). +if [ -z "$(env_get HTTPS_MODE)" ]; then + env_set HTTPS_MODE "local" +fi + +log ".env updated." + +# --------------------------------------------------------------------------- +# 6. Bring the stack up +# --------------------------------------------------------------------------- +if [[ "$LOCAL_SETUP_DRY_RUN" == "1" ]]; then + log "Dry-run mode — skipping 'docker compose up'." + exit 0 +fi + +log "Building & starting containers (first run can take 5-10 minutes) ..." +docker compose up -d --build + +cat < 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://"; + 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); diff --git a/scripts/tests/local-setup.test.mjs b/scripts/tests/local-setup.test.mjs new file mode 100644 index 00000000..960005a1 --- /dev/null +++ b/scripts/tests/local-setup.test.mjs @@ -0,0 +1,135 @@ +// Smoke test for scripts/local-setup.sh — verifies idempotency and that +// the .env upserts preserve unrelated keys. Uses a stubbed `mkcert` on +// the runner so no real CA is touched. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync, chmodSync, cpSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +function runLocalSetup(projectDir, extraEnv = {}) { + const stubDir = path.join(projectDir, ".stub-bin"); + mkdirSync(stubDir, { recursive: true }); + + // mkcert stub: writes minimal PEM-looking files so the script's + // existence check is satisfied. Also implements `-CAROOT`. + const mkcertStub = `#!/usr/bin/env bash +set -e +if [ "$1" = "-CAROOT" ]; then + echo "/tmp/fake-caroot" + exit 0 +fi +cert=""; key="" +positional=() +while [ $# -gt 0 ]; do + case "$1" in + -cert-file) cert="$2"; shift 2;; + -key-file) key="$2"; shift 2;; + *) positional+=("$1"); shift;; + esac +done +san="" +for s in "\${positional[@]}"; do + if [[ "$s" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then + if [ -z "$san" ]; then san="IP:$s"; else san="$san,IP:$s"; fi + else + if [ -z "$san" ]; then san="DNS:$s"; else san="$san,DNS:$s"; fi + fi +done +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \\ + -subj "/CN=local-test" \\ + -addext "subjectAltName=$san" \\ + -keyout "$key" -out "$cert" >/dev/null 2>&1 +`; + const stubPath = path.join(stubDir, "mkcert"); + writeFileSync(stubPath, mkcertStub); + chmodSync(stubPath, 0o755); + + return spawnSync("bash", [path.join(projectDir, "scripts", "local-setup.sh")], { + cwd: projectDir, + env: { + ...process.env, + PATH: `${stubDir}:${process.env.PATH}`, + LOCAL_SETUP_DRY_RUN: "1", + LOCAL_SETUP_NONINTERACTIVE: "1", + OSTYPE: "linux-gnu", + ...extraEnv, + }, + encoding: "utf8", + }); +} + +function makeFakeProject() { + const dir = mkdtempSync(path.join(tmpdir(), "tx-localsetup-")); + mkdirSync(path.join(dir, "scripts"), { recursive: true }); + // Copy the script under test verbatim. + copyFileSync( + path.resolve("scripts/local-setup.sh"), + path.join(dir, "scripts", "local-setup.sh"), + ); + chmodSync(path.join(dir, "scripts", "local-setup.sh"), 0o755); + + // Minimal .env.example so bootstrap works. + writeFileSync( + path.join(dir, ".env.example"), + [ + "SESSION_SECRET=change-me", + "POSTGRES_PASSWORD=change-me", + "SOME_USER_KEY=preserve-me", + "LOCAL_DOMAIN=tx.local", + "LOCAL_IP=127.0.0.1", + "BASE_URL=https://tx.local", + "PUBLIC_BASE_URL=https://tx.local", + "ALLOWED_ORIGINS=https://tx.local", + "HTTPS_MODE=local", + "", + ].join("\n"), + ); + return dir; +} + +test("first run bootstraps .env and writes a cert", () => { + const dir = makeFakeProject(); + try { + const r = runLocalSetup(dir); + assert.equal(r.status, 0, r.stderr || r.stdout); + const env = readFileSync(path.join(dir, ".env"), "utf8"); + assert.match(env, /^LOCAL_DOMAIN=tx\.local$/m); + assert.match(env, /^BASE_URL=https:\/\/tx\.local$/m); + assert.match(env, /^PUBLIC_BASE_URL=https:\/\/tx\.local$/m); + assert.match(env, /^SOME_USER_KEY=preserve-me$/m); + assert.ok(existsSync(path.join(dir, "certs", "local-cert.pem"))); + assert.ok(existsSync(path.join(dir, "certs", "local-key.pem"))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("second run is a no-op for cert generation and preserves user edits", () => { + const dir = makeFakeProject(); + try { + const first = runLocalSetup(dir); + assert.equal(first.status, 0, first.stderr || first.stdout); + + // Operator edits an unrelated key. + let env = readFileSync(path.join(dir, ".env"), "utf8"); + env = env.replace(/^SOME_USER_KEY=.*$/m, "SOME_USER_KEY=user-edited"); + writeFileSync(path.join(dir, ".env"), env); + + // Capture cert mtime to verify second run didn't rewrite it. + const certPath = path.join(dir, "certs", "local-cert.pem"); + const before = readFileSync(certPath); + + const second = runLocalSetup(dir); + assert.equal(second.status, 0, second.stderr || second.stdout); + + const after = readFileSync(certPath); + assert.deepEqual(before, after, "cert was rewritten on idempotent re-run"); + + const env2 = readFileSync(path.join(dir, ".env"), "utf8"); + assert.match(env2, /^SOME_USER_KEY=user-edited$/m); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});