# Deep Dive: die vier priorisierten Themen _Stand: 2026-08-05 · Basis: v0.9.1 · Vertiefung zu [`brainstorm-2026-08-05.md`](brainstorm-2026-08-05.md)_ Vertieft: (1) Fehler-Boundaries, (2) Setup-Assistent, (3) Stale-while-error, (4) API-Routen-Tests. **Vorab drei Korrekturen** an meiner ersten Einschätzung, die sich beim Lesen des Codes ergeben haben: - Ein unerreichbarer Immich führt **nicht** zu einem Absturz. `request()` fängt den Fehler und gibt `null` zurück — die Seite wird still leer und liefert HTTP 404 für real existierende Alben. Das ist ein anderes (und heimtückischeres) Problem als ein Crash. - Ein **`app/error.tsx` würde den wichtigsten Fehlerfall gar nicht abfangen**, weil `app/layout.tsx:53` selbst `getConfig()` aufruft. Nötig ist `global-error.tsx`. - Der Setup-Assistent hat ein architektonisches Hindernis, das man erst beim Bauen bemerkt: Credentials kommen aus **Env-Variablen**, nicht aus YAML — und `lib/env.ts` parst genau einmal beim Prozessstart. --- ## 1. Fehler-, Lade- und 404-Boundaries ### Befund Kein `error.tsx`, `global-error.tsx`, `not-found.tsx` oder `loading.tsx` im gesamten `app/`-Baum. Es gibt **zwei völlig verschiedene Fehlermodi**, die unterschiedliche Antworten brauchen: **Modus A — die Konfiguration wirft.** `getConfig()` wirft an mehreren Stellen hart: | Stelle | Auslöser | | ------------------------- | ------------------------------------------ | | `lib/config/index.ts:136` | `gallery.yaml` ohne Album und ohne Subpage | | `lib/config/index.ts:144` | Subpage ohne `name` | | `lib/config/index.ts:170` | Subpage ohne Alben/Sections | | `lib/secret.ts:20` | `AUTH_SECRET` fehlt in Production | Nicht in dieser Liste: `validateUuid()` (`lib/config/parser.ts`) wirft **nicht** — es warnt und liefert bewusst eine Dummy-UUID zurück, damit der Build nicht bricht. Eine kaputte Album-ID führt also zu einem stillschweigend fehlenden Album, nicht zu einem Fehler. Das ist ein Fall für den Config-Doctor (A5), nicht für die Boundaries. **Modus B — Immich ist nicht erreichbar.** Hier wirft nichts: `request()` fängt alles ab (`lib/immich.ts:108–111`) und liefert `null`. Folge: - `getAlbums()` → `[]` → Startseite zeigt **stillschweigend keine Alben** - `getAlbum()` → `null` → `notFound()` (`app/[...path]/page.tsx:233`) → **HTTP 404 für eine URL, die es wirklich gibt** - Map, EXIF, OG-Images: leer ### Der entscheidende technische Punkt `app/error.tsx` wird **innerhalb** des Root-Layouts gerendert. Da das Layout selbst `getConfig()` aufruft (`app/layout.tsx:53`), wirft bei Modus A das Layout — und ein `error.tsx` kommt nie zum Zug. Nur `app/global-error.tsx` greift, weil es `` und `` vollständig ersetzt. Daraus folgt: `global-error.tsx` muss **komplett eigenständig** sein — eigene ``/``, Inline-Styles, kein `getConfig()`, kein Theme-Zugriff. Genau wie der heutige `SetupScreen`, der aus demselben Grund schon inline gestylt ist. ### Entwurf **`app/global-error.tsx`** — der Konfigurations-Notausgang. Sollte nicht „Etwas ist schiefgelaufen" sagen, sondern die Datei nennen, in der das Problem steckt. Die Fehlermeldungen aus `getConfig()` sind bereits präzise formuliert (`Subpage "Reisen" must have albums or sections`) — sie sind die halbe Miete, sie kommen nur nirgends an. In Production nur Dateiname + Meldung zeigen, keine Stacktraces. **`app/error.tsx`** — für Render-Fehler unterhalb des Layouts, im Theme-Look, mit `reset()`-Button. **`app/not-found.tsx`** — gestaltete 404 statt Next.js-Default. **`loading.tsx` pro Segment** — Skeletons aus ThumbHash (`lib/thumbhash.ts` ist da). Wirkt besonders, weil alle Seiten `force-dynamic` sind und heute komplett auf die Immich-Antwort warten, bevor irgendetwas erscheint. ### Der Teil, der wirklich zählt: 404 vs. 503 Heute kann die App nicht unterscheiden zwischen „dieses Album existiert nicht" und „ich kann Immich gerade nicht fragen". Beides endet in `notFound()`. Das ist nicht nur kosmetisch: Ein 404 sagt Suchmaschinen _„diese URL löschen"_, ein 503 sagt _„später nochmal versuchen"_. Ein Immich-Update während eines Google-Crawls kann so die Sichtbarkeit des Portfolios kosten. **Vorschlag:** Bevor `app/[...path]/page.tsx` `notFound()` aufruft, den Upstream-Status prüfen (`immich.ping()` ist über `/api/health` bereits mit 10-Sekunden-Cache versehen — wiederverwenden, nicht neu bauen). Ist Immich unerreichbar, stattdessen werfen → Error-Boundary → 5xx. Das greift direkt in Thema 3 über. ### Aufwand **XS** für die drei Boundary-Dateien. **S**, wenn die 404/503-Unterscheidung mitkommt. Ich würde beides zusammen machen — die Boundaries allein sind Kosmetik, erst mit der Statusunterscheidung werden sie ein Feature. --- ## 2. Setup-Assistent im Browser ### Befund: das Hindernis `components/SetupScreen.tsx` erklärt vier Dateikopien und endet mit _„restart your server"_. Ein Assistent müsste stattdessen selbst schreiben — und stößt sofort auf ein Problem: ```ts // lib/env.ts, letzte Zeile export const env = parseEnv(); // läuft genau einmal beim Modul-Load ``` `IMMICH_API_URL` und `IMMICH_API_KEY` kommen aus der Umgebung. Ein Browser-Formular kann eine Umgebungsvariable eines laufenden Prozesses nicht ändern. YAML dagegen ist bereits heiß nachladbar — `loadYaml()` prüft die mtime, und `getConfig()` verzichtet bewusst auf einen In-Memory-Cache (Kommentar in `lib/config/index.ts:49–52`). Die Credentials liegen also ausgerechnet in der einzigen Schicht, die einen Neustart erzwingt. ### Drei Wege **(a) Assistent schreibt `.env.local`.** Erfordert `docker restart`. Zerstört das Versprechen „in fünf Minuten fertig", und in vielen Compose-Setups liegt `.env.local` nicht einmal in einem gemounteten Volume — die Datei wäre nach dem Neustart weg. **(b) Credentials nach `content/` verlagern**, z. B. `content/connection.yaml` (Mode 0600), gelesen über denselben mtime-Loader. Kein Neustart, Assistent läuft durch. Bricht aber bestehende Deployments, wenn Env nicht mehr gilt. **(c) Hybrid — Empfehlung.** Env gewinnt, wenn gesetzt; sonst Fallback auf `content/connection.yaml`. Bestehende Installationen ändern sich um nichts, neue Nutzer bekommen den Assistenten. `lib/env.ts` bleibt der alleinige Env-Parser; der Fallback gehört nach `lib/config/index.ts`, wo `apiUrl`/`apiKey` ohnehin schon aufgelöst werden (Zeilen 53–54). Ein Eingriff, eine Stelle. ### Was schon existiert Bemerkenswert viel — der Assistent ist eher Verdrahtung als Neubau: | Baustein | Wo | | --------------------------------------------- | --------------------------------------------------------- | | `/admin` bleibt während des Setups erreichbar | `app/layout.tsx:68–79`, `isAdminPath()`, Commit `8f0bb94` | | Atomares YAML-Schreiben mit Backups | `lib/admin/yaml-service.ts` | | Alle Shared Albums listen (ohne Allowlist) | `GET /api/admin/albums` | | Visuelle Album-/Asset-Auswahl | `AlbumPicker.tsx`, `AssetPicker.tsx` | | Theme-Vorschaubilder | `docs/screenshots/theme-*.png` | | Cache nach dem Speichern invalidieren | `invalidateConfigCache()` | Dass `/admin` während `needsSetup` offen bleibt, wurde in `#392` bewusst gebaut — mit genau dieser Begründung („it is the tool that completes the setup"). ### Ablauf 1. **Verbinden** — URL + API-Key eingeben, sofort gegen `/server/ping` testen, Permissions prüfen, Immich-Version prüfen (Folio braucht ≥ 3.0, siehe die Warnung in `lib/immich.ts:400`). Fehler hier sofort und verständlich melden — das ist die Stelle, an der die meisten scheitern. 2. **Alben wählen** — visuell aus den Shared Albums, keine UUIDs. 3. **Look wählen** — Preset-Karten mit den vorhandenen Screenshots. 4. **Identität** — Titel, Untertitel, Footer. Schreibt `connection.yaml`, `gallery.yaml`, `settings.yaml`; ruft `invalidateConfigCache()`; leitet auf die fertige Galerie. ### Die Sicherheitsfrage, die vorher beantwortet sein muss Während `needsSetup` gilt, ist die Instanz frisch — möglicherweise ohne gesetztes `ADMIN_PASSWORD`. Ein ungeschützter Assistent auf einer öffentlichen URL bedeutet: Wer den Container zuerst findet, besitzt ihn. Zwei gängige Muster: einmaliges Setup-Token ins Container-Log schreiben (wie es diverse Self-Hosted-Tools machen), oder `ADMIN_PASSWORD` zur Voraussetzung machen. **Empfehlung: `ADMIN_PASSWORD` voraussetzen.** Das Sicherheitsmodell bleibt exakt wie heute (`lib/admin/auth.ts`, HMAC-Session-Cookie), es gibt keinen zweiten, schwächeren Pfad — und aus Nutzersicht ist es trotzdem ein Fortschritt: **eine** Env-Variable statt dreien. Angesichts von Commit `37f7f62` („fix pre-auth bypasses") ist zusätzliche Vorsicht in genau diesem Bereich angebracht. ### Aufwand **M.** Der größte Einzelhebel für Verbreitung — aber erst nach der Credentials-Entscheidung (c) sinnvoll anzufangen, weil sie den Rest bestimmt. --- ## 3. Stale-while-error ### Befund: es ist kein Absturz, es ist Stille Meine ursprüngliche Formulierung war ungenau. `request()` wirft nicht, es gibt `null` zurück. Wenn Immich weg ist, passiert Folgendes: | Aufruf | Ergebnis | Für den Besucher | | ----------------- | --------------------- | --------------------------- | | `getAlbums()` | `[]` | Startseite ohne Alben | | `getAlbum(id)` | `null` → `notFound()` | **HTTP 404 auf echter URL** | | `getAssetInfo()` | `null` | Kein EXIF, keine Map-Punkte | | `/api/image/[id]` | 404 pro Bild | Kaputte Bildplätze | Immerhin sauber gelöst: das leere Ergebnis wird **nicht** gecacht (`lib/immich.ts:214`, `if (!all) return []` steht vor `cache.set`) — es brennt sich also nichts ein. Das eigentliche Problem ist der 404. Eine Portfolio-Seite, die während eines Immich-Neustarts gecrawlt wird, meldet Suchmaschinen die Löschung ihrer eigenen Alben. Das ist der Grund, warum ich das über ISR stellen würde: Bei ISR geht es um Millisekunden, hier geht es um Auffindbarkeit. ### Entwurf — drei kleine Eingriffe **1. `lib/cache.ts`: abgelaufene Einträge behalten.** Heute löscht `get()` beim Ablauf (Zeilen 23–26). Stattdessen zwei Zeitstempel pro Eintrag — `staleAt` (bisheriges TTL) und `hardExpiresAt` — und ein neues `getStale(key)`, das auch abgelaufene Daten herausgibt. Die Speichergrenze regelt weiterhin die LRU-Eviction. Ein hartes Limit (`STALE_MAX_AGE`, Default 24 h) verhindert, dass ein dauerhaft totes Immich wochenalte Daten ausliefert. **2. `lib/immich.ts`: Fallback statt Leere.** In `getAlbums()`, `getAlbum()` und `getAssetInfo()` bei `null` aus `request()` zuerst `cache.getStale()` versuchen. Die Rückgabe sollte mitführen, ob die Daten frisch oder stale sind — sonst kann die Seite es nicht anzeigen. **3. Konsequenz ziehen: nicht `notFound()`, wenn der Upstream tot ist.** Gibt es weder frische noch stale Daten _und_ ist Immich nachweislich unerreichbar, dann werfen statt 404. Das ist derselbe Eingriff wie in Thema 1 — die beiden Punkte sind eigentlich ein Feature von zwei Seiten. Den Ping nicht neu bauen: `app/api/health/route.ts` hat bereits einen 10-Sekunden-gecachten, gegen Thundering-Herd abgesicherten `immich.ping()`; den Mechanismus in ein kleines Modul ziehen und beidseitig nutzen. ### Trade-offs, die benannt gehören - **Stale kann falsch sein.** Ein in Immich gelöschtes Foto bliebe bis zu `STALE_MAX_AGE` sichtbar. Für ein kuratiertes Portfolio ist das Risiko gering, aber es ist eine bewusste Entscheidung, keine Nebenwirkung. - **Passwörter sind nicht betroffen.** Sie stammen aus `gallery.yaml`, nicht aus Immich — eine Passwortänderung greift also sofort, unabhängig vom Stale-Cache. Das ist der beruhigende Teil. - **Nur im Prozessspeicher.** Nach einem Container-Neustart ist der Cache leer, ein gleichzeitig totes Immich hilft dann nicht. Der persistente Bild-Cache (A3 im Brainstorm) ist die natürliche Fortsetzung — zusammen überleben die beiden auch einen Neustart. ### Aufwand **M.** Die Cache-Änderung selbst ist klein; die Sorgfalt steckt in der 404/503-Politik und darin, den Stale-Zustand ehrlich anzuzeigen. --- ## 4. Tests für die API-Routen ### Befund: strukturell ausgeschlossen ```ts // vitest.config.ts include: ['lib/__tests__/**/*.test.ts'], ``` Die Routen sind nicht vergessen worden — sie sind per Konfiguration außen vor. Das ist eine Einzeilenänderung. Und die Infrastruktur passt bereits: - `environment: 'node'` — Route-Handler sind gewöhnliche async-Funktionen, die ein `NextRequest` nehmen. `new NextRequest('http://localhost/api/image/xyz')` genügt, es braucht keinen laufenden Server. - `vitest.setup.ts` setzt die Pflicht-Env inklusive `AUTH_SECRET`. - `lib/__tests__/immich.test.ts` zeigt das Muster: `vi.mock('../config')` plus `global.fetch = vi.fn()`. Exakt das, was Routentests brauchen. Der Einstieg ist also billiger, als es aussieht. ### Was zu testen sich lohnt — Invarianten, keine Zeilen **Allowlist.** `getAlbum()` weist nicht gelistete IDs ab (`lib/immich.ts:374`). Ein Album aus `gallery.yaml` entfernen → Route muss 404 liefern. **Token-Grenze.** `/api/image/[id]` mit roher UUID statt Token → 400. Mit einem Token, das unter einem _anderen_ `AUTH_SECRET` erzeugt wurde → 400. `tokens.ts` ist unit-getestet; hier geht es darum, dass die _Route_ das durchsetzt. **Die Lücke, die dokumentiert gehört.** Der Bild-Proxy dekodiert den Token und streamt (`app/api/image/[id]/route.ts:78`) — er prüft **nicht**, ob das Asset noch zu einem freigegebenen Album gehört. Da Tokens deterministisch sind (bewusst, für Browser-Caching, siehe `CLAUDE.md`), folgt daraus: **Ein Album aus `gallery.yaml` zu entfernen widerruft bereits ausgelieferte Bild-URLs nicht.** Sie funktionieren weiter, solange `AUTH_SECRET` gleich bleibt. Das ist ein legitimer Trade-off, aber ein überraschender. Er verdient einen Test, der das Verhalten festschreibt, und eine Zeile im README — sonst nimmt jemand an, „nicht mehr veröffentlicht" hieße „nicht mehr erreichbar". **Content-Type-Sanitisierung.** Zeilen 84–93 der Bild-Route stammen aus einem echten Stored-XSS-Fix (`c2fa8e7`). `image/svg+xml` und `text/xml` müssen zu `application/octet-stream` werden. Klassischer Regressionskandidat. **Rate-Limit-Namespaces.** Die Buckets sind pro Endpunkt getrennt (`image:`, `map:`, `auth:`). Test: `image:` ausschöpfen darf `/api/auth` nicht auf 429 setzen. **Admin-Guards — der wertvollste Einzeltest.** Alle sieben `/api/admin/*`-Routen müssen ohne gültiges Session-Cookie ablehnen, ebenso mit einem unter falschem Secret signierten Token. Das ist genau die Fläche von Commit `37f7f62`. Entscheidend ist die Bauform: **tabellengetrieben über die Liste der Admin-Routen**, nicht sieben Einzeltests. Dann schlägt die Suite auch bei einer _künftig hinzugefügten_ Route ohne Guard an — sie testet dann die Regel statt der sieben bekannten Fälle. Das ist der Unterschied zwischen einer Suite, die alte Bugs festhält, und einer, die neue verhindert. **Passwort-Gate.** `/api/auth` setzt das Cookie nur bei korrektem Passwort; Vergleich zeitkonstant; die drei Pfade `scrypt:` / Klartext / abgelehntes bcrypt. **304/ETag.** Bei `If-None-Match` kein Body. ### Aufwand **M** für die volle Abdeckung. Aber der Einstieg ist klein: `vitest.config.ts` erweitern, dann Admin-Guard-Tabelle und Content-Type-Regression zuerst — das ist etwa ein halber Tag und deckt bereits beide Stellen ab, an denen es in der Vergangenheit real geknallt hat. --- ## Reihenfolge und Zusammenhänge Die vier Themen sind nicht unabhängig: ``` Thema 1 (Boundaries) ─┐ ├─→ gemeinsame 404-vs-503-Politik Thema 3 (Stale) ─┘ Thema 2 (Assistent) ──→ hängt an der Credentials-Entscheidung (c) Thema 4 (Tests) ──→ sichert 1–3 ab, sollte parallel mitlaufen ``` **Phase 1 — etwa ein Tag, sofort sichtbar** `global-error.tsx`, `error.tsx`, `not-found.tsx`, `loading.tsx`; `vitest.config.ts` erweitern; Admin-Guard-Tabellentest. **Phase 2 — die Betriebs-Härtung** Stale-Cache plus 404/503-Politik. Ein Vorhaben, nicht zwei. Danach die Content-Type- und Allowlist-Tests nachziehen. **Phase 3 — der Verbreitungs-Hebel** Setup-Assistent, beginnend mit dem Hybrid-Fallback für Credentials und der Entscheidung, `ADMIN_PASSWORD` vorauszusetzen. ### Der eine Satz dahinter Phase 1 und 2 sorgen dafür, dass die Seite eines fremden Fotografen auch dann noch etwas Vernünftiges tut, wenn dessen Immich hustet — und Phase 3 sorgt dafür, dass dieser Fotograf überhaupt erst so weit kommt.