Tool-Pfad in /api/v1/tools/cards.create war nicht konvergent zum
REST-Pfad in /api/v1/cards POST:
- subIndexCount(type) crashte bei type='cloze' und 'image-occlusion'
(beide werfen seit Sprint 8a/9l, weil Sub-Index-Anzahl text-abhängig)
- content_hash wurde nicht geschrieben (war seit Sprint 9j auf REST-Pfad)
Fix: identische Branching-Logik wie cards.ts POST. Cloze ohne {{cN::…}}
und image-occlusion ohne valides mask_regions-JSON liefern 422.
content_hash wird mit cardContentHash beim Insert geschrieben.
Damit ist der Tool-Pfad voll-konvergent — mana-mcp und Persona-Runner
können jeden Card-Type via cards.create anlegen, sobald die Plattform-
Services (mana-share + mana-mcp) deployed sind.
Phase-7-Plumbing:
✓ Cards-Tools (cards.create + cards.search) sind konvergent zum
REST-Pfad und end-to-end via Bearer-JWT verifiziert
✓ App-Manifest deklariert beide Tools (input_schema + output_schema)
✓ Service-Key in mana-auth registriert (Phase 2)
✗ mana-mcp + mana-share Container sind auf Mac Mini NICHT deployed
→ Tool-Discovery + Routing aus Claude Desktop / Persona-Runner
bleiben offen, bis die Plattform-Services hochgezogen werden.
Das ist Plattform-Scope, nicht Cards-Scope.
56 API-Tests grün, type-check sauber.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auth-middleware setzt jetzt zusätzlich `tier`-Claim aus dem JWT
(public/beta/alpha/founder, default public bei unbekannt) und
`authMode` (jwt|dev-stub). Dev-Stub bekommt founder-Tier — Tests
und der Anki-Importer (X-User-Id) bleiben unbeeinträchtigt.
`requireTier(min)`-Middleware-Helper für künftige Premium-Pfade,
liefert 403 mit `{need, have}` bei Tier-Unterlauf. Aktuell nicht
auf Cards-Endpoints angewendet — der MVP ist tier-frei für jeden
authentifizierten User. Plumbing ist da, sobald Premium-Features
auftauchen (AI-Bulk-Convert, Image-Generation, …).
services/credits-client.ts: schmaler Service-to-Service-Wrapper um
mana-credits (https://credits.mana.how/api/v1/internal/*) für
balance / reserve / commit / refund-reservation. Auth via
X-Service-Key (Cards-App-Key aus mana-auth, env
CARDS_MANA_SERVICE_KEY). Cards-MVP nutzt das nicht produktiv —
ist der Pre-Wire-Layer für später.
56 API-Tests grün, type-check sauber.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cards-api akzeptiert jetzt zwei Auth-Quellen:
1. Authorization: Bearer <jwt> — JWT aus mana-auth, gegen
/api/auth/jwks gecached, sub→userId
2. X-User-Id: <uuid> — Dev-Stub, bleibt aktiv
Schalter: CARDS_AUTH_DEV_STUB=false deaktiviert den Dev-Stub
hart (für die spätere Phase-10b-Cutover-Stufe). Default behält
beide Pfade — Tests + Anki-Importer + Sprint-9-User-Smokes
laufen unverändert weiter.
Cards-App ist heute in mana-auth (auth.mana.how) registriert
(app_id='cards', ownership_kind='verein', status='active'),
Service-Key generiert + per direktem DB-Insert in
auth.app_service_keys gelegt (Migrations-Tabellen waren in
prod-DB noch nicht angewendet, jetzt nachgezogen).
56 API-Tests grün, jose als neue Dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cards-domain/tsconfig.json extends ../../tsconfig.base.json — ohne
das File schlägt vite-build im web-Container fehl mit
'failed to resolve extends'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
infrastructure/docker-compose.production.yml mit 4 Services:
- cards-postgres :5436 (Plattform-Postgres :5432, Dev :5435 belegt)
- cards-minio :9110/9111 (Plattform-MinIO :9000/9001 belegt)
- cards-api :3091 (alt war :3072 — Cutover via Tunnel-Reroute)
- cards-web :5181 (alt war :5180)
Persistente Volumes auf /Volumes/ManaData/cards/{postgres,minio} —
außerhalb des Repo-Verzeichnisses (überlebt repo-wipes, gleicher
Pfad wie mana-platform-Daten).
Dockerfiles:
- apps/api: oven/bun:1.1-alpine, single-stage. pnpm via npm install.
Verdaccio-Auth via NPM_AUTH_TOKEN-Build-Arg + .npmrc.
- apps/web: 2-stage node:20-alpine. SvelteKit-build mit
PUBLIC_CARDS_API_URL als Build-Arg (kommt direkt in den
Client-Bundle via vite). Runtime startet adapter-node-Bundle
direkt mit `node build/index.js`.
infrastructure/.env.production.example als committable Skeleton —
echte .env.production bleibt git-ignored. Vier Secrets nötig:
CARDS_DB_PASSWORD, CARDS_S3_SECRET_KEY, CARDS_DSGVO_SERVICE_KEY,
NPM_AUTH_TOKEN.
Hard-Cutover-Plan: alte mana-app-cards-{server,web} bleiben kurz
parallel laufend, Tunnel zeigt nach dem Build/Verify-Cycle auf die
neuen Container, dann werden die alten gestoppt + entfernt.
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 Endpoint GET /api/v1/me/stats liefert in einem Aufruf alle
Aggregate für die Stats-UI:
- total_decks / total_cards / total_reviews / due_now
- state_counts pro FSRS-Zustand (new/learning/review/relearning)
- reviewed_per_day für die letzten 7 Tage (Quelle: reviews.last_review,
via to_char(day, 'YYYY-MM-DD') auf Postgres-Seite gruppiert)
- streak_days (rückwärts ab heute bis zum ersten Tag ohne Review)
study_sessions wird aktuell NICHT befüllt — der Schema-Slot existiert
seit Phase 3, aber der Session-Tracker kommt erst, wenn das Lern-
Flow-Layer ausgebaut wird. last_review reicht für jetzt.
/stats-Page rendert vier KPI-Cards, einen 7-Tage-Säulen-Chart per
CSS-Heights, plus eine FSRS-State-Distribution. Header-Nav um
"Statistik" ergänzt.
E2E-Smoke gegen lokale Postgres bestätigt: bestehender Cloze-User
zeigt 1 Deck, 1 Karte, 2 Reviews, 2 due, alle "new"-State, 0
Streak — passt zum gestern eingespielten Smoke-User.
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>