feat(setup): Stage 1 first-time setup wizard backend (no UI)

Task #534 — backend, infra, and tooling only. UI ships in Stage 2.

Backend
- New system_settings table (id=1 singleton): installed flag, base_url,
  local_domain, local_ip, https_mode, app_version. Pushed to dev DB.
- New /api/setup/status (open) and /api/setup/{validate,complete}
  (gated by requireSetupOpen — 409 once installed).
- completeInstall is fully transactional: pg_advisory_xact_lock
  serializes concurrent callers, double-gates on installed flag and
  admin existence, then atomically creates the admin user, assigns
  admin role + Admins/Everyone groups, and flips system_settings to
  installed=true. Rolls back on any failure.
- Zod validation, bcrypt hashing, in-memory rate limiter for the
  setup endpoints.

Backward compat
- scripts/src/seed.ts now branches on installed flag + admin
  existence + SEED_*_PASSWORD env vars. Legacy installs (admin
  exists, system_settings empty) get backfilled to installed=true
  via ON CONFLICT DO UPDATE so they are never forced through the
  wizard. When env passwords are unset and no admin exists, the
  seed prints a wizard hint instead of seeding.

Infra
- docker-compose.yml: replaced nginx edge with a Caddy service that
  mounts ./certs and ./docker/Caddyfile. The web service no longer
  publishes a port directly — Caddy is the only public ingress.
- docker/Caddyfile: HTTPS site for LOCAL_DOMAIN/LOCAL_IP with
  WebSocket upgrade preserved and a plaintext :80 fallback when
  HTTPS_MODE=skip (dev-only).
- .env.example: added LOCAL_DOMAIN, LOCAL_IP, BASE_URL, HTTP_PORT,
  HTTPS_PORT, HTTPS_MODE; SEED_*_PASSWORD now optional.

Tooling
- scripts/local-setup.sh: idempotent OS-aware bootstrap (.env upsert,
  mkcert hint, cert SAN check, dry-run via LOCAL_SETUP_DRY_RUN).
  start.sh untouched.

Tests
- artifacts/api-server/tests/setup-wizard.test.mjs: 7/7 pass
  (snapshot/restore admin role + system_settings around tests).
- scripts/tests/local-setup.test.mjs: 2/2 pass (first-run bootstrap
  + second-run no-op idempotency with mkcert/openssl stubs).

Constraints honored: no force-push, no destructive ops, start.sh
preserved, scripts idempotent, volumes/DB never touched, HTTPS skip
mode dev-only, wizard does not edit LOCAL_DOMAIN/LOCAL_IP.

Out of scope / not addressed: pre-existing TS errors in
routes/users.ts and pre-existing failure in
executive-meetings-postpone-race.test.mjs.
This commit is contained in:
riyadhafraa
2026-05-14 07:32:58 +00:00
parent 7932b3f1db
commit a3ebff2afa
13 changed files with 1365 additions and 53 deletions
+237
View File
@@ -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
View File
@@ -14,11 +14,12 @@ import {
userGroupsTable,
groupAppsTable,
groupRolesTable,
systemSettingsTable,
executiveMeetingsTable,
executiveMeetingAttendeesTable,
executiveMeetingNotificationsTable,
} from "@workspace/db";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
async function main() {
@@ -89,54 +90,120 @@ async function main() {
}
}
// Create admin user. Passwords are read exclusively from env vars so
// credentials are never committed and never appear in source. Both vars
// are required in every environment (including local dev) — copy them
// into a local `.env` from `.env.example` before running the seed.
// ---------------------------------------------------------------------
// First-run install state — defer admin creation to the Setup Wizard
// when no admin exists yet AND the operator hasn't pre-seeded passwords
// via environment variables.
//
// Behaviour matrix:
// installed=true OR admin exists → seed roles/permissions only
// (re-runs in CI / migrations stay
// green; never overwrite a real
// admin row)
// installed=false, no admin, env → behave as before: create the
// seeded admin + sample user, flip
// installed=true so the wizard does
// not appear
// installed=false, no admin, no
// env → seed roles/permissions only,
// leave admin creation to the
// wizard. Print a clear log line.
// ---------------------------------------------------------------------
const adminPassword = process.env.SEED_ADMIN_PASSWORD;
const userPassword = process.env.SEED_USER_PASSWORD;
if (!adminPassword) {
throw new Error(
"SEED_ADMIN_PASSWORD must be set (see .env.example). Refusing to seed.",
);
}
if (!userPassword) {
throw new Error(
"SEED_USER_PASSWORD must be set (see .env.example). Refusing to seed.",
);
}
const adminHash = await bcrypt.hash(adminPassword, 10);
const [adminUser] = await db
.insert(usersTable)
.values({
username: "admin",
email: "admin@tx.local",
passwordHash: adminHash,
displayNameAr: "مدير النظام",
displayNameEn: "System Admin",
preferredLanguage: "ar",
isActive: true,
})
.onConflictDoNothing()
.returning();
// Create regular user
const userHash = await bcrypt.hash(userPassword, 10);
const [regularUser] = await db
.insert(usersTable)
.values({
username: "ahmed",
email: "ahmed@tx.local",
passwordHash: userHash,
displayNameAr: "أحمد محمد",
displayNameEn: "Ahmed Mohammed",
preferredLanguage: "ar",
isActive: true,
})
.onConflictDoNothing()
.returning();
// Detect existing install state. The system_settings row may not exist
// yet on a brand-new DB; treat "no row" as installed=false.
const sysRows = await db.select().from(systemSettingsTable).limit(1);
const installedFlag = sysRows[0]?.installed ?? false;
console.log("Users created");
// Backfill: if any admin already exists but system_settings is empty
// (legacy installs from before this column shipped), upsert the row
// with installed=true so those operators are never forced through the
// wizard.
const existingAdminRows = await db
.select({ id: usersTable.id })
.from(userRolesTable)
.innerJoin(usersTable, eq(usersTable.id, userRolesTable.userId))
.innerJoin(rolesTable, eq(rolesTable.id, userRolesTable.roleId))
.where(eq(rolesTable.name, "admin"))
.limit(1);
const adminAlreadyExists = existingAdminRows.length > 0;
if (adminAlreadyExists && !installedFlag) {
// Use DO UPDATE so a stale id=1 row with installed=false (e.g. if a
// half-finished wizard run inserted the row first) is corrected to
// installed=true. Preserve installed_at if already set.
await db
.insert(systemSettingsTable)
.values({ id: 1, installed: true, installedAt: new Date() })
.onConflictDoUpdate({
target: systemSettingsTable.id,
set: {
installed: true,
installedAt: sql`COALESCE(${systemSettingsTable.installedAt}, NOW())`,
updatedAt: new Date(),
},
});
console.log("Backfilled system_settings.installed=true for existing admin");
}
let adminUser: { id: number } | undefined;
let regularUser: { id: number } | undefined;
const skipUserCreation = installedFlag || adminAlreadyExists;
const haveSeedEnv = Boolean(adminPassword && userPassword);
if (skipUserCreation) {
console.log(
"Skipping seeded admin/user — system already installed or an admin already exists.",
);
} else if (!haveSeedEnv) {
const host = process.env.PUBLIC_BASE_URL ?? "https://<host>";
console.log(
`[seed] No SEED_ADMIN_PASSWORD/SEED_USER_PASSWORD set — first-run wizard required at ${host}/setup`,
);
} else {
const adminHash = await bcrypt.hash(adminPassword!, 10);
const insertedAdmin = await db
.insert(usersTable)
.values({
username: "admin",
email: "admin@tx.local",
passwordHash: adminHash,
displayNameAr: "مدير النظام",
displayNameEn: "System Admin",
preferredLanguage: "ar",
isActive: true,
})
.onConflictDoNothing()
.returning();
adminUser = insertedAdmin[0];
const userHash = await bcrypt.hash(userPassword!, 10);
const insertedRegular = await db
.insert(usersTable)
.values({
username: "ahmed",
email: "ahmed@tx.local",
passwordHash: userHash,
displayNameAr: "أحمد محمد",
displayNameEn: "Ahmed Mohammed",
preferredLanguage: "ar",
isActive: true,
})
.onConflictDoNothing()
.returning();
regularUser = insertedRegular[0];
// Mark install complete so the wizard does not appear.
await db
.insert(systemSettingsTable)
.values({ id: 1, installed: true, installedAt: new Date() })
.onConflictDoNothing();
console.log("Users created");
}
// Assign roles
const roles = await db.select().from(rolesTable);
+135
View File
@@ -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 });
}
});