CASE · 102026 — LIVESOLO · END-TO-ENDSAAS · POSTGRES · pgvector
◦ CAPYPAD · capypad.com

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.

§ 01Problem · motivation

Why this exists.

Anki und Quizlet behandeln Lernen als Karteikarten-Problem. Code-Lesen ist eine eigene Disziplin — und in keinem von beiden zu Hause.

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.

§ 02Constraints · operating box

The box it had to fit in.

Solo-Builder, AI-getriebener Content-Flow, harte Security-Floor. Kein Platz für Microservices, keinen Vendor-Lock, keine offenen Datenbank-Ports.
C/01 · OPS
Solo-Dev, kein Team, keine Microservices, keine Event-Bus-Choreografien. Jede Komponente muss auf einer Box wartbar sein und mit einem Compose-File reproduzierbar.
C/02 · HOSTING
Coolify auf einem dedizierten Hetzner-Server — App und Postgres laufen unter einer Compose-Topologie, Cloudflare als Edge davor, Traefik als Reverse-Proxy. Push to main → Webhook → Coolify rebuildet. Keine Cloud-Lock-Kosten, keine Kaltstart-Latenz.
C/03 · CONTENT-OPS
AI generiert Snippets vom Mac aus, Push in die Live-DB ohne den Postgres-Port zu exposen. Eine HTTPS-Surface, rate-limitiert, mit SHA-256 + timingSafeEqual statt SSH-Tunnel oder public-port-toggle.
C/04 · CONTENT-INTEGRITY
60+60 Rows pro Sprache, AI-generiert, wachsend. Zwei Failure-Modes: Duplikation — Hash-Matching verliert gegen Whitespace-Drift, Rename-Varianten, Kommentar-Shuffles, also muss Dedup semantisch sein — und faktische Bugs, die das Modell mit voller Überzeugung ausgibt. Beides muss auf dem Insert-Path gefangen werden, nicht von einem Cleanup-Cron.
C/05 · AUTH
NextAuth v5 mit Drizzle-Adapter, Google + GitHub als Provider. Anon-User starten Quizzes, Login mid-quiz übernimmt den capypad_run_id sauber — kein verlorener Run-State beim Sign-in.
C/06 · I18N FLOOR
DE und EN gleichberechtigt — keine englische Standard-Stringhalde, keine deutsche Übersetzung als Aftermarket. EN als x-default, hreflang auf jeder öffentlichen Route, beide Locales bei jedem Quiz-Insert parallel gepflegt.
C/07 · CSP
Strikte CSP via per-request nonce, kein 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.
C/08 · SUPPLY-CHAIN
SHA-pinned Base-Images, Trivy in CI (Image + Filesystem), commit-pinned Third-Party-Actions. Was bei npm install oder FROM reinkommt, ist auf Hash gepinnt — nicht auf Tag.
§ 03Architecture · wizard to push

How it runs.

Drei Spuren: oben der User-Request-Pfad (Wizard → Run-Cookie → Auth-Gate → CSP), Mitte die Content-Spur (Embed → HNSW-Dedup → Admin-API → Bounded Body), unten die Cross-Cutting-Concerns (Stars, Rate-Limit, Audit, Deploy).
capypad.com·langs 10·rows 600+·pgvector HNSW·refill 30 RPS·embed 1536d
inserts/h 0·nonces/min 0
WIZARD · 01
SSR step
language → source → settings
flow3-step · server
RUN · 02
capypad_run_id
HttpOnly · cookie binding
statestartRun → finishRun
AUTH · 03
NextAuth v5
Drizzle adapter · 4-case finishRun
modelanon → signed-in
CSP · 04
per-request nonce
strict-dynamic · 16-byte b64
nonces/min0
EMBED · 05
text-embedding-3-small
1536-dim · OpenRouter
embed/min0
HNSW · 06
pgvector index
cosine · O(log n)
dedupinsert-path · soft-flag
ADMIN · 07
POST /api/admin/content
HTTPS · timing-safe key
inserts/h0
BODYCAP · 08
readBoundedBody
1 MB cap · stream cancel
guardper-chunk counter
STARS · 09
optimistic ref
seq · serialised settle
RATE · 10
token-bucket
60 burst · 30 RPS refill
AUDIT · 11
[admin-content] log
event=inserted · JSON-flat
formatflat · grep-ready
DEPLOY · 12
Coolify · Hetzner
SHA-pinned · push-to-deploy
edgeCloudflare · Traefik
EVENT LOG · /api/admin/content · pgvector HNSW · CSP nonce
21:14:08embedtext-embedding-3-small · 1536-dim · ~142ms
21:14:07hnswNN cosine 0.94 · flagged-dup soft-flag
21:14:07adminPOST /api/admin/content · HTTPS · rate-limit ok
21:14:06bodyreadBoundedBody · 1 MB cap · ok
21:14:05cspnonce ok · script-src 'strict-dynamic'
21:14:02starseq 14 · serialized · optimistic ok
21:13:58admin401 · timing-safe · no length oracle
21:13:54audit[admin-content] event=inserted lang=sql n=30
21:14:08embedtext-embedding-3-small · 1536-dim · ~142ms
21:14:07hnswNN cosine 0.94 · flagged-dup soft-flag
21:14:07adminPOST /api/admin/content · HTTPS · rate-limit ok
21:14:06bodyreadBoundedBody · 1 MB cap · ok
21:14:05cspnonce ok · script-src 'strict-dynamic'
21:14:02starseq 14 · serialized · optimistic ok
21:13:58admin401 · timing-safe · no length oracle
21:13:54audit[admin-content] event=inserted lang=sql n=30
§ 04Decisions · trade-offs

Six deliberate choices.

Pro Entscheidung: was gewählt, statt was, warum.
D/01

Drizzle + Postgres + pgvector statt Prisma + SQLite + separater Such-Layer.

chosen
drizzle-orm/pg-core gegen Postgres 18 mit pgvector-Extension, Embeddings als vector(1536)-Spalten direkt im selben Tabellen-Layout
instead of
Prisma 6 + SQLite + ein zweites Such-System (Meilisearch / Typesense)
reason
Der pgvector-Dedup ist ein Gate des Insert-Pfads — kein nachgelagerter Job, kein Sync-Loop. Ein zweites Such-System wäre doppelte Buchführung: dieselben Snippets in zwei Stores, dieselbe Konsistenz zweimal beweisen, dieselben Migrations doppelt fahren. Drizzle gibt mir TypeScript-genaue SQL ohne den Prisma-Engine-Overhead, pg-core spricht direkt mit dem Postgres-Cluster, und der Vector-Index lebt neben den Quiz-Daten in derselben Datenbank. Ein Solo-Dev kann sich keine zweite Datenquelle leisten — jedes zusätzliche System ist eine zusätzliche 3-Uhr-morgens-Pager-Klasse.
D/02

Admin-Content-Push-API statt SSH-Tunnel oder offenem Postgres-Port.

chosen
POST/DELETE /api/admin/content als einzige Operator-Surface, rate-limitiert vor Auth, mit readBoundedBody-Streaming-Cap und SHA-256 + timingSafeEqual auf den Admin-Key
instead of
SSH-Tunnel zum Postgres-Port oder ein temporär offener Public-Port-Toggle
reason
Eine HTTPS-Surface ist eine Surface, nicht drei Netzwerk-Löcher. SSH-Tunnel und Public-Port-Toggle sind beides „einmal vergessen → tot"-Mechaniken; ein Tunnel, der nach dem Push offen bleibt, ist ein offener Postgres-Port mit Vergessens-Garantie. Die Content-API hat einen definierten Body-Cap (1 MB streaming, nicht Content-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.
D/03

In-Memory Token-Bucket hinter RateLimiter-Interface.

chosen
Token-Bucket im Process-Memory, abstrahiert hinter einem RateLimiter-Interface · right-most XFF-Auswertung hinter RATE_LIMIT_TRUST_PROXY-ENV · Refill 30 RPS · Burst 60
instead of
Upstash Redis als Rate-Limit-Backend von Tag eins
reason
Capypad läuft single-instance auf einer Coolify-Box — In-Memory Token-Bucket ist kostenfrei, kein Network-Roundtrip im Hot-Path, deterministisch testbar. Das RateLimiter-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.
D/04

Per-request CSP-Nonce statt unsafe-inline.

chosen
Next.js 16 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-Attribut
instead of
CSP mit unsafe-inline relaxen oder Hash-basiertes script-src pflegen
reason
unsafe-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.
D/05

runs-Tabelle + HttpOnly-Cookie statt URL-Params oder localStorage.

chosen
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-in
instead of
URL-Params (?runId=…) oder localStorage als Run-State-Träger
reason
URL-Params leaken in Referrer-Header, Server-Logs und Browser-History — und sind clientseitig editierbar. localStorage überlebt keinen Tab-Wechsel-Workflow und ist beliebig manipulierbar. Eine DB-Tabelle ist die einzige Stelle, an der ich 4 Auth-Cases sauber abdecke: anon-startet/anon-finished, anon-startet/signed-in-finished (Claim-Flow), signed-in-startet/signed-in-finished, sowie der TOCTOU-Fall „signed-in-User rät einen fremden runId". Der Cookie ist HttpOnly + SameSite=Lax + Secure, der Server prüft auf jedem Schritt actor matches run.userId or run.userId is null. Race-Conditions zwischen finish und parallelem Auth-Wechsel landen alle in einer Postgres-Row mit deterministischer Reihenfolge.
D/06

LLM-as-Judge mit einem cross-vendor-Modell statt dem Generator-Output blind zu vertrauen.

chosen
Nach der AI-Generierung fact-checkt ein zweiter CLI-Provider — nie der, der generiert hat — jedes Item: stimmt die markierte Antwort, fallen die Distraktoren wirklich durch, tut der Code was die Frage behauptet. High-Confidence-Fehler werden vor dem Insert gedroppt; das Gate degradiert bei Timeout oder unparsbarem Output auf Pass-Through, damit ein flakiger Judge die Pipeline nie blockiert.
instead of
Dem Generator-Modell blind vertrauen oder mit demselben Modell self-reviewen, das den Content produziert hat
reason
Ein Modell, das seine eigene Arbeit benotet, erbt seine eigenen Blind Spots — dieselbe Fehlannahme, die eine falsche Antwort produziert hat, winkt sie im Review durch. Ein anderer Vendor als Judge bricht diese Korrelation. Der retroaktive Sweep hat den Einsatz gezeigt: ~10% der Expert-Snippets im Live-Korpus trugen faktische Bugs — fehlende Imports, falsche 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.
§ 05Highlights · interesting bits

Things that were not obvious.

Edge-Cases und Details, die erst beim Bauen klar wurden.

HNSW für O(log n) NN-Search

H/01
Naive Lösung: sequenzieller Scan über (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

H/02
Der naheliegende Body-Cap ist: 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

H/03
Klassischer Fehler: 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

H/04
Star-Toggle naiv: 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

H/05
Der Judge, das Topic-Coverage-Steering, der Byte-identische-Code-Reject — alle laufen zur Insert-Zeit. Sie schützen jede Row, die nach ihrem Ship geschrieben wird — und nichts, was schon in der Tabelle steht.

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.
§ 06Stack · in production

What's running.

Working toolchain — nichts Theoretisches.
Next.js 16 · App RouterTypeScript 5Drizzle pg-corePostgres 18 + pgvectorNextAuth v5 · Drizzle adapterTailwind 4Gemini · Claude · Opencode · Codex CLILLM-as-Judge · cross-vendor gateOpenRouter · text-embedding-3-small · 1536dToken-bucket · RateLimiter ifaceCSP nonce · proxy.tsTrivy CI · image + fsCoolify · dedicated HetznerCloudflare · Traefik · push-to-deployDocker · SHA-pinned
§ 07Reflection · takeaways

What I learned.

Live als SaaS auf capypad.com. Diese Dinge nehme ich in die nächsten mit.

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.

◦ NEXT CASE · 11 / 11
cvmake
← alle Projekte