Jedes Snippet erarbeitet. Jede Dublette semantisch erkannt.
SSR-first Code-Quiz für 10 Sprachen mit AI-generierten Snippets, durch eine Admin-Content-Push-API in die Live-Postgres gepusht, statt einen DB-Port zu exposen. pgvector-HNSW-Dedup beim Insert, Per-Request-CSP-Nonce, Token-Bucket vor Auth. Next.js 16 · Drizzle · Postgres + pgvector · NextAuth v5 · Coolify auf Hetzner.

Warum es das gibt.
Code zu lesen ist die unterschätzte Hälfte der Programmier-Praxis. Senior-Devs verbringen mehr Zeit im fremden Code als im eigenen, aber kein Lern-Tool trainiert das gezielt. Anki ist generisch — wer Code drin hat, hat Code in Plaintext. Quizlet ignoriert Syntax. LeetCode misst Lösen, nicht Lesen. Capypad schließt diese Lücke: SSR-Code-Quiz mit AI-generierten Snippets für 10 Sprachen, in 60+60 geprüften Zeilen pro Sprache aufgebaut, jede Quiz-Frage in DE und EN parallel.
Der spannende Teil ist nicht das Quiz an der Oberfläche. Der spannende Teil ist die Content-Pipeline darunter: ein Vier-Gate-Insert-Path — Topic-Coverage-Steering, ein Cross-Vendor-LLM-as-Judge, ein Reject für byte-identischen Code und pgvector-Semantik-Dedup — den keine AI-generierte Row ohne Bestehen passiert; eine Admin-Content-Push-API als einzige Schreib-Surface vom Operator; eine Run-State-Maschine über eine runs-Tabelle und einen HttpOnly-Cookie. Die smarten Teile sind an der Edge unsichtbar — und im Code offensichtlich.
Der Rahmen, in den es passen musste.
main → Webhook → Coolify rebuildet. Keine Cloud-Lock-Kosten, keine Kaltstart-Latenz.timingSafeEqual statt SSH-Tunnel oder Public-Port-Toggle.capypad_run_id sauber — kein verlorener Run-State beim Sign-in.unsafe-inline. Der Nonce wird in proxy.ts generiert, per Header an die Server-Components durchgereicht, jedes Inline-Script (inkl. JSON-LD) trägt ihn.npm install oder FROM reinkommt, ist auf Hash gepinnt — nicht auf Tag.Wie es läuft.
◦ Schematische Darstellung, Zahlen illustrativ.
Sechs bewusste Entscheidungen.
Drizzle + Postgres + pgvector statt Prisma + SQLite + separater Such-Layer.
drizzle-orm/pg-core gegen Postgres 18 mit pgvector-Extension, Embeddings als vector(1536)-Spalten direkt im selben Tabellen-LayoutAdmin-Content-Push-API statt SSH-Tunnel oder offenem Postgres-Port.
readBoundedBody-Streaming-Cap und SHA-256 + timingSafeEqual auf den Admin-KeyContent-Length-trust), einen separaten Rate-Limit-Bucket vor dem Key-Vergleich (Brute-Force wird vor der Crypto gebremst), und timingSafeEqual verhindert, dass die Schlüssel-Länge per Timing leakt. Der Push-Client kommt vom Mac, reines HTTPS, Cloudflare-fronted — und der Postgres-Port bleibt im Compose-Netz.In-Memory-Token-Bucket hinter RateLimiter-Interface.
RateLimiter-Interface · Right-most-XFF-Auswertung hinter einem expliziten Proxy-VertrauensschalterRateLimiter-Interface ist eine One-File-Swap-Versicherung: wird je horizontal skaliert, tausche ich die Implementierung gegen einen Upstash-Adapter, ohne eine einzige Call-Site zu ändern. Right-most-XFF-Auswertung hinter ENV-Toggle: hinter Cloudflare/Traefik vertraue ich dem rechtesten Proxy-Hop, ohne Proxy nehme ich die Socket-IP — keine Header-Spoof-Lücke. Premature Optimization wäre Upstash; Premature Coupling wäre direkter Map-Zugriff.Per-Request-CSP-Nonce statt unsafe-inline.
proxy.ts generiert pro Request einen 16-Byte-Base64-Nonce, propagiert ihn via x-nonce-Header an Server-Components, jeder Inline-Script-Tag (auch JSON-LD) trägt das nonce-Attributunsafe-inline kippt die ganze CSP-Hygiene — XSS-Resilienz fällt auf null, der Strict-CSP-Lighthouse-Win ist weg. Hash-basiertes script-src ist konsistent, aber jeder Build-Output muss neu gehasht werden, jede dynamisch erzeugte JSON-LD-Spur wird zur Dauerpflege. Ein Per-Request-Nonce ist die Lösung, die Next.js 16 nahelegt: proxy.ts → Header → Server-Component liest headers().get("x-nonce") → Inline-Script. Die einzige Disziplin: jeder neue <Script> braucht den Nonce, sonst CSP-Block. Das ist genau die Sorte Disziplin, die ich will — ein fehlender Nonce ist ein Lighthouse-Drop, keine stille XSS-Tür.runs-Tabelle + HttpOnly-Cookie statt URL-Params oder localStorage.
runs-Tabelle als State-Quelle der Wahrheit · HttpOnly-Cookie capypad_run_id als Client-Token · startRun/finishRun als Server-Actions · 4-Case-Auth-Modell für anon ↔ signed-infinish und parallelem Auth-Wechsel landen alle in einer Postgres-Row mit deterministischer Reihenfolge.LLM-as-Judge mit einem cross-vendor-Modell statt dem Generator-Output blind zu vertrauen.
instanceof-Behauptungen, Security-Antipattern als Pattern gelehrt. Er hat auch gezeigt, dass der Judge nur so gut ist wie das Modell dahinter — ein Provider lag bei einer 52%-False-Positive-Rate, flaggte überzeugt korrekten Code wegen veraltetem Framework-Wissen. Der Judge gatet neue Inserts automatisch, aber ein vom Sweep geflaggter Bestand kriegt ein Human- (oder Besseres-Modell-) Review, bevor irgendetwas gelöscht wird — Defense-in-Depth, nie ein Auto-Purge.Was nicht offensichtlich war.
HNSW für O(log n) NN-Search
(language, quizLanguage) mit Kosinus-Distanz pro Insert. Bei 600 Rows fein, bei 6000 nicht mehr — und der Insert-Loop selbst würde linear wachsen.Lösung: HNSW-Index mit
vector_cosine_ops auf embedding_code und embedding_text jeweils. Der Insert-Pre-Check ist eine Sub-Millisekunden-NN-Query, log-scaling, und die beiden Embedding-Spuren (Code-Text und natürliche Beschreibung) fangen unterschiedliche Duplikate ab — dasselbe Snippet mit umbenannten Variablen kollidiert auf embedding_code, dieselbe Lernintention mit anderem Snippet kollidiert auf embedding_text.Streaming readBoundedBody
if (request.headers["content-length"] > MAX) reject. Falsch. Content-Length ist ein Header, den der Client schreibt — ein Angreifer kann 0 melden und 1 GB senden, und die naive Lösung lässt es durch.Lösung: Per-Chunk-Counter im Stream-Reader, plus
reader.cancel() bei Overflow. Der Memory-Footprint ist begrenzt, egal was der Header behauptet. MAX_BODY_BYTES ist 1 MB, hart in der Admin-Content-Route. Wenn der Stream-Abbruch ausgelöst wird, kommt 413 zurück — kein OOM, kein partielles Parsing eines Halb-JSONs.Rate-Limit vor Auth
verifyAdminKey() zuerst, Rate-Limit als Mitigation danach. Ergebnis: ein Brute-Force-Angreifer kann ungebremst Schlüssel raten, weil das Rate-Limit erst greift, nachdem die Crypto-Vergleichs-Pipe schon durch ist.Lösung: separater, deutlich strengerer
adminLimiter-Bucket, geprüft VOR verifyAdminKey. Der Bucket ist unabhängig vom User-Limiter — Admin-Brute-Force beißt sich am Rate-Limit fest, lange bevor sie an timingSafeEqual kommt. Die Reihenfolge ist nicht egal: Defense-in-Depth heißt, jede Schicht muss alleine greifen.Optimistic Star mit serialisiertem Commit
setStarred(!starred) + Server-Action. Bei zwei schnellen Klicks landet die zweite Antwort manchmal vor der ersten, das UI flippt, und der DB-Endzustand ist Last-Write-Wins — vom Server, nicht vom letzten User-Klick.Lösung: drei Refs (
starRequestSeq, confirmedStarred, starInFlight) serialisieren jeden Klick gegen das vorherige Settle. Solange starInFlight hochsteht, queued der nächste Klick einen Pending-Intent; nach Settle wird der Pending-Intent gegen den bestätigten Zustand abgeglichen und nur dann gefeuert, wenn er divergiert. Schließt die DB-Last-Write-Wins-Inversion an der UI-Schicht — keine serverseitige Lock-Koordination nötig.Generation-Gates schützen den Bestand nicht
Lösung: ein retroaktiver Audit, der den ganzen Korpus per Keyset-Pagination durch dieselbe Judge-Schicht schiebt, crash-safe über einen
after-Cursor, der die Verdicts zu faktischen Bugs in eine judge_flag-Spalte schreibt. Ein 200-Konzept-Random-Sample las 5 %; der Expert-Difficulty-Sweep las ~10 %. Der nicht-offensichtliche Teil: der Sweep braucht einen eigenen Write-Back-Pfad — die Generation-Gates rejecten, aber ein Bestands-Audit muss in-place flaggen, damit ein Mensch reviewt, bevor gelöscht wird. Insert-Zeit und Audit-Zeit sind zwei verschiedene Probleme, die denselben Judge tragen.Was läuft.
Was ich gelernt habe.
Insert-Path-Gates schlagen den Cleanup-Cron — für neue Rows.
Mein erster Reflex bei schlechtem Content war ein nächtlicher Cleanup-Job: inserten, dann offline scannen, gruppieren, mergen oder löschen. Kosten: ein Job mit eigener Logik, eigener Migration für die Merge-History und einer Antwort auf „was passiert mit laufenden Runs während des Sweeps“ — und die schlechten Rows sind längst in User-Runs, wenn er feuert. Mit dem Vier-Gate-Insert-Path ist der Pre-Check Millisekunden; der Cron-Job wird nie geschrieben, weil die schlechten Rows nie landen.
Der ehrliche Vorbehalt: Insert-Gates schützen nur, was nach ihrem Ship geschrieben wird. Der Pre-Gate-Bestand brauchte trotzdem genau einen retroaktiven Sweep — und das ist ein anderes Werkzeug. Insert-Zeit rejectet; Audit-Zeit flaggt in-place fürs Review. Derselbe Judge, zwei Probleme. Bau das Gate zuerst; plane den einen Sweep ein, der die History nachzieht.
Eine HTTPS-Surface schlägt drei Netzwerk-Löcher.
Die Mac → Coolify Push-API ist nicht Bequemlichkeit, sie ist Reduktion der Angriffsfläche. Die Alternativen waren beide einmal-vergessen-ist-tot: SSH-Tunnel hängen an Schlüsseln und Skripten auf einem Laptop; ein Public-Port-Toggle öffnet den Postgres-Port explizit und lebt davon, dass ich ihn manuell schließe. Beide hängen an Dingen außerhalb des Servers, die man vergisst oder verliert.
Eine HTTPS-Route mit Body-Cap, Rate-Limit-vor-Auth und timing-safe-Key-Vergleich ist aufwändiger zu bauen, aber stateless: vergessen ist nicht möglich, weil es nichts zu vergessen gibt. Der Postgres-Port bleibt im Compose-Netz, der Operator-Workflow läuft über Cloudflare-fronted HTTPS. Solo-Dev-Security heißt: Mechanismen, die ohne Disziplin funktionieren — nicht Mechanismen, die mit Disziplin funktionieren.