Every snippet earned. Every dedupe semantic.
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.
Why this exists.
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 kuratierten 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 Byte-identischer-Code-Reject 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.
The box it had to fit in.
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, jeder Inline-Script (inkl. JSON-LD) trägt ihn.npm install oder FROM reinkommt, ist auf Hash gepinnt — nicht auf Tag.How it runs.
Six deliberate choices.
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.
POST/DELETE /api/admin/content als einzige Operator-Surface, rate-limitiert vor Auth, mit readBoundedBody-Streaming-Cap und SHA-256 + timingSafeEqual auf den Admin-KeyContent-Length-trust), eine separate 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, reine 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 RATE_LIMIT_TRUST_PROXY-ENV · Refill 30 RPS · Burst 60RateLimiter-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 Karenz-Pflege. 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, nicht eine 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 gated 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.Things that were not obvious.
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 — derselbe 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 gebunden, egal was der Header behauptet. MAX_BODY_BYTES ist 1 MB, hart in der Admin-Content-Route. Wenn die Stream-Cancel 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 der Rate-Limit erst greift, nachdem die Crypto-Vergleichs-Pipe schon durch ist.Lösung: separater
adminLimiter-Bucket, 10 req/min pro IP, geprüft BEFORE verifyAdminKey. Der Bucket ist eigenständig 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 eine Pending-Intent; nach Settle wird die Pending-Intent gegen den confirmed-Zustand abgeglichen und nur dann gefeuert, wenn sie divergiert. Schließt die DB-Last-Write-Wins-Inversion an der UI-Schicht — keine Server-seitige Lock-Coordination 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 Faktische-Bug-Verdicts 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.What's running.
What I learned.
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 Live-Traffic 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 rejected; 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 öffnet einen Postgres-Port, der nach dem Push offen bleibt; Public-Port-Toggle öffnet ihn explizit, lebt aber davon, dass ich ihn manuell schließe. Beide haben dieselbe Failure-Mode: ein vergessener Schritt = ein offenes Loch im Internet.
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.