Behebt live verifiziertes Auth-Bypass auf cardecky-api.mana.how
(X-User-Id → founder-Tier) und zieht im selben Patch das fehlende
Operations-/Compliance-Fundament nach.
* Auth-Middleware fail-secure: opt-in via CARDS_AUTH_DEV_STUB="true"
(war opt-out, Default true). Compose-Default flipped auf "false",
NODE_ENV="production" für cards-api ergänzt, env-Template
dokumentiert. vitest.config.ts + tests/setup.ts aktivieren den
Stub gezielt für Test-Suiten.
* Security-Headers: Hono secureHeaders() in apps/api,
SvelteKit hooks.server.ts mit X-Frame/X-Content-Type/Referrer/
HSTS in apps/web. CSP bewusst ausgelassen — eigener Sprint.
* CORS-localhost-Whitelist nur außerhalb Prod.
* Rate-Limiting (in-memory sliding window, dependency-frei) auf
share.receive 60/min/IP, media.upload 30/min/user,
decks.generate + decks.from-image 10/min/user, dsgvo.* 10/min/IP.
* Health-Endpoint mit echter DB- und MinIO-Probe; /healthz bleibt
Liveness, /healthz/details ist Readiness mit 503 bei Failure.
* DSGVO-Honesty: storage_ok + storage_error im Response (statt
schluckend console.warn), Account-UI zeigt Fehler-Toast.
* Audit-Log: strukturierte JSON-Zeile (kind: "audit") auf stdout
für /dsgvo/export, /dsgvo/delete, /me/export, /me/delete.
* Bug-Fix: duplizierte case "multiple-choice"-Clause in fsrs.ts.
Verifiziert: apps/api 17 Files / 104 Tests grün, apps/web check
0 errors. Deploy auf Mac Mini steht noch aus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull-Requests (Diff-Modell add/modify/remove, GitHub-style 3-way
merge in der DB-Transaktion):
- POST /decks/:slug/pull-requests (auth) — neuer PR mit
diff.{add,modify,remove}; previousContentHash für modify identifiziert
die zu ersetzende Karte by content-hash, type bleibt aus dem alten
Eintrag (modify ist field-only Replace)
- GET /decks/:slug/pull-requests (optional-auth) — Liste mit Status-
Filter (open/merged/closed/rejected)
- GET /pull-requests/:id (optional-auth) — Detail
- POST /pull-requests/:id/close (auth) — Author oder Deck-Owner
- POST /pull-requests/:id/reject (auth) — nur Deck-Owner; getrennt
von close, damit der PR-Author klares Feedback hat
- POST /pull-requests/:id/merge (auth) — nur Deck-Owner; baut neue
card-list aus latest version + diff (removes weglassen, modifies
fields-replace, adds anhängen mit re-counted ord), schreibt
publicDeckVersions + publicDeckCards atomar in einer Drizzle-
Transaction, bumpt latestVersionId und setzt PR auf merged.
Default-Semver-Bump: minor (1.0.0 → 1.1.0). Authorenüberschreibbar
via mergeNote/newSemver-Body-Felder.
Card-Discussions (Threads pro card_content_hash, überleben
Versions-Bumps solange Karten-Inhalt bleibt):
- POST /decks/:slug/cards/:hash/discussions (auth) — neuer Thread
oder Reply (parent_id muss in derselben card_content_hash-Gruppe
leben → 422 sonst)
- GET /cards/:hash/discussions (optional-auth) — Liste sichtbarer
Comments, hidden gefiltert
- GET /decks/:slug/discussions/counts (optional-auth) — Bulk-Count
pro card_content_hash für Deck-Übersicht (kein N+1)
- POST /discussions/:id/hide (auth) — Soft-Hide (Author oder Deck-
Owner); kein Delete, Audit-Trail bleibt
Helpers:
- lib/marketplace/semver.ts — bumpMinor, isSemver, semverGreater
(klein, ohne Range-Logik). Wird von PRs + später vom decks.ts
publish-Flow konsumiert.
Bug-Fix:
- routes/marketplace/fork.ts hatte r.use('*', authMiddleware) am
Anfang. An dem /api/v1/marketplace-Mount-Punkt fängt das Wildcard
alle nachfolgenden Router-Mounts (PRs, Discussions) → anonymer
GET /pull-requests wurde mit 401 abgelehnt. Refactor auf per-
route authMiddleware (Pattern wie in subscriptions.ts und
engagement.ts seit R3). Lessons learned dokumentiert in der
STATUS.md-Zeile.
Verifikation:
- type-check 0 errors
- 11 neue Semver-Tests, 89 gesamt grün
- E2E-Smoke gegen lokale cards-api durch:
· Cardecky publisht v1.0.0 (Apatheia, Eudaimonia, Logos)
· Till's PR: modify Eudaimonia-Back, remove Logos, add Tugendlehre
· Till's Merge-Versuch → 403 (deck_owner_only)
· Cardecky merged → v1.1.0 atomar, card_count=3, ord-Reihenfolge:
[Apatheia, Eudaimonia-mit-neuem-Back, Tugendlehre]
· Re-Merge → 409 (pr_already_merged)
· Till's Discussion-Thread + Cardecky-Reply mit parent_id
· Cross-Card-parent abgelehnt → 422
· Hide → Comment verschwindet aus Liste, total von 2 auf 1
· Bulk-Counts liefert {hash → 2}
· Smart-Merge-Pull v1.0.0→v1.1.0 wertet PR-Merge korrekt aus
(changed=2 via Eudaimonia + Logos↔Tugendlehre ord-Heuristik)
Verbleibend: R5 Frontend-Routes (/explore, /d/[slug], /u/[slug],
/me/{published,subscribed,forks}), R6 voller UI-E2E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes (additiv unter /api/v1/marketplace/*):
- POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen
- GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt)
- POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil +
CHECK auf paid + Pro-License)
- POST /decks/:slug/publish — Versions-Snapshot mit per-Karte
cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log,
atomarer latest_version_id-Bump in Drizzle-Transaction
- PATCH /decks/:slug — Metadaten-Update (Owner-Only)
- GET /decks/:slug — Public-Detail mit optional-auth-Middleware
Geport aus cards-decommission-base:services/cards-server/, mit
Greenfield-Anpassungen:
- Hashing über @cards/domain.cardContentHash (gemeinsame SoT
zwischen privatem cards.cards und marketplace.deck_cards), per-
Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix
- AI-Moderation als R2-Stub (pass+rationale+model='stub'),
echte mana-llm-Anbindung in späterer Welle
- Auth-Middleware-Shape an Greenfield (userId/tier/authMode in
c.get(...) statt user-Object), optional-auth als Schwester für
anonymen Public-Read
- Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars>
weil Public-GET kein JWT braucht; Auth-Subroute ist strict
Lese-Referenz:
- 3331 LOC altes cards-server-Code (routes, services, middleware,
lib) unter docs/marketplace/archive/code/ archiviert. Read-only,
nicht im Build-Path.
Verifikation:
- 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün
- type-check 0 errors
- E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck
r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze),
per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409
+ paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt.
Verbleibend für R3+: Discovery (explore + search), Engagement (stars/
subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs +
Card-Discussions, R5 Frontend-Routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eigener cards-minio-Container im docker-compose (9100/9101 — Plattform
auf 9000/9001 bleibt isoliert). cardsadmin/cardsadmin als Dev-Default,
prod via env-Vars (CARDS_S3_*).
apps/api/src/services/storage.ts — schmaler StorageService um den
minio-Client. ensureBucket() ist idempotent (auto-create beim ersten
Upload). removeObjectsByPrefix() implementiert den DSGVO-Bucket-Sweep,
weil die S3-API kein Cascade kennt.
Neue Tabelle media_files in pgSchema('cards'):
id, user_id, object_key, mime_type, original_filename, size_bytes,
kind, created_at — kein FK auf cards (ein File kann mehreren Karten
gehören). objectKey-Format <userId>/<ulid>.<ext> für Bucket-Prefix-
Sweep beim DSGVO-Delete. Legacy mediaRefs bleibt als Slot.
Neuer Router /api/v1/media:
POST /upload — multipart, 25 MiB Default-Limit, image/audio/video
only (415 sonst), schreibt media_files-Row + speichert
in MinIO unter <userId>/<ulid>.<ext>
GET /:id — streamt aus MinIO mit Cache-Control: private,
immutable. Cross-User → 404 (nicht 403, anti-enumeration).
GET / — listet alle eigenen Files
DSGVO-Pfade (Service-Key + /me/delete) räumen jetzt auch media_files
+ MinIO-Bucket-Prefix mit ab. Storage-Sweep ist non-fatal — DB ist erst
konsistent gelöscht, dead bytes wären die schlimmstmögliche Folge.
Anki-Import: parse.ts sanitizeAnkiHtml akzeptiert wieder eine
Filename→URL-Map (war in Phase 8c gedroppt). import.ts lädt vor den
Karten alle referenzierten Media-Files via uploadMedia() in MinIO,
sammelt URLs, ersetzt Anki-Filenames durch /api/v1/media/<id>-Pfade
in `<img>` (Markdown) und `[sound:…]` (HTML <audio>). 4-fache Worker-
Concurrency.
apps/web/src/lib/markdown.ts: DOMPurify lässt jetzt <audio>/<video>/
<source> mit src/controls/preload-Attributen durch — sonst würden die
Audio-Tags aus dem Anki-Import gestrippt.
i18n-Strings (DE/EN) auf Media-Stage erweitert: stage_media,
done_media, what_works_media, dropzone_hint, preview_media.
import.what_skipped_media wird zur Bestätigung dass Media seit
Sprint 9k mit übernommen wird.
Manueller E2E-Smoke gegen lokale MinIO (cards-minio :9100):
- 1×1-PNG hochgeladen → 201 mit ID + URL
- /api/v1/media/<id> streamt 200 image/png 69 bytes (file-Identifikation
bestätigt)
- Cross-User → 404, ohne X-User-Id → 401, text/plain → 415
53 API-Tests grün (+4 neue media-Auth-Gate-Tests), 7 Web-Tests,
51 Domain-Tests, type-check + svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neuer Domain-Helper cardContentHash({ type, fields }) — SHA-256 über
canonisierten JSON ({type, sorted-fields}), pure Web-Crypto. Field-
Reihenfolge ist invariant; Whitespace + Cloze-Markup zählen mit
(zwei Karten mit identischem Text aber unterschiedlichem
{{c1::…}}-Markup sind verschiedene Karten).
cards-API POST schreibt content_hash automatisch in den schon
existierenden Schema-Slot. Neuer Endpoint GET /api/v1/cards/hashes
liefert die kompakte Hash-Liste des Users (ohne Card-Body) — eine
Anfrage pro Anki-Import statt pro Karte.
apps/web/src/lib/anki/import.ts holt die Hashes vor dem Loop und
prüft pro Karte clientseitig. Duplikate werden gezählt
(cardsSkippedDuplicate) und übersprungen, der Counter erscheint
in der AnkiImport-Done-View. Same-File-Drift (Anki-interne
Doppel-Notes) wird auch erkannt — nach erfolgreichem Insert
landet der Hash sofort im Set.
Fallback: wenn /hashes fehlschlägt (älterer Server), bleibt das
Dedupe-Set leer und Karten werden eingefügt wie zuvor — kein
Hard-Bruch.
Pre-Phase-9j-Karten haben null content_hash (Hashes-Endpoint
filtert sie weg) — sie können also irrtümlich erneut eingespielt
werden, falls noch im Anki-File. Pragmatisch akzeptiert: ein
Backfill-Script wäre Phase-10-Polish, sobald Live-User da sind.
5 neue Domain-Tests, 1 neuer API-Auth-Gate-Test (105 grün ges.:
51 + 49 + 5). svelte-check 380 files 0 errors. E2E gegen lokale
Postgres bestätigt: neue Karte hat content_hash (64-char-hex),
/hashes listet sie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neuer User-JWT-Pfad GET/POST /api/v1/me/{export,delete} — gespiegelte
DSGVO-Logik aus dem Service-Key-Pfad, aber gegen die eigene User-ID
gated. buildUserExport extrahiert in dsgvo.ts und wird von beiden
Routern geteilt.
/account-Page zeigt User-ID, Logout, JSON-Daten-Export (Download als
Blob), und einen rot-markierten Account-Delete-Knopf mit "LÖSCHEN"-
Confirmation. Logout im Header verlinkt jetzt auf /account statt
direkt clear() — der User sieht zuerst, was an seinem Account hängt.
Andere mana-Apps werden nicht mit gelöscht — der UI-Hinweistext zeigt
auf die spätere Verein-DSGVO-Sammelanfrage über mana-admin.
48 API-Tests grün (+2 neue auth-gate-Tests für /me), web type-check
374 files 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CardTypeSchema öffnet 'cloze' als drittes MVP-Set-Mitglied. Domain-Modul
@cards/domain/src/cloze.ts kapselt die Cluster-Logik (extractClusterIds,
subIndexCountForCloze, clusterIdForSubIndex, renderClozePrompt/Answer)
— Hint-Markup wird MVP-stumm gedroppt.
subIndexCount('cloze') wirft jetzt explizit, statt still auf 1 zu fallen,
weil die Cluster-Anzahl text-abhängig ist und ein silent-default falsch
dimensionierte Review-Tabellen produzieren würde. Card-POST-Handler holt
für Cloze die Anzahl aus subIndexCountForCloze und lehnt 422 ab, wenn
kein {{cN::…}}-Markup vorhanden ist.
12 neue Cloze-Tests, alle Domain- und API-Tests grün (41 + 46).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>