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.