Start

Technische Dokumentation

Stand: 2026-11-05

Wie Wanderbibliothek gebaut ist: Architektur, Routen, Daten, Sicherheit und Betrieb. Gedacht für alle, die den Code lesen, die Anwendung betreiben oder prüfen wollen, was gespeichert wird.

Alles hier Beschriebene steht im Repository. Ändert sich der Code, ändert sich diese Seite mit.

Überblick

Modularer Monolith mit Next.js 16 (App Router mit Cache Components), strengem TypeScript und SQLite über better-sqlite3. Ein einziger Prozess: SQLite kennt nur einen Schreiber, und der Live-Bus für Hinweise liegt im Speicher.

Es gibt keine verpflichtenden Fremddienste. Cover und bibliografische Daten holt der Server und legt sie auf der Platte ab, sodass der Browser nie mit Dritten spricht.

Der Code steht unter der MIT-Lizenz. Diese Seite beschreibt den Code der Instanz, die sie ausliefert.

Architektur

src/
  app/                 Seiten und Route Handler (App Router)
    api/               71 Handler, nach Domäne gruppiert
    styles/            Tokens, Typografie, Struktur, Karten, Dialoge, Detailseiten
    proxy.ts           CSP mit Nonce und Sicherheits-Header
  components/          Server-Gerüst und interaktive Inseln
  lib/
    db/                Verbindung, Basisschema (v2), Migrationen (v3–v38), Seed
    repo/              SQL-Abfragen je Domäne; `repo.ts` reexportiert nur
    validation.ts      Zod-Schemata mit übersetzten Meldungen
    secret-box.ts      umkehrbare Verschlüsselung mit Schlüsselring (TOTP, Versand)
    e2e.ts             Ende-zu-Ende-Verschlüsselung (ECDH + HKDF + AES-GCM)
    route.ts           Routen-Wrapper: Sitzung, Body, Kontingent, Fehler
tests/                 331 Unit- und Integrationstests (45 Dateien)
e2e/                   36 Browser-Tests mit Playwright
deploy/                systemd, nginx, Umgebungsvorlage und Anleitung

Das Gerüst (site-shell.tsx, site-rail.tsx) ist ein Server Component: Seitenleiste, Kopfzeile, Fußzeile und mobile Navigation kommen fertig gerendert an, hydratisiert werden nur die interaktiven Inseln (account-menu, theme-toggle, locale-switcher, verify-email-banner).

Das deutsche Wörterbuch geht nicht an den Browser: i18n.ts wird nur auf dem Server importiert, und das Layout gibt dem Client-Provider nur die Texte der aktiven Sprache, nicht das ganze Wörterbuch. Sie sind außerdem über eine 32-Bit-Kennung (messageId) adressiert statt über den Quelltext: der Schlüssel ist zehn Ziffern lang und der spanische Text reist nicht mit, was das deutsche HTML von 236 auf 182 KB bringt.

getDb() hängt sich in prepare und verwendet kompilierte Anweisungen weiter (FIFO-Cache mit 400 Einträgen je Verbindung). Öffentliche Aggregate laufen über use cache mit dem Tag public-data, das die schreibenden Routen entwerten.

Das Rendering liest weder Date.now() noch new Date(): Next wertet das beim Vorab-Rendern als instabilen Wert. Zeitstempel für Hinweise, Wartung und Limits setzt SQLite mit strftime('%Y-%m-%dT%H:%M:%fZ','now').

Routen

Die internen Seiten verlangen eine Sitzung; ohne sie leiten sie auf /?auth=1&next=… um. Öffentlich sind nur die Startseite, die Rechtstexte, die Abläufe mit eigenem Link und diese Dokumentation. robots.txt sperrt Katalog und Profile, damit Name und Stadt der Leser:innen nicht in Suchmaschinen landen.

RouteInhalt
/Öffentliche Startseite: Hero, vier Kennzahlen und eine Reihe Bücher.
/descubrirVollständiger Katalog mit Suche, Kategorien, Sortierung und Seitenzahl in der URL. Braucht eine Sitzung.
/ofrecerEin Exemplar einstellen: die ISBN füllt die bibliografischen Daten.
/buscarGlobale Suche: Werke, Clubs, Leser:innen und Listen.
/estanteria, /intercambiosPrivate Bereiche: eigene Exemplare und Anfragen mit dem nächsten Schritt.
/comunidadHub mit Tabs (Gespräche, Aktivität, Clubs, Leser:innen, Listen) und Umfang über ?scope=.
/lectoresVerzeichnis mit Suche, Stadt und Sortierung nach Nähe oder Aktivität.
/clubes, /clubes/[clubId]Clubs je Stadt: Anfragen, laufende Lektüre, Abstimmung, Teilnahme und privates Forum.
/listas, /listas/[listId]Öffentliche Leselisten, nur von der eigenen Person bearbeitbar.
/libro/[id]Exemplarseite mit Metadaten und JSON-LD Book/Offer.
/obra/[bookId]Kanonisches Werk: Gespräch zum Titel, Exemplare und Empfehlungen.
/u/[id]Öffentliches Profil: Biografie, Kennzahlen, Rezensionen, Regal und Folgen.
/cuentaEinstellungen: Profil, Hinweise, Passwort, Verschlüsselung, zweiter Faktor, Export und Löschung.
/adminModeration der Community; verlangt einen aktiven zweiten Faktor.
/privacidad, /aviso-legalDatenschutzerklärung (Art. 13 DSGVO) und Impressum (§ 5 DDG).
/verificar, /restablecerE-Mail bestätigen und neues Passwort über den Link setzen.
/documentacionDiese Seite.
/api/healthZustand von Dienst und Datenbank.
/sitemap.xml, /robots.txtIndexierung: die Sitemap veröffentlicht nur die Startseite.

Die API besteht aus 71 Handlern unter src/app/api. Fast alle werden mit route() (src/lib/route.ts) deklariert, das an einer Stelle Sitzung, Zod-Schema des Bodys, Kontingent mit Herkunftsprüfung und handleApiError() anwendet; tests/route.test.ts prüft an den Dateien, dass keine Route Fehler unübersetzt durchlässt.

  • /api/auth/*: Registrierung, Anmeldung, Abmeldung, Sitzung, Bestätigung und Zurücksetzen mit Einmal-Tokens.
  • /api/listings, /api/listings/[id], …/favorite: Exemplare einstellen, bearbeiten, zurückziehen und merken.
  • /api/requests, /api/requests/[id] und Ableger (events per SSE, messages, read, review, handover, handover/shipping, handover/verify): der ganze Tauschablauf.
  • /api/books/*: Werksuche, Kommentare, Likes, Hinweise je Werk und Lesestände.
  • /api/clubs/*: Club, Mitglieder, Moderator:innen, Einladungen, Teilnahme, Übergabe, Forum, Abstimmungen und Beitrittsanfragen.
  • /api/lists/*, /api/direct/*, /api/users/[userId]/follow, /api/blocks: Listen, verschlüsselte Direktnachrichten, Folgen und Blockieren.
  • /api/account/*: Profil, Sprache, Passwort, Verschlüsselungsschlüssel, TOTP, Export und Löschung.
  • /api/admin/*, /api/reports, /api/comments/[id], /api/notifications: Meldungen, Moderation, Kommentare und Hinweise.
  • /api/cover/*, /api/isbn/[isbn]: zwischengespeicherte Cover und bibliografische Daten (DNB und Open Library).
  • /api/cron/maintenance: geplante Aufgaben, mit MAINTENANCE_TOKEN.

Daten

  • books ist die Ausgabe (eindeutige ISBN), listings das konkrete Exemplar; books.work_key bündelt Ausgaben desselben Werks, sodass sie Gespräch und Exemplarliste teilen.
  • Zustände eines Exemplars: available, reserved, given, paused, removed.
  • Zustände einer Anfrage: pending, accepted, declined, cancelled, completed, expired.
  • Suche über FTS5 (listings_fts), von Triggern gepflegt; unaccent() faltet Akzente, damit „Lucía“ auch als „lucia“ gefunden wird.
  • cities enthält 123 Städte in Deutschland, city_distances die vorberechnete Distanzmatrix.

Migrationen werden über PRAGMA user_version versioniert (aktuell: v38) und erhalten die Daten einer bestehenden Installation. Die v38 öffnet den Katalog wieder für ganz Deutschland, ergänzt requests.ship_kind und ship_street, verschlüsselt die Versanddaten, die die v37 im Klartext abgelegt hat, und entfernt die Deutsche-Post-Filialen; die v36 brachte die verschlüsselten Direktnachrichten, die v37 den Versand per Post.

Versanddaten (ship_number, ship_station, ship_street, ship_postal_code, ship_city) liegen verschlüsselt mit APP_ENCRYPTION_KEY, und jeder Wert ist über AAD an seine Anfrage und seine Spalte gebunden (ship:<anfrage>:<feld>): ein in eine andere Zeile verschobenes Chiffrat lässt sich nicht öffnen. Entschlüsselt wird nur in der Ansicht der beiden Tauschpartner:innen, und Hinweise wiederholen das Ziel nicht.

Nachrichten eines Tauschs und Direktnachrichten werden im Browser verschlüsselt (ECDH P-256 + HKDF-SHA256 + AES-GCM-256 mit AAD). Der Server speichert den Umschlag und den Schlüssel-Fingerabdruck und lehnt Klartext ab, sobald eine der beiden Seiten einen Schlüssel veröffentlicht hat (409).

  • Ein Exemplar wird nur reserviert, wenn es noch available ist – innerhalb derselben Transaktion.
  • Beim Annehmen wandern konkurrierende Anfragen nach expired; annehmen oder ablehnen kann nur die Eigentümer:in.
  • Der Abschluss ist beidseitig: jede Seite bestätigt getrennt mit dem 6-stelligen Code, und erst die zweite Bestätigung schließt den Tausch.
  • Unbeantwortete Anfragen verfallen nach 14 Tagen und benachrichtigen die wartende Person.
  • Blockierungen, gesperrte Konten und pausierte Exemplare werden in SQL gefiltert, nicht im Client.

Sicherheit

  • Passwörter mit asynchronem scrypt und eigenem Salt; die Anmeldung prüft immer einen Hash (gegen einen Köder, wenn es das Konto nicht gibt), sodass die Antwortzeit nicht verrät, welche Adressen registriert sind.
  • Sitzung mit 256 Bit im httpOnly-Cookie und SameSite=Lax; in Produktion wird nur das Präfix __Host- akzeptiert, und in der Datenbank liegt nur ihr SHA-256.
  • Zweiter Faktor (TOTP nach RFC 6238, Fenster ±1 Schritt, Anti-Replay) ist für Moderation in Produktion Pflicht; die Geheimnisse liegen verschlüsselt.
  • Atomare Kontingente in der Datenbank je IP und je Konto, mit 429 und Retry-After.
  • Alle schreibenden Zugriffe laufen über guard() (Herkunft und Kontingent), und readJson() verlangt application/json und kappt Bodys über 64 KB.
  • CSP mit Nonce und strict-dynamic, X-Frame-Options: DENY, COOP/CORP, nosniff, Referrer-Policy, Permissions-Policy und HSTS in Produktion (src/proxy.ts).
  • Verzeichnis 0700 und Datenbank 0600; npm run db:backup nutzt die .backup()-API von SQLite und verschlüsselt mit AES-256-GCM, wenn WANDERBIBLIOTHEK_BACKUP_KEY gesetzt ist.
  • Registrierung mit Honeypot, Mindestzeit und optionalem Cloudflare Turnstile; die Antwort ist allgemein gehalten, um keine Adressen zu verraten.
  • Konto-Tokens sind einmalig, gehasht und befristet; ein Passwortwechsel beendet alle Sitzungen.
  • GET /api/account/export liefert alle Daten als JSON, und das Löschen des Kontos entfernt eigene Inhalte, ohne fremde Gespräche zu zerreißen.

Umgebungsvariablen

Ohne APP_ENCRYPTION_KEY wird kein Geheimnis im Klartext abgelegt: das Anlegen scheitert mit einem Konfigurationsfehler. In Produktion prüft src/instrumentation.ts die Konfiguration beim Start und schreibt den Grund ins Log.

VariableStandardZweck
WANDERBIBLIOTHEK_DB.data/wanderbibliothek.dbPfad der SQLite-Datenbank (:memory: in Tests).
NEXT_PUBLIC_SITE_URLhttp://localhost:3000Basis für sitemap.xml, robots.txt und Canonicals. In Produktion Pflicht.
APP_ENCRYPTION_KEYnicht gesetzt32 Byte in Hex oder Base64 für TOTP und Versanddaten. Pflicht.
APP_ENCRYPTION_KEY_IDabgeleitetLesbarer Name des aktiven Schlüssels; fehlt er, dient ein Präfix seines SHA-256.
APP_ENCRYPTION_OLD_KEYSleerFrühere Schlüssel nur zum Lesen, solange eine Rotation läuft.
WANDERBIBLIOTHEK_ALLOW_PLAINTEXTleerNur Entwicklung: legt Geheimnisse im Klartext ab, wenn kein Schlüssel da ist.
WANDERBIBLIOTHEK_BACKUP_KEYnicht gesetztAES-256-GCM-Schlüssel der Sicherungen.
WANDERBIBLIOTHEK_BACKUP_KEEP14Anzahl der Sicherungen, die beim Rotieren bleiben.
WANDERBIBLIOTHEK_BACKUP_HOOKnicht gesetztBefehl, der BACKUP_FILE für eine externe Kopie erhält.
WANDERBIBLIOTHEK_COVER_CACHEneben der DatenbankOrdner für geladene und hochgeladene Cover.
WANDERBIBLIOTHEK_SKIP_ENV_CHECKnicht gesetztÜberspringt die Umgebungsprüfung beim Start.
MAIL_WEBHOOK_URLnicht gesetztEmpfängt {to, subject, text}; Versand läuft über mail_outbox mit Wiederholungen.
MAIL_WEBHOOK_TOKENnicht gesetztBearer-Authentifizierung des Mail-Webhooks.
MAINTENANCE_TOKENnicht gesetztToken für POST /api/cron/maintenance; ohne ihn 404.
AUDIT_RETENTION_DAYS0Tage, die admin_audit aufbewahrt wird (0 = dauerhaft).
ADMIN_EMAIL / ADMIN_PASSWORDnicht gesetztLegen das erste Verwaltungskonto an, wenn keines existiert.
REQUIRE_EMAIL_VERIFICATIONnicht gesetzt1 verlangt eine bestätigte Adresse zum Einstellen, Anfragen, Kommentieren und Schreiben.
NEXT_PUBLIC_TURNSTILE_SITE_KEY / TURNSTILE_SECRETnicht gesetztAktivieren Cloudflare Turnstile bei der Registrierung.
ERROR_WEBHOOK_URL / ERROR_WEBHOOK_TOKENnicht gesetztEmpfangen 500er als JSON (Sentry, Logtail oder eigener Webhook).
TRUST_PROXYnicht gesetzt1 nur mit eigenem Proxy davor: erlaubt X-Forwarded-For/-Host und aktiviert Kontingente je IP.
LEGAL_CONTROLLER / LEGAL_ADDRESS / LEGAL_CONTACT_EMAILnicht gesetztFüllen /privacidad und /aviso-legal.
LEGAL_VAT_ID / LEGAL_PHONE / LEGAL_REGISTERnicht gesetztOptionale Angaben für das Impressum.
SEED_DEMO1 außerhalb der ProduktionLegt Demodaten an; in Produktion nur mit ausdrücklicher 1.
GOOGLE_BOOKS_API_KEYnicht gesetztErhöht das Kontingent von Google Books für Cover.

Tests und Continuous Integration

npm test führt 331 Unit- und Integrationstests in 45 Dateien aus (Node-Testrunner auf SQLite im Speicher), und npm run test:e2e ergänzt 36 Browser-Tests mit Playwright, die ihren eigenen Server auf Port 3100 starten.

npm run test:coverage setzt Mindestschwellen von 92 % Zeilen, 80 % Zweigen und 88 % Funktionen. Die Continuous Integration (.github/workflows/ci.yml) führt lint, typecheck, test, build, npm audit --omit=dev, die Browser-Tests und einen Build des Docker-Images ohne Veröffentlichung aus.

npm install
npm run lint && npm run typecheck && npm test
npm run build
npx playwright install chromium && npm run test:e2e

Betrieb

  • npm run db:backup nutzt die .backup()-API von SQLite, prüft die Integrität, rotiert die Sicherungen, verschlüsselt bei vorhandenem Schlüssel und startet WANDERBIBLIOTHEK_BACKUP_HOOK. Die Datei nie bei aktivem WAL im laufenden Betrieb kopieren.
  • POST /api/cron/maintenance mit Authorization: Bearer $MAINTENANCE_TOKEN lässt Anfragen verfallen, räumt Sitzungen, Hinweise und Kontingente auf und verschickt die Erinnerungen der Clubs.
  • npm run user:verify -- <adresse> und npm run admin:2fa-off -- <adresse> sind die Notausgänge, wenn Mail oder zweiter Faktor jemanden aussperren.
  • Jede Antwort trägt x-request-id und x-response-time, und jede Anfrage hinterlässt eine Zeile [req] {…} im Log. Die reference eines 500ers sind die ersten 8 Zeichen dieser ID.
  • Nur eine Instanz: SQLite hat einen einzigen Schreiber, und der Hinweis-Bus liegt im Speicher. Skalierung verlangt PostgreSQL und Redis/Pubsub.
  • Gemessene Obergrenze mit npm run bench:sqlite: rund 99 000 Einzel-Schreibvorgänge/s, 250 000/s im Stapel, 350 000 Lesezugriffe/s über den Primärschlüssel und 2 000/s bei einem Filter ohne Index über 5 000 Zeilen. Die echte Grenze ist die Dauer jeder Transaktion, nicht die Zahl der Aufrufe.

Geheimnisse liegen als enc:v2:<schlüssel>:<flags>:<iv>.<tag>.<daten> vor. Schlüsselwechsel ohne Datenverlust:

# 1. Probelauf: der neue Schlüssel ist aktiv, der alte bleibt zum Lesen
APP_ENCRYPTION_KEY=<neu> APP_ENCRYPTION_OLD_KEYS=<alt> \
  npm run rotate:secrets -- --dry-run

# 2. Wenn der Bericht passt, ohne --dry-run
APP_ENCRYPTION_KEY=<neu> APP_ENCRYPTION_OLD_KEYS=<alt> \
  npm run rotate:secrets

# 3. APP_ENCRYPTION_OLD_KEYS entfernen

Deployment

  • Die vollständige Anleitung (Systembenutzer, systemd, nginx, TLS, Timer und Wiederherstellung) steht in deploy/README.md.
  • deploy/wanderbibliothek.service, deploy/nginx.conf und deploy/env.example: die Anwendung lauscht auf 127.0.0.1:3000, nginx macht das TLS, und /api/requests/ läuft ohne Buffering, damit der SSE-Kanal nicht abreißt.
  • deploy/wanderbibliothek-maintenance.timer (alle 15 Minuten) und deploy/wanderbibliothek-backup.timer (täglich).
  • Alternativ Dockerfile und docker-compose.yml, mehrstufig und nur mit Produktionsabhängigkeiten; der Image-Build wird in der CI geprüft.
  • Fehlt etwas Wichtiges in der Konfiguration, startet der Prozess zwar, antwortet aber auf alles mit 500 und dem Grund im Log, statt funktionsfähig zu wirken.
  • tsx ist eine Produktionsabhängigkeit: admin:create, db:backup, user:verify und smoke brauchen es, auch im Image mit npm ci --omit=dev.

Oberfläche und Barrierefreiheit

  • Ein einziges Gerüst (SiteShell) für die internen Seiten; die Startseite ist die Ausnahme und überlässt dem Katalog die volle Breite.
  • Farben kommen aus Tokens (src/app/styles/tokens.css): außerhalb dieser Datei gibt es keine Literale, deshalb teilen helles und dunkles Thema dasselbe Markup.
  • Vier Breakpoints (480, 768, 1120 und 1400/1800 px), Abstandsstaffel von 4 bis 48 px und Bedienelemente mit 40 px, mobil 44 px.
  • Dialoge mit gefangenem Fokus, Schließen per Escape und Fokus-Rückgabe; Sprunglink „Zum Inhalt“, aria-current in der Navigation, Filter mit aria-pressed und Fehler mit role="alert".
  • Mindestkontrast AA, Schrift ab 12 px, Touch-Ziele mit 44 px auf dem Handy und prefers-reduced-motion wird respektiert.
  • Die Oberfläche ist zweisprachig mit Deutsch als Standard: Spanisch kommt über den Umschalter, der das Cookie wanderbibliothek-locale setzt und mit Sitzung die Kontoeinstellung für Hinweise und E-Mails speichert.

Weiterlesen