diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index 79f73421..b340160e 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -3,6 +3,7 @@ import { Server as SocketIOServer } from "socket.io"; import type { IncomingMessage, ServerResponse } from "http"; import app, { sessionMiddleware } from "./app"; import { logger } from "./lib/logger"; +import { ensureSystemSettingsBootstrap } from "./lib/setupService"; const rawPort = process.env["PORT"]; @@ -58,6 +59,15 @@ io.on("connection", (socket) => { }); }); -httpServer.listen(port, () => { - logger.info({ port }, "Server listening"); -}); +// Run install-state bootstrap before accepting traffic so the wizard +// gate is always consistent with the DB on the first request, even +// when the schema was applied without running the seed script. +ensureSystemSettingsBootstrap() + .catch((err) => { + logger.error({ err }, "ensureSystemSettingsBootstrap failed (continuing)"); + }) + .finally(() => { + httpServer.listen(port, () => { + logger.info({ port }, "Server listening"); + }); + }); diff --git a/artifacts/api-server/src/lib/setupService.ts b/artifacts/api-server/src/lib/setupService.ts index 71a990a4..75d4263f 100644 --- a/artifacts/api-server/src/lib/setupService.ts +++ b/artifacts/api-server/src/lib/setupService.ts @@ -344,9 +344,43 @@ export async function completeInstall( } } -// 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. +// Bootstrap idempotently called from index.ts at server start. Ensures +// system_settings(id=1) exists and backfills installed=true for legacy +// installs where an admin already exists. Migration-safe: runs on every +// boot regardless of whether the seed script was invoked. +export async function ensureSystemSettingsBootstrap(): Promise { + const client = await pool.connect(); + try { + await client.query( + `INSERT INTO system_settings (id, installed) + VALUES (1, false) + ON CONFLICT (id) DO NOTHING`, + ); + const sys = await client.query<{ installed: boolean }>( + `SELECT installed FROM system_settings WHERE id = 1 LIMIT 1`, + ); + if (sys.rows[0]?.installed) return; + const adminCount = await client.query<{ id: number }>( + `SELECT u.id FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE r.name = 'admin' LIMIT 1`, + ); + if ((adminCount.rowCount ?? 0) > 0) { + await client.query( + `UPDATE system_settings + SET installed = true, + installed_at = COALESCE(installed_at, NOW()), + updated_at = NOW() + WHERE id = 1`, + ); + } + } finally { + client.release(); + } +} + +// Should the SPA router redirect to /setup? Mirrors readStatus(). export async function isSetupOpen(): Promise { try { const sys = await getSystemSettings(); @@ -357,11 +391,8 @@ export async function isSetupOpen(): Promise { } } -// Companion to isSetupOpen() that returns the full SetupStatus payload -// alongside the redirect decision so the SPA router (and any other -// caller making a redirect choice) gets a single, authoritative answer -// without making two requests. `shouldRedirect` is true iff the wizard -// should be shown — i.e. setup is open AND the DB check succeeded. +// Returns the full SetupStatus + a single redirect decision so callers +// don't have to make two requests. export async function redirectIfSetupNeeded(): Promise<{ shouldRedirect: boolean; target: "/setup" | null; diff --git a/artifacts/api-server/src/middlewares/setupGate.ts b/artifacts/api-server/src/middlewares/setupGate.ts index abdd5378..2251e59e 100644 --- a/artifacts/api-server/src/middlewares/setupGate.ts +++ b/artifacts/api-server/src/middlewares/setupGate.ts @@ -2,19 +2,10 @@ import type { Request, Response, NextFunction } from "express"; import { db } from "@workspace/db"; import { systemSettingsTable } from "@workspace/db"; -// 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. -// -// Gate semantics: this middleware uses `system_settings.installed` as -// the SINGLE source of truth, per the Stage 1 contract. The -// admin-existence cross-check lives inside the POST /setup/complete -// transaction (see lib/setupService.ts) where it can run under the -// advisory lock — that's the right place to defend against legacy -// installs that have an admin but never wrote system_settings. -// Backfill (in scripts/src/seed.ts) ensures any such legacy install -// flips system_settings.installed=true on next boot, so this gate -// stays consistent with the source-of-truth model. +// Gate /api/setup/{validate,complete} on system_settings.installed +// (the single source of truth). The admin-existence cross-check +// lives inside the POST /setup/complete transaction. +// /api/setup/status is intentionally NOT guarded. export async function requireSetupOpen( _req: Request, res: Response, diff --git a/docker-compose.yml b/docker-compose.yml index 25322d08..edcdb78a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,22 +97,28 @@ services: LOCAL_DOMAIN: ${LOCAL_DOMAIN:-tx.local} LOCAL_IP: ${LOCAL_IP:-127.0.0.1} HTTPS_MODE: ${HTTPS_MODE:-local} - # Pick the Caddyfile at boot. In skip mode we use a cert-free, - # HTTP-only config so Caddy can start without /certs being - # populated. start.sh forces APP_PORT-compatible HTTP_PORT in - # this mode so http://localhost:${APP_PORT} keeps working. + # Pick the Caddyfile at boot. We auto-fall-back to the cert-free + # Caddyfile.skip when HTTPS_MODE=skip OR when /certs is missing + # the keypair, so a fresh `./start.sh` works on hosts that don't + # have mkcert without any extra wiring. entrypoint: - /bin/sh - -c - | - if [ "$${HTTPS_MODE}" = "skip" ]; then + if [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then exec caddy run --config /etc/caddy/Caddyfile.skip --adapter caddyfile else exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile fi ports: - - "${HTTP_PORT:-${APP_PORT:-80}}:80" - - "${HTTPS_PORT:-443}:443" + # HTTP edge: defaults to APP_PORT so the legacy http://localhost:${APP_PORT} + # URL keeps working without modifying start.sh. + - "${HTTP_PORT:-${APP_PORT:-3000}}:80" + # HTTPS edge: bound to localhost on a non-privileged port by + # default so it never fights with another service on :443 and + # never requires root to bind. local-setup.sh writes + # HTTPS_BIND=0.0.0.0:443 (or :443) to .env once mkcert has run. + - "${HTTPS_BIND:-127.0.0.1:8443}:443" volumes: - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro - ./docker/Caddyfile.skip:/etc/caddy/Caddyfile.skip:ro diff --git a/scripts/src/seed.ts b/scripts/src/seed.ts index eeda61c8..43762c99 100644 --- a/scripts/src/seed.ts +++ b/scripts/src/seed.ts @@ -90,56 +90,22 @@ async function main() { } } - // --------------------------------------------------------------------- - // 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. - // --------------------------------------------------------------------- + // First-run install state. The canonical bootstrap + legacy backfill + // live in the api-server startup hook (ensureSystemSettingsBootstrap), + // so they run on every boot regardless of whether seed is invoked. + // Here we only read the current flag to decide whether to create the + // seeded admin/user pair below. const adminPassword = process.env.SEED_ADMIN_PASSWORD; const userPassword = process.env.SEED_USER_PASSWORD; - // Ensure a single source-of-truth row in system_settings always - // exists at id=1. If absent (brand-new DB), insert installed=false - // so /api/setup/status, the wizard, and admin-panel reads all see - // a consistent shape from minute one. The completeInstall flow - // (and the legacy backfill below) flip this row to installed=true. await db .insert(systemSettingsTable) .values({ id: 1, installed: false }) .onConflictDoNothing(); - // Detect existing install state. After the bootstrap insert above - // the row always exists, so installed defaults to false until the - // wizard or backfill flips it. const sysRows = await db.select().from(systemSettingsTable).limit(1); const installedFlag = sysRows[0]?.installed ?? false; - // OPERATOR NOTE: install-state backfill happens here (in seed) rather - // than as a standalone Drizzle migration because this project uses - // `drizzle-kit push` (no migration files). Any nonstandard deploy that - // applies the schema change WITHOUT running the seed must manually - // upsert system_settings(id=1, installed=true) for legacy installs - // where an admin user already exists, otherwise those operators will - // be incorrectly redirected to the first-time setup wizard. - // - // 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) @@ -149,24 +115,6 @@ async function main() { .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; diff --git a/start.sh b/start.sh index 2ae158dc..970a24bf 100755 --- a/start.sh +++ b/start.sh @@ -64,41 +64,11 @@ case "$action" in up) echo "==> Building images (first time can take 5-10 minutes) ..." docker compose build - # Default to HTTPS_MODE=skip when no cert is on disk, so a fresh - # `./start.sh` keeps working on hosts that don't have mkcert. The - # operator can switch to local/byo later via `local-setup.sh`. - if ! grep -qE '^HTTPS_MODE=' .env; then - if [ -f certs/local-cert.pem ] && [ -f certs/local-key.pem ]; then - echo "HTTPS_MODE=local" >> .env - else - echo "HTTPS_MODE=skip" >> .env - fi - fi - HTTPS_MODE="$(grep -E '^HTTPS_MODE=' .env | head -n1 | cut -d= -f2 | tr -d '\r')" - APP_PORT="$(grep -E '^APP_PORT=' .env | head -n1 | cut -d= -f2 | tr -d '\r')" - APP_PORT="${APP_PORT:-3000}" - # In skip mode, expose Caddy on APP_PORT so the legacy URL still - # works. In local/byo mode, Caddy listens on HTTPS_PORT (default - # 443) and the URL uses LOCAL_DOMAIN. - if [ "$HTTPS_MODE" = "skip" ]; then - export HTTP_PORT="$APP_PORT" - fi echo "==> Starting containers ..." - HTTP_PORT="${HTTP_PORT:-${APP_PORT}}" docker compose up -d + docker compose up -d + APP_PORT="$(grep -E '^APP_PORT=' .env | cut -d= -f2 | tr -d '\r' || echo 3000)" echo - if [ "$HTTPS_MODE" = "skip" ]; then - echo "Tx OS is starting. Open http://localhost:${APP_PORT}" - else - LOCAL_DOMAIN="$(grep -E '^LOCAL_DOMAIN=' .env | head -n1 | cut -d= -f2 | tr -d '\r')" - HTTPS_PORT="$(grep -E '^HTTPS_PORT=' .env | head -n1 | cut -d= -f2 | tr -d '\r')" - LOCAL_DOMAIN="${LOCAL_DOMAIN:-tx.local}" - HTTPS_PORT="${HTTPS_PORT:-443}" - if [ "$HTTPS_PORT" = "443" ]; then - echo "Tx OS is starting. Open https://${LOCAL_DOMAIN}" - else - echo "Tx OS is starting. Open https://${LOCAL_DOMAIN}:${HTTPS_PORT}" - fi - fi + echo "Tx OS is starting. Open http://localhost:${APP_PORT:-3000}" echo "Tail logs with: ./start.sh logs" ;; rebuild)