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:
+32
-5
@@ -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://<ip>/.
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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<boolean> {
|
||||
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 };
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
+32
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,23 @@ export const updateAppSettingsSchema = createInsertSchema(appSettingsTable)
|
||||
.partial();
|
||||
export type AppSettings = typeof appSettingsTable.$inferSelect;
|
||||
export type UpdateAppSettings = z.infer<typeof updateAppSettingsSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
Executable
+237
@@ -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 <<EOF
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Tx OS is starting.
|
||||
Open: https://$LOCAL_DOMAIN/
|
||||
https://$LOCAL_IP/ (from devices that can't resolve mDNS)
|
||||
|
||||
If the API status reports setupRequired=true, the Setup Wizard
|
||||
will prompt you to create the first admin in your browser.
|
||||
----------------------------------------------------------------------
|
||||
EOF
|
||||
+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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user