From e1ddbf34b35d586277e2e39be0b96c42d463c20b Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 12 May 2026 16:56:03 +0200 Subject: [PATCH] security(cards): fail-secure dev-stub, headers, rate-limit, dsgvo audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/index.ts | 16 +- apps/api/src/lib/audit.ts | 34 ++ apps/api/src/middleware/auth.ts | 4 +- apps/api/src/middleware/rate-limit.ts | 99 +++++ apps/api/src/routes/decks-from-image.ts | 3 + apps/api/src/routes/decks-generate.ts | 3 + apps/api/src/routes/dsgvo.ts | 49 ++- apps/api/src/routes/health.ts | 74 +++- apps/api/src/routes/me.ts | 41 +- apps/api/src/routes/media.ts | 5 + apps/api/src/routes/share.ts | 3 + apps/api/tests/rate-limit.test.ts | 78 ++++ apps/api/tests/setup.ts | 9 + apps/api/vitest.config.ts | 7 + apps/web/src/hooks.server.ts | 25 ++ apps/web/src/lib/api/me.ts | 19 +- apps/web/src/routes/account/+page.svelte | 19 +- docs/FEATURE_IDEAS.md | 409 ++++++++++++++++--- infrastructure/.env.production.example | 7 + infrastructure/docker-compose.production.yml | 6 +- packages/cards-domain/src/fsrs.ts | 2 - 21 files changed, 832 insertions(+), 80 deletions(-) create mode 100644 apps/api/src/lib/audit.ts create mode 100644 apps/api/src/middleware/rate-limit.ts create mode 100644 apps/api/tests/rate-limit.test.ts create mode 100644 apps/api/tests/setup.ts create mode 100644 apps/api/vitest.config.ts create mode 100644 apps/web/src/hooks.server.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c50a9d4..ab43be4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; +import { secureHeaders } from 'hono/secure-headers'; import { manifestRoute } from './routes/manifest.ts'; import { healthRoute } from './routes/health.ts'; @@ -25,14 +26,23 @@ import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/mark const app = new Hono(); +const IS_PROD = process.env.NODE_ENV === 'production'; + +app.use('*', secureHeaders()); + app.use( '*', cors({ origin: (origin) => { if (!origin) return origin; - // Dev: localhost-Ports erlaubt. Prod: explizite Whitelist. - if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin; - if (/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin)) return origin; + // Lokal-Dev: localhost-Ports erlaubt. Prod: keine localhost-Origins, + // damit eine lokale Dev-Instanz nicht Cross-Origin gegen die Live-API + // arbeiten kann (Defense-in-Depth — JWT liegt zwar per-Origin, aber + // die localhost-Allowance war eine offene Tür). + if (!IS_PROD) { + if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin; + if (/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin)) return origin; + } if (origin === 'https://cardecky.mana.how') return origin; return null; }, diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..3bb5b61 --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -0,0 +1,34 @@ +/** + * Audit-Log für sicherheits-/compliance-relevante Aktionen. + * + * Schreibt eine strukturierte JSON-Zeile auf stdout (`console.info`). + * In Produktion landet das im Container-Log und kann später per + * Promtail/Loki oder ähnlich aggregiert werden. Persistierung in + * eine `audit_log`-Tabelle ist ein eigener Sprint — wenn die fällig + * wird, muss nur diese Funktion erweitert werden, alle Aufrufer + * bleiben gleich. + * + * Ereignis-Naming-Konvention: `.`, lower_snake_case. + * Bsp.: `dsgvo.export`, `dsgvo.delete`, `auth.bypass_attempt`. + */ + +export interface AuditEvent { + event: string; + actor_user_id?: string; + target_user_id?: string; + auth_mode?: 'jwt' | 'dev-stub' | 'service-key'; + ip?: string | null; + user_agent?: string | null; + result: 'success' | 'failure' | 'partial'; + detail?: Record; +} + +export function auditLog(event: AuditEvent): void { + const line = { + kind: 'audit', + ts: new Date().toISOString(), + ...event, + }; + // eslint-disable-next-line no-console + console.info(JSON.stringify(line)); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index f0ddd46..600d7e7 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -36,7 +36,9 @@ export function tierAtLeast(have: Tier, need: Tier): boolean { } const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how'; -const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB !== 'false'; +// Fail-secure: opt-in, nicht opt-out. Vergessene env-var ⇒ Bypass AUS. +// Tests setzen die Variable via vitest.config.ts → tests/setup.ts. +const ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB === 'true'; let jwksCache: ReturnType | null = null; function getJwks() { diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts new file mode 100644 index 0000000..f506e1d --- /dev/null +++ b/apps/api/src/middleware/rate-limit.ts @@ -0,0 +1,99 @@ +import type { Context, MiddlewareHandler } from 'hono'; + +/** + * Schmale In-Memory-Rate-Limit-Middleware. Sliding window per Key. + * + * Bewusst dependency-frei und ohne Redis — Cards läuft als + * Single-Container. Wenn skaliert wird, ist das ein expliziter + * Sprint (Redis oder Cloudflare-Rate-Limiting auf der Edge). + * + * Verwendung: + * r.post('/heavy', rateLimit({ keyOf: ipKey, windowMs: 60_000, max: 30 }), handler) + * + * Identifier-Funktion (`keyOf`) gibt einen String zurück, der den + * Bucket bestimmt. Beispiele: `ipKey`, `userKey`, oder eine Custom- + * Funktion (z.B. service-key-Hash). + */ + +interface BucketEntry { + hits: number[]; +} + +const buckets = new Map(); +const MAX_BUCKETS = 10_000; + +export interface RateLimitOptions { + windowMs: number; + max: number; + keyOf: (c: Context) => string; + /** Wenn gesetzt, Bucket-Prefix — nützlich um mehrere Limits über denselben Caller zu trennen. */ + scope?: string; +} + +export function rateLimit(opts: RateLimitOptions): MiddlewareHandler { + return async (c, next) => { + const id = `${opts.scope ?? 'default'}|${opts.keyOf(c)}`; + const now = Date.now(); + const cutoff = now - opts.windowMs; + + let bucket = buckets.get(id); + if (!bucket) { + bucket = { hits: [] }; + if (buckets.size >= MAX_BUCKETS) evictOldest(now); + buckets.set(id, bucket); + } + bucket.hits = bucket.hits.filter((t) => t > cutoff); + + if (bucket.hits.length >= opts.max) { + const oldest = bucket.hits[0]!; + const retryAfterSec = Math.max(1, Math.ceil((oldest + opts.windowMs - now) / 1000)); + c.header('Retry-After', String(retryAfterSec)); + c.header('X-RateLimit-Limit', String(opts.max)); + c.header('X-RateLimit-Remaining', '0'); + return c.json( + { error: 'rate_limited', retry_after_s: retryAfterSec }, + 429 + ); + } + + bucket.hits.push(now); + c.header('X-RateLimit-Limit', String(opts.max)); + c.header('X-RateLimit-Remaining', String(opts.max - bucket.hits.length)); + await next(); + }; +} + +/** Caller-IP, hinter Cloudflare/Reverse-Proxy aus X-Forwarded-For. */ +export function ipKey(c: Context): string { + const xff = c.req.header('X-Forwarded-For'); + if (xff) return xff.split(',')[0]!.trim(); + const real = c.req.header('X-Real-IP'); + if (real) return real.trim(); + return 'unknown'; +} + +/** Eingeloggter User (für authentifizierte Endpoints). */ +export function userKey(c: Context): string { + const userId = c.get('userId' as never) as string | undefined; + return userId ?? ipKey(c); +} + +function evictOldest(now: number): void { + // LRU-light: ältester Bucket-Hit fliegt raus. Linear, aber MAX_BUCKETS klein. + let oldestKey: string | null = null; + let oldestTs = Infinity; + for (const [k, v] of buckets) { + const last = v.hits[v.hits.length - 1] ?? 0; + if (last < oldestTs) { + oldestTs = last; + oldestKey = k; + } + } + if (oldestKey) buckets.delete(oldestKey); + void now; +} + +/** Nur in Tests: Bucket-State zurücksetzen. */ +export function _resetRateLimitForTests(): void { + buckets.clear(); +} diff --git a/apps/api/src/routes/decks-from-image.ts b/apps/api/src/routes/decks-from-image.ts index f7da8fb..db6c582 100644 --- a/apps/api/src/routes/decks-from-image.ts +++ b/apps/api/src/routes/decks-from-image.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getDb, type CardsDb } from '../db/connection.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; +import { rateLimit, userKey } from '../middleware/rate-limit.ts'; import { chatVisionJson } from '../services/llm-client.ts'; import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts'; import { fetchUrlContent } from '../lib/url-fetch.ts'; @@ -51,6 +52,8 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); + // 10/min per User — Vision-LLM-Call ist besonders teuer. + r.use('/', rateLimit({ scope: 'decks.from-image', windowMs: 60_000, max: 10, keyOf: userKey })); r.post('/', async (c) => { const userId = c.get('userId'); diff --git a/apps/api/src/routes/decks-generate.ts b/apps/api/src/routes/decks-generate.ts index 9187144..1f8c757 100644 --- a/apps/api/src/routes/decks-generate.ts +++ b/apps/api/src/routes/decks-generate.ts @@ -9,6 +9,7 @@ import { makeInitialReviewRows } from '../lib/reviews.ts'; import { getDb, type CardsDb } from '../db/connection.ts'; import { cards, decks, reviews } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; +import { rateLimit, userKey } from '../middleware/rate-limit.ts'; import { ulid } from '../lib/ulid.ts'; import { chatJson } from '../services/llm-client.ts'; import { fetchUrlContent } from '../lib/url-fetch.ts'; @@ -130,6 +131,8 @@ export function decksGenerateRouter(deps: GenerateDeps = {}): Hono<{ Variables: const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); + // 10/min per User — LLM-Call ist teuer (mana-llm-Credits). + r.use('/', rateLimit({ scope: 'decks.generate', windowMs: 60_000, max: 10, keyOf: userKey })); r.post('/', async (c) => { const userId = c.get('userId'); diff --git a/apps/api/src/routes/dsgvo.ts b/apps/api/src/routes/dsgvo.ts index 22ebd6c..911c916 100644 --- a/apps/api/src/routes/dsgvo.ts +++ b/apps/api/src/routes/dsgvo.ts @@ -13,7 +13,9 @@ import { tags, } from '../db/schema/index.ts'; import { serviceKeyAuth } from '../middleware/service-key.ts'; +import { rateLimit, ipKey } from '../middleware/rate-limit.ts'; import { getStorage } from '../services/storage.ts'; +import { auditLog } from '../lib/audit.ts'; export type DsgvoDeps = { db?: CardsDb }; @@ -98,6 +100,9 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { const r = new Hono(); const dbOf = () => deps.db ?? getDb(); + // IP-Rate-Limit als Defense-in-Depth — der Service-Key ist ohnehin + // Pflicht, aber 10/min stoppt einen brute-force-Versuch auf den Key. + r.use('*', rateLimit({ scope: 'dsgvo', windowMs: 60_000, max: 10, keyOf: ipKey })); r.use('*', serviceKeyAuth({ envVar: 'CARDS_DSGVO_SERVICE_KEY' })); /** @@ -108,7 +113,20 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { r.get('/export', async (c) => { const userId = c.req.query('user_id'); if (!userId) return c.json({ error: 'missing_user_id' }, 400); - return c.json(await buildUserExport(dbOf(), userId)); + const payload = await buildUserExport(dbOf(), userId); + auditLog({ + event: 'dsgvo.export', + target_user_id: userId, + auth_mode: 'service-key', + ip: c.req.header('X-Forwarded-For') ?? null, + user_agent: c.req.header('User-Agent') ?? null, + result: 'success', + detail: { + decks: payload.data.decks.length, + cards: payload.data.cards.length, + }, + }); + return c.json(payload); }); /** @@ -148,18 +166,43 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono { ); // MinIO-Bucket-Sweep nach DB-Cleanup. Wenn der Storage-Sweep - // scheitert, ist das nicht-fatal — die DB ist schon konsistent - // gelöscht und Storage-Files ohne DB-Eintrag sind tote Bytes. + // scheitert, ist das nicht-fatal für die DB-Konsistenz (Files + // ohne DB-Eintrag sind tote Bytes), aber der Caller MUSS es + // erfahren — sonst meldet mana-admin dem User „alles gelöscht" + // obwohl Media-Objekte überleben können. let storageObjectsDeleted = 0; + let storageOk = true; + let storageError: string | null = null; try { storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`); } catch (err) { + storageOk = false; + storageError = err instanceof Error ? err.message : String(err); console.warn('[dsgvo/delete] storage sweep failed:', err); } + auditLog({ + event: 'dsgvo.delete', + target_user_id: userId, + auth_mode: 'service-key', + ip: c.req.header('X-Forwarded-For') ?? null, + user_agent: c.req.header('User-Agent') ?? null, + result: storageOk ? 'success' : 'partial', + detail: { + decks: deletedDecks.length, + import_jobs: deletedImports.length, + media_files: deletedMediaFiles.length, + storage_objects: storageObjectsDeleted, + storage_ok: storageOk, + storage_error: storageError, + }, + }); + return c.json({ deleted: true, user_id: userId, + storage_ok: storageOk, + ...(storageError ? { storage_error: storageError } : {}), counts: { decks: deletedDecks.length, import_jobs: deletedImports.length, diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index d5dff0b..a441acf 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -1,22 +1,44 @@ import { Hono } from 'hono'; +import { sql } from 'drizzle-orm'; + +import { getDb } from '../db/connection.ts'; +import { getStorage } from '../services/storage.ts'; export const healthRoute = new Hono(); +/** + * Liveness — antwortet immer 200, solange der Prozess läuft. + * Bewusst KEIN Downstream-Probe, damit ein kurzer DB-/MinIO-Hänger + * nicht den Container in eine Restart-Schleife zwingt. + */ healthRoute.get('/healthz', (c) => c.json({ status: 'ok' })); -healthRoute.get('/healthz/details', (c) => - c.json({ - status: 'ok', - app: 'cards', - version: process.env.CARDS_API_VERSION ?? '0.0.0', - uptime_s: Math.floor(process.uptime()), - mana_packages: { - // In Phase 5 mit @mana/shared-app-tpl ersetzen. - '@mana/shared-share-protocol': 'TBD', - '@mana/shared-app-tpl': 'TBD', +/** + * Readiness mit Downstream-Probes. Status 200 wenn DB + MinIO grün, + * sonst 503 mit Aufschlüsselung welche Probe fehlgeschlagen ist. + * Probes timen sich selbst aus über ein 1s-AbortSignal — eine träge + * Abhängigkeit darf das Readiness-Signal nicht hängen lassen. + */ +healthRoute.get('/healthz/details', async (c) => { + const [dbProbe, storageProbe] = await Promise.all([ + probeDb(), + probeStorage(), + ]); + const allOk = dbProbe.ok && storageProbe.ok; + return c.json( + { + status: allOk ? 'ok' : 'degraded', + app: 'cards', + version: process.env.CARDS_API_VERSION ?? '0.0.0', + uptime_s: Math.floor(process.uptime()), + checks: { + db: dbProbe, + storage: storageProbe, + }, }, - }) -); + allOk ? 200 : 503 + ); +}); healthRoute.get('/version', (c) => c.json({ @@ -25,3 +47,31 @@ healthRoute.get('/version', (c) => build: process.env.CARDS_BUILD_SHA ?? 'dev', }) ); + +type ProbeResult = { ok: true; latency_ms: number } | { ok: false; error: string }; + +async function probeDb(): Promise { + const t0 = Date.now(); + try { + const db = getDb(); + await db.execute(sql`SELECT 1`); + return { ok: true, latency_ms: Date.now() - t0 }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +} + +async function probeStorage(): Promise { + const t0 = Date.now(); + try { + await getStorage().ensureBucket(); + return { ok: true, latency_ms: Date.now() - t0 }; + } catch (err) { + return { ok: false, error: errorMessage(err) }; + } +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 0b89e0e..2749d35 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -6,6 +6,7 @@ import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/inde import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; import { getStorage } from '../services/storage.ts'; import { buildUserExport } from './dsgvo.ts'; +import { auditLog } from '../lib/audit.ts'; export type MeDeps = { db?: CardsDb }; @@ -24,7 +25,21 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { /** Voll-Export der eigenen Daten als JSON. */ r.get('/export', async (c) => { const userId = c.get('userId'); - return c.json(await buildUserExport(dbOf(), userId)); + const payload = await buildUserExport(dbOf(), userId); + auditLog({ + event: 'dsgvo.export', + actor_user_id: userId, + target_user_id: userId, + auth_mode: c.get('authMode'), + ip: c.req.header('X-Forwarded-For') ?? null, + user_agent: c.req.header('User-Agent') ?? null, + result: 'success', + detail: { + decks: payload.data.decks.length, + cards: payload.data.cards.length, + }, + }); + return c.json(payload); }); /** @@ -188,15 +203,39 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> { ); let storageObjectsDeleted = 0; + let storageOk = true; + let storageError: string | null = null; try { storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`); } catch (err) { + storageOk = false; + storageError = err instanceof Error ? err.message : String(err); console.warn('[me/delete] storage sweep failed:', err); } + auditLog({ + event: 'dsgvo.delete', + actor_user_id: userId, + target_user_id: userId, + auth_mode: c.get('authMode'), + ip: c.req.header('X-Forwarded-For') ?? null, + user_agent: c.req.header('User-Agent') ?? null, + result: storageOk ? 'success' : 'partial', + detail: { + decks: deletedDecks.length, + import_jobs: deletedImports.length, + media_files: deletedMediaFiles.length, + storage_objects: storageObjectsDeleted, + storage_ok: storageOk, + storage_error: storageError, + }, + }); + return c.json({ deleted: true, user_id: userId, + storage_ok: storageOk, + ...(storageError ? { storage_error: storageError } : {}), counts: { decks: deletedDecks.length, import_jobs: deletedImports.length, diff --git a/apps/api/src/routes/media.ts b/apps/api/src/routes/media.ts index 9bc7b7c..154fd1f 100644 --- a/apps/api/src/routes/media.ts +++ b/apps/api/src/routes/media.ts @@ -4,6 +4,7 @@ import { Hono } from 'hono'; import { getDb, type CardsDb } from '../db/connection.ts'; import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; +import { rateLimit, userKey } from '../middleware/rate-limit.ts'; import { ulid } from '../lib/ulid.ts'; import { getStorage, type StorageService } from '../services/storage.ts'; @@ -47,6 +48,10 @@ export function mediaRouter(deps: MediaDeps = {}): Hono<{ Variables: AuthVars }> const storageOf = () => deps.storage ?? getStorage(); r.use('*', authMiddleware); + // 30/min per User — Upload ist teuer (Storage + DB-Insert), aber + // ein Anki-Import kann viele Files in Folge schicken; das Limit + // soll Bursts ermöglichen, aber Dauer-Spam stoppen. + r.use('/upload', rateLimit({ scope: 'media.upload', windowMs: 60_000, max: 30, keyOf: userKey })); /** * Multipart-Upload eines einzelnen Files. Bilder/Audio/Video; alles diff --git a/apps/api/src/routes/share.ts b/apps/api/src/routes/share.ts index 192c24d..5b101c9 100644 --- a/apps/api/src/routes/share.ts +++ b/apps/api/src/routes/share.ts @@ -6,6 +6,7 @@ import manifest from '../../../../app-manifest.json' with { type: 'json' }; import { getDb, type CardsDb } from '../db/connection.ts'; import { authMiddleware, type AuthVars } from '../middleware/auth.ts'; +import { rateLimit, ipKey } from '../middleware/rate-limit.ts'; import { SHARE_HANDLERS, type ShareHandlerName } from '../share-handlers/index.ts'; export type ShareDeps = { db?: CardsDb }; @@ -18,6 +19,8 @@ export function shareRouter(deps: ShareDeps = {}): Hono<{ Variables: AuthVars }> const dbOf = () => deps.db ?? getDb(); r.use('*', authMiddleware); + // 60/min per IP — Federation-Endpoint, soll für legitime App-Calls reichen. + r.use('/receive', rateLimit({ scope: 'share.receive', windowMs: 60_000, max: 60, keyOf: ipKey })); r.post('/receive', async (c) => { const userId = c.get('userId'); diff --git a/apps/api/tests/rate-limit.test.ts b/apps/api/tests/rate-limit.test.ts new file mode 100644 index 0000000..27663b6 --- /dev/null +++ b/apps/api/tests/rate-limit.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Hono } from 'hono'; + +import { + rateLimit, + ipKey, + _resetRateLimitForTests, +} from '../src/middleware/rate-limit.ts'; + +describe('rateLimit middleware', () => { + beforeEach(() => { + _resetRateLimitForTests(); + }); + + function buildApp(max: number) { + const app = new Hono(); + app.use('/probe', rateLimit({ scope: 't', windowMs: 60_000, max, keyOf: ipKey })); + app.get('/probe', (c) => c.json({ ok: true })); + return app; + } + + it('lässt Calls unter dem Limit durch und zählt Remaining runter', async () => { + const app = buildApp(3); + const ip = { 'X-Forwarded-For': '10.0.0.1' }; + + const r1 = await app.request('/probe', { headers: ip }); + expect(r1.status).toBe(200); + expect(r1.headers.get('X-RateLimit-Remaining')).toBe('2'); + + const r2 = await app.request('/probe', { headers: ip }); + expect(r2.status).toBe(200); + expect(r2.headers.get('X-RateLimit-Remaining')).toBe('1'); + + const r3 = await app.request('/probe', { headers: ip }); + expect(r3.status).toBe(200); + expect(r3.headers.get('X-RateLimit-Remaining')).toBe('0'); + }); + + it('antwortet 429 mit Retry-After wenn das Limit überschritten ist', async () => { + const app = buildApp(2); + const ip = { 'X-Forwarded-For': '10.0.0.2' }; + + await app.request('/probe', { headers: ip }); + await app.request('/probe', { headers: ip }); + const blocked = await app.request('/probe', { headers: ip }); + + expect(blocked.status).toBe(429); + expect(blocked.headers.get('Retry-After')).toBeTruthy(); + const body = (await blocked.json()) as { error: string; retry_after_s: number }; + expect(body.error).toBe('rate_limited'); + expect(body.retry_after_s).toBeGreaterThan(0); + }); + + it('isoliert Buckets pro IP', async () => { + const app = buildApp(1); + const ipA = { 'X-Forwarded-For': '10.0.0.3' }; + const ipB = { 'X-Forwarded-For': '10.0.0.4' }; + + await app.request('/probe', { headers: ipA }); + const aBlocked = await app.request('/probe', { headers: ipA }); + const bAllowed = await app.request('/probe', { headers: ipB }); + + expect(aBlocked.status).toBe(429); + expect(bAllowed.status).toBe(200); + }); + + it('benutzt nur den ersten X-Forwarded-For-Hop als Key', async () => { + const app = buildApp(1); + // Zwei Proxy-Hops im XFF — soll auf den Client-IP-Bucket fallen, nicht + // auf den Edge-IP-Bucket. + const headers1 = { 'X-Forwarded-For': '203.0.113.5, 10.0.0.9' }; + const headers2 = { 'X-Forwarded-For': '203.0.113.5, 10.0.0.10' }; + + await app.request('/probe', { headers: headers1 }); + const blocked = await app.request('/probe', { headers: headers2 }); + expect(blocked.status).toBe(429); + }); +}); diff --git a/apps/api/tests/setup.ts b/apps/api/tests/setup.ts new file mode 100644 index 0000000..ccfee7c --- /dev/null +++ b/apps/api/tests/setup.ts @@ -0,0 +1,9 @@ +// Vitest-Setup. Wird über vitest.config.ts → setupFiles geladen, +// bevor irgendein Test-Modul ausgeführt wird. +// +// Aktiviert den X-User-Id-Dev-Stub für Tests, weil die Suiten ohne +// echten mana-auth-JWKS laufen. In Produktion ist der Stub fail-secure +// AUS (siehe apps/api/src/middleware/auth.ts). +if (process.env.CARDS_AUTH_DEV_STUB === undefined) { + process.env.CARDS_AUTH_DEV_STUB = 'true'; +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..68e7bd6 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + setupFiles: ['./tests/setup.ts'], + }, +}); diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts new file mode 100644 index 0000000..324ff30 --- /dev/null +++ b/apps/web/src/hooks.server.ts @@ -0,0 +1,25 @@ +import type { Handle } from '@sveltejs/kit'; + +/** + * Server-Hook für Security-Headers auf jeder ausgelieferten Response. + * + * Bewusst minimalistisch: nur die well-known Header, die ohne + * Browser-Verhaltens-Test sicher sind. **CSP fehlt absichtlich** — + * eine richtige Content-Security-Policy braucht App-Inventur + * (inline-styles, externe Theme-Assets, Markdown-Renderer) und + * sollte separat mit Live-Test eingeführt werden. + * + * Cloudflare-Tunnel-Transform-Rules dürfen das gerne nochmal + * obendrauf setzen — die hier sind die Defense-Tiefe-Schicht für + * den Fall, dass die Cloudflare-Schiene aus dem Pfad fällt. + */ +export const handle: Handle = async ({ event, resolve }) => { + const response = await resolve(event); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); + if (process.env.NODE_ENV === 'production') { + response.headers.set('Strict-Transport-Security', 'max-age=15552000; includeSubDomains'); + } + return response; +}; diff --git a/apps/web/src/lib/api/me.ts b/apps/web/src/lib/api/me.ts index 0888f21..e6514a9 100644 --- a/apps/web/src/lib/api/me.ts +++ b/apps/web/src/lib/api/me.ts @@ -21,12 +21,23 @@ export function exportMe() { return api('/api/v1/me/export'); } +export interface DeleteMeResult { + deleted: true; + user_id: string; + /** false ⇒ DB ist gelöscht, aber Storage-Sweep hat Bytes hinterlassen. */ + storage_ok: boolean; + storage_error?: string; + counts: { + decks: number; + import_jobs: number; + media_files: number; + storage_objects: number; + }; +} + /** Löscht alle Cards-Daten des eingeloggten Users (User-JWT). */ export function deleteMe() { - return api<{ deleted: true; user_id: string; counts: { decks: number; import_jobs: number } }>( - '/api/v1/me/delete', - { method: 'POST' } - ); + return api('/api/v1/me/delete', { method: 'POST' }); } export interface UserStats { diff --git a/apps/web/src/routes/account/+page.svelte b/apps/web/src/routes/account/+page.svelte index 0f1f2f4..bf30520 100644 --- a/apps/web/src/routes/account/+page.svelte +++ b/apps/web/src/routes/account/+page.svelte @@ -67,9 +67,22 @@ deleting = true; try { const r = await deleteMe(); - toasts.success( - t('account.delete_done', { decks: r.counts.decks, imports: r.counts.import_jobs }), - ); + if (r.storage_ok) { + toasts.success( + t('account.delete_done', { + decks: r.counts.decks, + imports: r.counts.import_jobs, + }), + ); + } else { + // DB sauber, aber Storage-Sweep ist gescheitert. User MUSS das wissen, + // sonst denkt er „alles gelöscht" obwohl Bytes liegen geblieben sind. + toasts.error( + `Daten gelöscht, aber Medien-Speicher konnte nicht vollständig geleert werden${ + r.storage_error ? ` (${r.storage_error})` : '' + }. Bitte den Vereins-Support kontaktieren.`, + ); + } devUser.clear(); goto('/'); } catch (e) { diff --git a/docs/FEATURE_IDEAS.md b/docs/FEATURE_IDEAS.md index 9dbc580..111f311 100644 --- a/docs/FEATURE_IDEAS.md +++ b/docs/FEATURE_IDEAS.md @@ -1,94 +1,403 @@ # Feature Ideas -Stand: 2026-05-11. Basiert auf einer Analyse des aktuellen Cardecky-Stands (Phasen 0–12). +Stand: 2026-05-12. Korrektur-Pass nach Code-Review gegen den +aktuellen Cardecky-Stand. Items, die zwischen Erst-Erstellung +(2026-05-11) und heute bereits implementiert wurden, sind unten in +**„Seit letzter Liste gebaut"** geführt, damit der Diff nachvollziehbar +bleibt, und aus den Themen-Sektionen entfernt. + +--- + +## 🔴 Akute Sicherheitslücke — Live verifiziert 2026-05-12 + +**Dev-Stub-Auth-Bypass ist auf Production aktiv.** +`curl -H "X-User-Id: " https://cardecky-api.mana.how/api/v1/decks` +liefert **200 OK** (ohne Header: 401), und Dev-Stub-User bekommen +`founder`-Tier (`auth.ts:90`). + +### Repo-Patch — gebaut 2026-05-12, noch nicht deployed + +1. `apps/api/src/middleware/auth.ts:39` auf **opt-in** geflippt: + `ALLOW_DEV_STUB = process.env.CARDS_AUTH_DEV_STUB === 'true'`. + Vergessene env-var ⇒ Bypass AUS. +2. `infrastructure/docker-compose.production.yml`: Default flipped + auf `:-false`, plus `NODE_ENV: production` für cards-api ergänzt + (fehlte zuvor — nur cards-web hatte es). +3. `infrastructure/.env.production.example`: `CARDS_AUTH_DEV_STUB=false` + als Pflichtzeile mit Kommentar. +4. `apps/api/vitest.config.ts` + `apps/api/tests/setup.ts` angelegt, + damit die Test-Suiten (5 Files mit `X-User-Id`-Auth) den Stub + automatisch aktivieren. **Verifikation:** `pnpm test` → + 16 Files, 100 Tests grün. + +### Verbleibend bei dir (Mac Mini + Deploy) + +1. `git pull` / Rebase auf der Prod-Box, dann + `docker compose -f infrastructure/docker-compose.production.yml \ + up -d --build cards-api`. +2. Verifizieren mit + `curl -i -H "X-User-Id: probe" https://cardecky-api.mana.how/api/v1/decks` + → erwartet **401** (vorher 200). +3. Optional: `.env.production` auf dem Mac Mini um die explizite + `CARDS_AUTH_DEV_STUB=false`-Zeile ergänzen (jetzt nur noch + Doku-Konsistenz — der Compose-Default ist schon fail-secure). +4. Forensik: `cards.decks.user_id` und `cards.cards.user_id` gegen + `mana_auth.users` joinen — IDs ohne Match wären Indizien für + ausgenutzten Bypass. + +--- + +## Seit letzter Liste gebaut (Stand-Korrektur) + +Diese Punkte standen in der 2026-05-11-Fassung noch als Ideen, sind +aber im Code längst gelandet: + +- **`audio-front` / `typing` / `multiple-choice`** — komplette + Renderer + Editor-Forms + Integration im Study-Flow + (`AudioFrontView.svelte`, `TypingView.svelte`, `MultipleChoiceView.svelte`, + Edit-Routen unter `cards/[id]/edit/+page.svelte`). +- **Keyboard Shortcuts im Study-Mode** — `1/2/3/4` = Rating, + `Space`/`Enter` = Reveal bzw. `good` (`routes/study/[deckId]/+page.svelte:170-180`). +- **Daily Streaks (Backend + Anzeige)** — `me.ts:106-156` berechnet + `streak_days`, Stats-Dashboard zeigt es an. Streak-Freeze, Streak im + Header und Streak-Push fehlen noch (siehe Gamification unten). +- **CSV-Import + CSV-Export** — `lib/csv/{parse,import,export}.ts` + + `CsvImport.svelte`. +- **Quizlet-Import** — `lib/quizlet/parse.ts` + `QuizletImport.svelte`. +- **Marketplace-Smart-Merge-Pull-UI** — `updateAvailable`-Banner und + `pullUpdate(deckId)` im Deck-Edit + (`routes/decks/[id]/edit/+page.svelte:39,113,275`). +- **Lizenz-Feld auf Marketplace-Decks** — `marketplace/decks.ts:73` + hat `license` mit Default `Cardecky-Personal-Use-1.0`; Constraint + bindet Preis an Lizenz-Typ. --- ## Lern-Erlebnis -### Schema-ready (nur UI fehlt) - -| Feature | Kartentyp | Notiz | -|---------|-----------|-------| -| Hör-Verständnis | `audio-front` | Schema + Renderer vorbereitet | -| Tipp-Antwort | `typing` | Fuzzy-Matching-Logik in `domain/typing.ts` | -| Multiple Choice | `multiple-choice` | Schema vorbereitet, Form-Komponente fehlt | - ### Scheduler-Verbesserungen -- **Card Burial / Suspension** — Karten temporär deaktivieren ohne Löschen; häufig angefragtes Anki-Feature -- **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten nicht am selben Tag wiederholen -- **Custom Study Sessions** — Gefilterte Sitzungen: nur neue Karten, nur Fehler der letzten Woche, nach Tag filtern -- **Subdeck-Unterstützung** — Hierarchische Deck-Struktur (z. B. Vokabeln → Nomen / Verben) +- **FSRS-Parameter pro User optimieren** — `ts-fsrs` liefert + `computeParameters()` aus Review-History; Schema (`decks.fsrs_settings`) + und Per-Deck-Override sind vorbereitet, aber kein Endpoint und kein UI. + Größter messbarer Retention-Gewinn pro Aufwand. +- **Leech-Detection** — `reviews.lapses` zählt bereits; + Auto-Markierung + UI-Vorschlag „aufteilen / suspendieren" für + Karten oberhalb eines Schwellwerts. +- **Undo letzte Bewertung** — wichtiger UX-Reflex; aktuell muss man + das Review händisch zurückbauen. +- **Card Burial / Suspension** — Karten temporär deaktivieren ohne + Löschen; häufig angefragtes Anki-Feature. +- **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten + nicht am selben Tag wiederholen. +- **Custom Study Sessions** — gefilterte Sitzungen: nur neue Karten, + nur Fehler der letzten Woche, nach Tag filtern. Tag-Filter im + `reviews`-Endpoint fehlt aktuell ganz. +- **Session-Mix-Algorithmus** — heute wird `due ≤ now` linear + abgearbeitet; Optionen „Neue Karten zuerst", „durchmischt", „nur + Fehler" als saubere Filter im Queue-Loader. +- **Subdeck-Unterstützung** — hierarchische Deck-Struktur + (z. B. Vokabeln → Nomen / Verben). +- **Cloze-Hint-Anzeige** — `{{c1::answer::hint}}` wird aktuell beim + Rendern fallen gelassen; Hint-Anzeige steht in `STATUS.md` als + verbleibender Phase-9-Punkt. + +### Kartentypen, Schema vorhanden / vorbereitet + +- **Bild-Front / Bild-Back** — als generischer Type mit `media_ref` + in `fields`. MinIO-Storage + `media_files`-Pfad ist seit Sprint 9k + da, also rein UI-Frage. +- **Speech-In** (Audio-Antwort vom User) — Whisper via `mana-stt` + ist im Plattform-Stack vorhanden; Schema müsste `expected` + Audio- + Vergleichs-Strategie definieren. --- ## Gamification & Motivation -- **Daily Streaks** — Tägliche Lernkette mit optionalem Freeze-Token -- **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage-Streak) -- **Tages-Ziele** — "Heute: 20 Karten" mit Progress-Bar im Dashboard -- **Push/Email-Reminders** — "Du hast heute noch 15 fällige Karten" via mana-notify -- **Estimated Mastery Date** — "Dieses Deck beherrschst du voraussichtlich in 3 Wochen" (aus FSRS-Parametern berechenbar) +- **Streak-Freeze-Token** — ein Streak-Schutztag pro Woche, optional + durch Credits kaufbar. +- **Streak im Header** — heute nur im Stats-Dashboard sichtbar; + Header-Glyph mit Zahl wäre Mikro-Aufwand, sichtbare Wirkung. +- **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage- + Streak). +- **Tages-Ziele** — „Heute: 20 Karten" mit Progress-Bar im Dashboard. +- **Push/Email-Reminders** — „Du hast heute noch 15 fällige Karten" + via `mana-notify`. Aktuell wird `mana-notify` von Cards noch nicht + benutzt (siehe `marketplace/pull-requests.ts:41` — „Notify-Calls + ausgelassen — eigene Welle"). +- **Estimated Mastery Date** — „Dieses Deck beherrschst du + voraussichtlich in 3 Wochen" (aus FSRS-Stability ableitbar). --- ## KI-Features -- **Auto-Cloze-Generator** — Text markieren → `{{c1::...}}` automatisch einfügen -- **Card-Split-Vorschlag** — KI erkennt informationsreiche Karten und schlägt Aufteilung vor -- **Erklär-Modus** — Nach falscher Antwort: KI erklärt den Zusammenhang (opt-in) -- **Auto-Tagging** — Karten beim Erstellen / Importieren semantisch taggen -- **Duplicate Detection** — Semantische Ähnlichkeit über Decks hinweg erkennen -- **Card Quality Score** — Hinweis: "Diese Karte hat zu viel Text" + Verbesserungsvorschlag +- **Auto-Cloze-Generator** — Text markieren → `{{c1::...}}` + automatisch einfügen. +- **Card-Split-Vorschlag** — KI erkennt informationsreiche Karten + und schlägt Aufteilung vor. +- **Erklär-Modus** — Nach falscher Antwort: KI erklärt den + Zusammenhang (opt-in). +- **Auto-Tagging** — Karten beim Erstellen / Importieren semantisch + taggen. +- **Duplicate Detection** — semantische Ähnlichkeit über Decks + hinweg erkennen. +- **Card Quality Score** — Hinweis „Diese Karte hat zu viel Text" + + Verbesserungsvorschlag. --- ## Analytics & Insights -- **Vergessenskurven-Visualisierung** — Pro Deck und Tag, aus FSRS-State ableitbar -- **Retention-Rate** — Aufgeschlüsselt nach Kategorie und Sprache -- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend -- **Karten-Schwierigkeits-Heatmap** — Welche Karten kosten die meiste Review-Zeit -- **Wöchentliche Zusammenfassung** — In-App oder per Email via mana-notify +- **Vergessenskurven-Visualisierung** — pro Deck und Tag, aus + FSRS-State ableitbar. +- **Retention-Rate** — aufgeschlüsselt nach Kategorie und Sprache. +- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend. +- **Karten-Schwierigkeits-Heatmap** — welche Karten kosten die + meiste Review-Zeit. +- **Wöchentliche Zusammenfassung** — In-App oder per Email via + `mana-notify`. +- **Algorithmus-Transparenz pro Karte** — kleines „Wieso wurde ich + befragt?"-Tooltip mit Stability/Difficulty/letztem Rating. Macht + FSRS sichtbar (Mission-Wert „Souveränität"). --- ## Import / Export -- **CSV Import/Export** — Einfachste Interop, relevant für Lehrer und Nutzer-Migration -- **PDF Export** — Druckbare Karteikarten (A6-Format, vorder-/rückseitig) -- **Web Clipper** (Browser-Extension) — Markierter Text → sofort neue Karte; eigenes Projekt -- **Quizlet Import** — Größte Nutzerbasis im Markt, hohe Migrations-Relevanz -- **SuperMemo XML** — Für Power-User aus dem SM-Ecosystem -- **FSRS-State Export** — Lernstand als JSON exportieren für Backup und Migration +- **PDF-Export** — druckbare Karteikarten (A6-Format, + vorder-/rückseitig). Print-Route existiert schon + (`decks/[id]/print/`), aber als HTML-Print, nicht generiert-PDF. +- **`.apkg`-Export** — Round-Trip mit dem Anki-Parser (Parser ist + schon da). Stärkste „du kannst jederzeit weggehen"-Geste, + Mission-Argument. +- **Web Clipper** (Browser-Extension) — markierter Text → sofort + neue Karte; eigenes Projekt, eigener Scope. +- **SuperMemo XML** — für Power-User aus dem SM-Ecosystem. +- **FSRS-State-Export** — Lernstand als JSON exportieren für Backup + und Migration (über DSGVO-Export hinaus, in interop-tauglichem + Format). --- ## Zusammenarbeit & Community -- **Study Spaces** — Gemeinsame Decks für Schulklassen und Lerngruppen (braucht mana-auth Gruppen-Konzept) -- **Deck-Ratings & Kommentare** — Qualitätssicherung im Marketplace durch Community -- **Study Challenges** — Mit Freunden auf demselben Deck messen -- **Kreator-Analytics** — Für Marketplace-Publisher: Views, Forks, Abonnenten-Retention -- **Collaborative Decks** — Team-Editing mit Rollen (Maintainer / Contributor) +- **Study Spaces** — gemeinsame Decks für Schulklassen und + Lerngruppen (braucht Gruppen-Konzept in `mana-auth`, das noch nicht + existiert; `decks.visibility = 'space'` ist im Schema vorgesehen, + aber ohne Spaces-Backend wirkungslos). +- **Deck-Ratings & Kommentare** — Qualitätssicherung im Marketplace + durch Community. `marketplace/discussions.ts`-Schema existiert, + UI-Anbindung müsste geprüft werden. +- **Study Challenges** — mit Freunden auf demselben Deck messen. +- **Kreator-Analytics** — für Marketplace-Publisher: Views, Forks, + Abonnenten-Retention. +- **Collaborative Decks** — Team-Editing mit Rollen + (Maintainer / Contributor). + +--- + +## Marketplace (eigener Themenblock) + +Marketplace-Schema + Pull-Update-UI sind da, aber drumherum klafft +einiges: + +- **Pull-Request-UI** — Backend (`marketplace/pull-requests.ts`) und + API-Client (`lib/api/marketplace.ts`) existieren; eine Surface, wo + ein Fork-Maintainer Änderungen *an* das Original *vorschlagen* + kann, fehlt im Web. +- **Versions-Diff zwischen Fork und Upstream** — Voraussetzung, + damit Smart-Merge-Pull nicht „trust me"-Klick ist. +- **Moderations-Queue-Surface** — `marketplace/moderation.ts` im + Schema vorhanden; Report-Funktion auf Decks/Karten im UI fehlt. +- **Lizenz-Auswahl bei Publish** — `decks.license` hat Default + `Cardecky-Personal-Use-1.0`; Standard-Lizenzen + (CC-BY-SA, CC-0, CC-BY-NC) als Optionen im Publish-Flow. +- **Lizenz-Anzeige beim Studieren** geforkter Decks — wer das Deck + veröffentlicht hat, welche Lizenz greift; transparent unten in der + Study-View. --- ## UX / Plattform -- **PWA Offline-Support** — Service Worker + lokaler Lern-Cache; erfordert Entscheidung über FSRS-State-Sync-Strategie (server-authoritative vs. lokal) -- **Keyboard Shortcuts im Study-Mode** — Space = Antwort zeigen, 1–4 = Rating -- **Dark Mode / Theme-Switcher** — In-App-Auswahl statt nur System-Präferenz -- **Bulk-Operationen** — Mehrere Karten auswählen, verschieben, taggen, löschen -- **Dynamic Decks (Smart Playlists)** — Automatisch gefiltert: z. B. "Alle Karten mit Tag 'Grammatik' aus 3 Decks" -- **Card History** — Lernverlauf pro Karte: wann wie bewertet +- **PWA Offline-Read-Only** als Zwischenschritt zur vollen PWA: + Service-Worker cached die letzte Queue, Bewertungen werden + gepuffert. Bricht die Server-authoritative-Invariante nicht + (Puffer ist transient). +- **Dark Mode / Theme-Switcher** — `data-theme="forest"` ist in + `app.html` fest verdrahtet; In-App-Auswahl fehlt. Bei der + laufenden 12-Token-Theming-Strategie ohnehin geplant. +- **Bulk-Operationen** — mehrere Karten auswählen, verschieben, + taggen, löschen. +- **Dynamic Decks / Smart Playlists** — automatisch gefiltert: + z. B. „alle Karten mit Tag 'Grammatik' aus 3 Decks". +- **Card History** — Lernverlauf pro Karte: wann wie bewertet. + +--- + +## A11y (konkret aufs UI bezogen) + +- **`prefers-reduced-motion` respektieren** in `DeckFan.svelte` und + `DeckStack.svelte` (animationslastige Komponenten). +- **Screen-Reader-Live-Region** im Study-View für das Reveal-Event + („Antwort eingeblendet"). +- **Touch-Targets ≥ 44 px** für die Rating-Buttons; Mobile-Review + ist der Haupt-Use-Case und Code zeigt keine explizite Min-Höhe. +- **Fokus-Reihenfolge nach Reveal** — Cursor sollte auf den Rating- + Buttons landen, nicht am Body-Anfang. +- **Tastatur-Hilfe-Overlay** im Study-Mode (`?`-Taste): zeigt die + bestehenden Shortcuts an. Heute sind sie undokumentiert in der UI. + +--- + +## Security (eigener Block, weil Live-App) + +- **Dev-Stub-Default ist `opt-out`, nicht `opt-in`** — siehe akute + Sicherheitslücke oben. Fix-Anweisung dort, dieser Eintrag bleibt + als Erinnerung, dass die Middleware-Logik in `auth.ts:39` auf + opt-in geflippt gehört (`=== 'true'` statt `!== 'false'`). +- **JWT in `localStorage`** — `dev-stub.svelte.ts:13` speichert das + Access-Token unter `cards.auth.accessToken`. Anfällig für XSS- + Token-Diebstahl. Mitigation: DOMPurify sanitisiert Markdown (gut), + aber HttpOnly-Cookie wäre der robustere Ansatz, sobald der SSO- + Cookie-Pfad ohnehin schon da ist. +- **SVG-Upload erlaubt** — `media.ts:13` whitelistet `image/`- + Prefix; `image/svg+xml` ist im Extension-Map (`media.ts:28`). + SVG kann eingebettetes JavaScript enthalten. Wenn SVG-Medien per + `` gerendert werden, ist es harmlos; bei ``/`