First-run setup wizard: API #80

Closed
opened 2026-07-04 14:52:43 +02:00 by fable-5 · 1 comment
Collaborator

Context

Kickoff decision: self-hosters configure their instance in the browser instead of editing .env — the wizard runs exactly once against an empty database.

Scope

Implement setup mode: when no Site Admin exists, all routes except /setup/* return the setup-pending state; endpoints for the wizard steps — create Site Admin account, instance name + default locale, SMTP configuration (with a live 'send test mail' check; secret written to the env-backed secret store per security.md, not a DB row — implement that store), registration mode; completing the wizard locks it permanently; all values pre-seedable via env for automated deploys (Test/Int pipelines keep working unattended).

Acceptance criteria

  • fresh database + empty env → wizard required; completing it yields a working instance
  • pre-seeded env skips the wizard entirely (pipeline proof on Test)
  • wizard endpoints are unreachable after completion (410) — including after restart
  • SMTP test failure blocks the step with actionable detail; skipping SMTP is allowed with a documented consequence list (no signup mails)

Technical notes

  • deployment.md §Configuration, security.md §Secrets, ADR 0007.

Dependencies

Depends on #13, #19.

Size: ~1.5 days


Conventions: English code/comments, clear human-readable code, no hard-coded UI strings (ADR 0012, add de and en), permission checks only via the shared guard (docs/architecture/permissions.md). Read the referenced ADRs before starting.

## Context Kickoff decision: self-hosters configure their instance in the browser instead of editing .env — the wizard runs exactly once against an empty database. ## Scope Implement setup mode: when no Site Admin exists, all routes except `/setup/*` return the setup-pending state; endpoints for the wizard steps — create Site Admin account, instance name + default locale, SMTP configuration (with a live 'send test mail' check; secret written to the env-backed secret store per security.md, not a DB row — implement that store), registration mode; completing the wizard locks it permanently; all values pre-seedable via env for automated deploys (Test/Int pipelines keep working unattended). ## Acceptance criteria - [ ] fresh database + empty env → wizard required; completing it yields a working instance - [ ] pre-seeded env skips the wizard entirely (pipeline proof on Test) - [ ] wizard endpoints are unreachable after completion (410) — including after restart - [ ] SMTP test failure blocks the step with actionable detail; skipping SMTP is allowed with a documented consequence list (no signup mails) ## Technical notes - deployment.md §Configuration, security.md §Secrets, ADR 0007. ## Dependencies Depends on #13, #19. **Size**: ~1.5 days --- *Conventions: English code/comments, clear human-readable code, no hard-coded UI strings (ADR 0012, add `de` **and** `en`), permission checks only via the shared guard (docs/architecture/permissions.md). Read the referenced ADRs before starting.*
fable-5 added this to the M8 — Self-hosting & operations milestone 2026-07-04 14:52:43 +02:00
fable-5 added the
backend
label 2026-07-04 14:52:43 +02:00
Author
Collaborator

Umgesetzt in f0a82ba + Bootfix 224ae3e (Pipeline grün, alle 8 Kontexte inkl. Smoke auf Test; Test+Int readyz voll ok, secrets-Volumes provisioniert).

Setup-Mode & Gate

  • Neues apps/api/src/setup/-Modul. Solange setup.completedAt (instance_settings) fehlt, beantwortet ein globaler SetupGuard jede nicht ausgenommene Route mit 503 setup_required — auch anonym (das Modul ist bewusst VOR dem AuthModule registriert, damit 503 vor 401 gewinnt). Ausgenommen: /setup/*, healthz/readyz (Deploys/Monitoring) und auth/login|logout|me (Recovery, wenn der Wizard-Admin mitten im Wizard das Cookie verliert).
  • GET /setup bleibt immer lesbar ({status, adminCreated, smtpConfigured}) — darauf routet die #81-UI.

Wizard-Schritte (POST /setup/…)

  • admin: legt den Site Admin an (aktiv + E-Mail verifiziert + Personal Pond wie beim Double-Opt-in) und setzt direkt das Session-Cookie; alle Folgeschritte verlangen diese Site-Admin-Session (kein Hijacking durch einen zweiten Besucher). Zweiter Admin → 409 setup_admin_exists. Rate-limitiert.
  • instance (Name + Default-Locale) und registration (open/closed) schreiben direkt in instance_settings.
  • smtp: Live-Test zuerst (verify + echte Testmail an den Admin, neues smtpTest-Mail-Template de+en); Fehler → 400 smtp_test_failed mit Transport-Fehlertext in details.smtp (AC „actionable detail"), es wird nichts persistiert. Überspringen erlaubt — Konsequenz: keine Signup-/Reset-Mails, bis SMTP konfiguriert ist.
  • complete: setzt setup.completedAtalle Schritte dauerhaft 410 setup_locked, auch nach Neustart; der Admin-Settings-PATCH kann den Marker nicht anfassen (interner Key, 400).

Env-backed Secret Store (security.md §Secrets) — neu implementiert

  • apps/api/src/config/secret-store.ts + Service: mode-600-dotenv-Datei (SECRETS_FILE, Default /data/secrets/secrets.env im Image, neues secrets-Volume in der Compose), atomarer Write (staging+rename). Der Wizard schreibt die SMTP-Werte dorthin — nie in die DB.
  • Präzedenz: explizite Container-Env gewinnt immer über den Store (Operator-Override per Stage-.env möglich); Leerstrings zählen als ungesetzt (die Compose reicht ${SMTP_HOST:-} als "" durch). Zod-Defaults füllen den Rest.
  • Neuer SmtpConfigService: Mail-Transport wird lazy aus Env+Store aufgelöst und nach dem Wizard-Save per refresh() invalidiert → Mails fließen ohne Neustart über das neue Relay.

Pre-Seeding & Bestandsinstanzen

  • SETUP_ADMIN_USERNAME/EMAIL/PASSWORD (+ optional SETUP_ADMIN_DISPLAY_NAME, SETUP_INSTANCE_NAME, SETUP_DEFAULT_LOCALE, SETUP_REGISTRATION_MODE) in der .env schließen den kompletten Wizard beim Boot ab — automatisierte Deploys sehen ihn nie. In .env.example + Compose dokumentiert/durchgereicht.
  • Backfill-Migration 20260711100000: Instanzen, die schon einen Site Admin haben (Test/Int!), bekommen den Marker beim ersten migrate deploy → kein Wizard, Lock aktiv. Seed + vitest-global-setup setzen den Marker für Fixture-DBs. Pipeline-Beweis auf Test: der CD-Deploy dieses Commits lief unbeaufsichtigt durch, Smoke grün — die Stage hat den Wizard nie gesehen.

Tests

  • secret-store.test.ts (Parse/Serialize/Overlay/atomarer Write/0600).
  • setup.e2e.db.test.ts (10 Tests): provisioniert sich eine eigene frische Datenbank (CREATE DATABASE + prisma migrate deploy — validiert nebenbei die Backfill-Migration auf jungfräulichem Schema) und deckt alle vier ACs ab: fresh DB → Wizard required + 503-Gate; SMTP-Fail blockt mit Detail / Success gegen einen minimalen echten SMTP-Fake-Server (verify + Delivery über echten Socket) und persistiert in den Store (0600); complete → Instanz nutzbar + alle Schritte 410, auch mit frischer App-Instanz (Restart); Env-Preseed → komplett + gesperrt + Login funktioniert.

Stage-Provisioning: Stage-Composes auf ONE ersetzt (Backup *.bak-pre80) — neues secrets-Volume + SECRETS_FILE + SETUP_*-Passthrough; SMTP-Defaults der Compose sind jetzt leer statt localhost (sonst hätte die Container-Env den Store immer überstimmt — Stage-.envs setzen SMTP explizit, Verhalten dort unverändert).

Fallen für Folge-Sessions

  • Smoke-Rot beim ersten Deploy (f0a82ba): main.ts parste process.env roh — die Compose reicht optionale Variablen als "" durch (SETUP_DEFAULT_LOCALE: ${…:-}), der Enum-Parse crashte den api-Boot in der Schleife. Fix 224ae3e: main.ts und AppConfig teilen sich loadApiEnv() (Store-Overlay + Leerstring-Drop) + Regressionstest. Merke: jede neue optionale Enum-Env-Variable, die die Compose durchreicht, geht durch loadApiEnv, nie durch rohes parseEnv.
  • InstanceSettingsService cached auch null-Werte → der Setup-State liest den Marker direkt via Prisma (sonst friert eine Anfrage zwischen api-Boot und Seed den Pending-Zustand ein).
  • Nullable Registry-Settings brauchen Prisma.JsonNull beim Upsert.
  • Globale Guards laufen in Modul-Registrierungsreihenfolge → SetupModule bewusst vor AuthModule in app.module.ts.
Umgesetzt in `f0a82ba` + Bootfix `224ae3e` (Pipeline grün, alle 8 Kontexte inkl. Smoke auf Test; Test+Int readyz voll ok, `secrets`-Volumes provisioniert). **Setup-Mode & Gate** - Neues `apps/api/src/setup/`-Modul. Solange `setup.completedAt` (instance_settings) fehlt, beantwortet ein globaler `SetupGuard` jede nicht ausgenommene Route mit **503 `setup_required`** — auch anonym (das Modul ist bewusst VOR dem AuthModule registriert, damit 503 vor 401 gewinnt). Ausgenommen: `/setup/*`, `healthz`/`readyz` (Deploys/Monitoring) und `auth/login|logout|me` (Recovery, wenn der Wizard-Admin mitten im Wizard das Cookie verliert). - `GET /setup` bleibt immer lesbar (`{status, adminCreated, smtpConfigured}`) — darauf routet die #81-UI. **Wizard-Schritte** (`POST /setup/…`) - `admin`: legt den Site Admin an (aktiv + E-Mail verifiziert + Personal Pond wie beim Double-Opt-in) und **setzt direkt das Session-Cookie**; alle Folgeschritte verlangen diese Site-Admin-Session (kein Hijacking durch einen zweiten Besucher). Zweiter Admin → 409 `setup_admin_exists`. Rate-limitiert. - `instance` (Name + Default-Locale) und `registration` (open/closed) schreiben direkt in instance_settings. - `smtp`: **Live-Test zuerst** (verify + echte Testmail an den Admin, neues `smtpTest`-Mail-Template de+en); Fehler → 400 `smtp_test_failed` mit Transport-Fehlertext in `details.smtp` (AC „actionable detail"), es wird nichts persistiert. Überspringen erlaubt — Konsequenz: keine Signup-/Reset-Mails, bis SMTP konfiguriert ist. - `complete`: setzt `setup.completedAt` → **alle Schritte dauerhaft 410 `setup_locked`**, auch nach Neustart; der Admin-Settings-PATCH kann den Marker nicht anfassen (interner Key, 400). **Env-backed Secret Store (security.md §Secrets) — neu implementiert** - `apps/api/src/config/secret-store.ts` + Service: mode-600-dotenv-Datei (`SECRETS_FILE`, Default `/data/secrets/secrets.env` im Image, neues `secrets`-Volume in der Compose), atomarer Write (staging+rename). Der Wizard schreibt die SMTP-Werte dorthin — **nie in die DB**. - Präzedenz: **explizite Container-Env gewinnt immer** über den Store (Operator-Override per Stage-`.env` möglich); Leerstrings zählen als ungesetzt (die Compose reicht `${SMTP_HOST:-}` als `""` durch). Zod-Defaults füllen den Rest. - Neuer `SmtpConfigService`: Mail-Transport wird lazy aus Env+Store aufgelöst und nach dem Wizard-Save per `refresh()` invalidiert → Mails fließen **ohne Neustart** über das neue Relay. **Pre-Seeding & Bestandsinstanzen** - `SETUP_ADMIN_USERNAME/EMAIL/PASSWORD` (+ optional `SETUP_ADMIN_DISPLAY_NAME`, `SETUP_INSTANCE_NAME`, `SETUP_DEFAULT_LOCALE`, `SETUP_REGISTRATION_MODE`) in der `.env` schließen den kompletten Wizard beim Boot ab — automatisierte Deploys sehen ihn nie. In `.env.example` + Compose dokumentiert/durchgereicht. - Backfill-Migration `20260711100000`: Instanzen, die schon einen Site Admin haben (Test/Int!), bekommen den Marker beim ersten `migrate deploy` → kein Wizard, Lock aktiv. Seed + vitest-global-setup setzen den Marker für Fixture-DBs. **Pipeline-Beweis auf Test:** der CD-Deploy dieses Commits lief unbeaufsichtigt durch, Smoke grün — die Stage hat den Wizard nie gesehen. **Tests** - `secret-store.test.ts` (Parse/Serialize/Overlay/atomarer Write/0600). - `setup.e2e.db.test.ts` (10 Tests): provisioniert sich **eine eigene frische Datenbank** (CREATE DATABASE + `prisma migrate deploy` — validiert nebenbei die Backfill-Migration auf jungfräulichem Schema) und deckt alle vier ACs ab: fresh DB → Wizard required + 503-Gate; SMTP-Fail blockt mit Detail / Success gegen einen minimalen echten SMTP-Fake-Server (verify + Delivery über echten Socket) und persistiert in den Store (0600); complete → Instanz nutzbar + alle Schritte 410, **auch mit frischer App-Instanz (Restart)**; Env-Preseed → komplett + gesperrt + Login funktioniert. **Stage-Provisioning:** Stage-Composes auf ONE ersetzt (Backup `*.bak-pre80`) — neues `secrets`-Volume + `SECRETS_FILE` + `SETUP_*`-Passthrough; SMTP-Defaults der Compose sind jetzt leer statt `localhost` (sonst hätte die Container-Env den Store immer überstimmt — Stage-`.env`s setzen SMTP explizit, Verhalten dort unverändert). **Fallen für Folge-Sessions** - **Smoke-Rot beim ersten Deploy (`f0a82ba`)**: `main.ts` parste `process.env` roh — die Compose reicht optionale Variablen als `""` durch (`SETUP_DEFAULT_LOCALE: ${…:-}`), der Enum-Parse crashte den api-Boot in der Schleife. Fix `224ae3e`: main.ts und AppConfig teilen sich `loadApiEnv()` (Store-Overlay + Leerstring-Drop) + Regressionstest. Merke: **jede neue optionale Enum-Env-Variable, die die Compose durchreicht, geht durch loadApiEnv, nie durch rohes parseEnv.** - `InstanceSettingsService` cached auch `null`-Werte → der Setup-State liest den Marker **direkt via Prisma** (sonst friert eine Anfrage zwischen api-Boot und Seed den Pending-Zustand ein). - Nullable Registry-Settings brauchen `Prisma.JsonNull` beim Upsert. - Globale Guards laufen in Modul-Registrierungsreihenfolge → SetupModule bewusst vor AuthModule in `app.module.ts`.
Sign in to join this conversation.
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: stwaidele/dorfteich#80
No description provided.