security(cards): fail-secure dev-stub, headers, rate-limit, dsgvo audit
Some checks are pending
CI / validate (push) Waiting to run
Some checks are pending
CI / validate (push) Waiting to run
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>
This commit is contained in:
parent
5859e202c5
commit
e1ddbf34b3
21 changed files with 832 additions and 80 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
|
import { secureHeaders } from 'hono/secure-headers';
|
||||||
|
|
||||||
import { manifestRoute } from './routes/manifest.ts';
|
import { manifestRoute } from './routes/manifest.ts';
|
||||||
import { healthRoute } from './routes/health.ts';
|
import { healthRoute } from './routes/health.ts';
|
||||||
|
|
@ -25,14 +26,23 @@ import { discussionsRouter as marketplaceDiscussionsRouter } from './routes/mark
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
app.use('*', secureHeaders());
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
'*',
|
'*',
|
||||||
cors({
|
cors({
|
||||||
origin: (origin) => {
|
origin: (origin) => {
|
||||||
if (!origin) return origin;
|
if (!origin) return origin;
|
||||||
// Dev: localhost-Ports erlaubt. Prod: explizite Whitelist.
|
// 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?:\/\/localhost(:\d+)?$/.test(origin)) return origin;
|
||||||
if (/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin)) return origin;
|
if (/^https?:\/\/127\.0\.0\.1(:\d+)?$/.test(origin)) return origin;
|
||||||
|
}
|
||||||
if (origin === 'https://cardecky.mana.how') return origin;
|
if (origin === 'https://cardecky.mana.how') return origin;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
34
apps/api/src/lib/audit.ts
Normal file
34
apps/api/src/lib/audit.ts
Normal file
|
|
@ -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: `<domäne>.<aktion>`, 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
@ -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 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<typeof createRemoteJWKSet> | null = null;
|
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||||
function getJwks() {
|
function getJwks() {
|
||||||
|
|
|
||||||
99
apps/api/src/middleware/rate-limit.ts
Normal file
99
apps/api/src/middleware/rate-limit.ts
Normal file
|
|
@ -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<string, BucketEntry>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||||
import { authMiddleware, type AuthVars } from '../middleware/auth.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 { chatVisionJson } from '../services/llm-client.ts';
|
||||||
import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
|
import { GeneratedDeckSchema, insertGeneratedDeck } from './decks-generate.ts';
|
||||||
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
||||||
|
|
@ -51,6 +52,8 @@ export function decksFromImageRouter(deps: FromImageDeps = {}): Hono<{ Variables
|
||||||
const dbOf = () => deps.db ?? getDb();
|
const dbOf = () => deps.db ?? getDb();
|
||||||
|
|
||||||
r.use('*', authMiddleware);
|
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) => {
|
r.post('/', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { makeInitialReviewRows } from '../lib/reviews.ts';
|
||||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||||
import { cards, decks, reviews } from '../db/schema/index.ts';
|
import { cards, decks, reviews } from '../db/schema/index.ts';
|
||||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||||
|
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||||
import { ulid } from '../lib/ulid.ts';
|
import { ulid } from '../lib/ulid.ts';
|
||||||
import { chatJson } from '../services/llm-client.ts';
|
import { chatJson } from '../services/llm-client.ts';
|
||||||
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
import { fetchUrlContent } from '../lib/url-fetch.ts';
|
||||||
|
|
@ -130,6 +131,8 @@ export function decksGenerateRouter(deps: GenerateDeps = {}): Hono<{ Variables:
|
||||||
const dbOf = () => deps.db ?? getDb();
|
const dbOf = () => deps.db ?? getDb();
|
||||||
|
|
||||||
r.use('*', authMiddleware);
|
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) => {
|
r.post('/', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import {
|
||||||
tags,
|
tags,
|
||||||
} from '../db/schema/index.ts';
|
} from '../db/schema/index.ts';
|
||||||
import { serviceKeyAuth } from '../middleware/service-key.ts';
|
import { serviceKeyAuth } from '../middleware/service-key.ts';
|
||||||
|
import { rateLimit, ipKey } from '../middleware/rate-limit.ts';
|
||||||
import { getStorage } from '../services/storage.ts';
|
import { getStorage } from '../services/storage.ts';
|
||||||
|
import { auditLog } from '../lib/audit.ts';
|
||||||
|
|
||||||
export type DsgvoDeps = { db?: CardsDb };
|
export type DsgvoDeps = { db?: CardsDb };
|
||||||
|
|
||||||
|
|
@ -98,6 +100,9 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
||||||
const r = new Hono();
|
const r = new Hono();
|
||||||
const dbOf = () => deps.db ?? getDb();
|
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' }));
|
r.use('*', serviceKeyAuth({ envVar: 'CARDS_DSGVO_SERVICE_KEY' }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -108,7 +113,20 @@ export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
|
||||||
r.get('/export', async (c) => {
|
r.get('/export', async (c) => {
|
||||||
const userId = c.req.query('user_id');
|
const userId = c.req.query('user_id');
|
||||||
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
|
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
|
// MinIO-Bucket-Sweep nach DB-Cleanup. Wenn der Storage-Sweep
|
||||||
// scheitert, ist das nicht-fatal — die DB ist schon konsistent
|
// scheitert, ist das nicht-fatal für die DB-Konsistenz (Files
|
||||||
// gelöscht und Storage-Files ohne DB-Eintrag sind tote Bytes.
|
// 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 storageObjectsDeleted = 0;
|
||||||
|
let storageOk = true;
|
||||||
|
let storageError: string | null = null;
|
||||||
try {
|
try {
|
||||||
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
storageOk = false;
|
||||||
|
storageError = err instanceof Error ? err.message : String(err);
|
||||||
console.warn('[dsgvo/delete] storage sweep failed:', 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({
|
return c.json({
|
||||||
deleted: true,
|
deleted: true,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
storage_ok: storageOk,
|
||||||
|
...(storageError ? { storage_error: storageError } : {}),
|
||||||
counts: {
|
counts: {
|
||||||
decks: deletedDecks.length,
|
decks: deletedDecks.length,
|
||||||
import_jobs: deletedImports.length,
|
import_jobs: deletedImports.length,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,44 @@
|
||||||
import { Hono } from 'hono';
|
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();
|
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', (c) => c.json({ status: 'ok' }));
|
||||||
|
|
||||||
healthRoute.get('/healthz/details', (c) =>
|
/**
|
||||||
c.json({
|
* Readiness mit Downstream-Probes. Status 200 wenn DB + MinIO grün,
|
||||||
status: 'ok',
|
* 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',
|
app: 'cards',
|
||||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||||
uptime_s: Math.floor(process.uptime()),
|
uptime_s: Math.floor(process.uptime()),
|
||||||
mana_packages: {
|
checks: {
|
||||||
// In Phase 5 mit @mana/shared-app-tpl ersetzen.
|
db: dbProbe,
|
||||||
'@mana/shared-share-protocol': 'TBD',
|
storage: storageProbe,
|
||||||
'@mana/shared-app-tpl': 'TBD',
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
);
|
allOk ? 200 : 503
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
healthRoute.get('/version', (c) =>
|
healthRoute.get('/version', (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
|
|
@ -25,3 +47,31 @@ healthRoute.get('/version', (c) =>
|
||||||
build: process.env.CARDS_BUILD_SHA ?? 'dev',
|
build: process.env.CARDS_BUILD_SHA ?? 'dev',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type ProbeResult = { ok: true; latency_ms: number } | { ok: false; error: string };
|
||||||
|
|
||||||
|
async function probeDb(): Promise<ProbeResult> {
|
||||||
|
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<ProbeResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { cards, decks, importJobs, mediaFiles, reviews } from '../db/schema/inde
|
||||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||||
import { getStorage } from '../services/storage.ts';
|
import { getStorage } from '../services/storage.ts';
|
||||||
import { buildUserExport } from './dsgvo.ts';
|
import { buildUserExport } from './dsgvo.ts';
|
||||||
|
import { auditLog } from '../lib/audit.ts';
|
||||||
|
|
||||||
export type MeDeps = { db?: CardsDb };
|
export type MeDeps = { db?: CardsDb };
|
||||||
|
|
||||||
|
|
@ -24,7 +25,21 @@ export function meRouter(deps: MeDeps = {}): Hono<{ Variables: AuthVars }> {
|
||||||
/** Voll-Export der eigenen Daten als JSON. */
|
/** Voll-Export der eigenen Daten als JSON. */
|
||||||
r.get('/export', async (c) => {
|
r.get('/export', async (c) => {
|
||||||
const userId = c.get('userId');
|
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 storageObjectsDeleted = 0;
|
||||||
|
let storageOk = true;
|
||||||
|
let storageError: string | null = null;
|
||||||
try {
|
try {
|
||||||
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
storageObjectsDeleted = await getStorage().removeObjectsByPrefix(`${userId}/`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
storageOk = false;
|
||||||
|
storageError = err instanceof Error ? err.message : String(err);
|
||||||
console.warn('[me/delete] storage sweep failed:', 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({
|
return c.json({
|
||||||
deleted: true,
|
deleted: true,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
|
storage_ok: storageOk,
|
||||||
|
...(storageError ? { storage_error: storageError } : {}),
|
||||||
counts: {
|
counts: {
|
||||||
decks: deletedDecks.length,
|
decks: deletedDecks.length,
|
||||||
import_jobs: deletedImports.length,
|
import_jobs: deletedImports.length,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Hono } from 'hono';
|
||||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||||
import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts';
|
import { mediaFiles, type MediaFileRow } from '../db/schema/index.ts';
|
||||||
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
||||||
|
import { rateLimit, userKey } from '../middleware/rate-limit.ts';
|
||||||
import { ulid } from '../lib/ulid.ts';
|
import { ulid } from '../lib/ulid.ts';
|
||||||
import { getStorage, type StorageService } from '../services/storage.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();
|
const storageOf = () => deps.storage ?? getStorage();
|
||||||
|
|
||||||
r.use('*', authMiddleware);
|
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
|
* Multipart-Upload eines einzelnen Files. Bilder/Audio/Video; alles
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import manifest from '../../../../app-manifest.json' with { type: 'json' };
|
||||||
|
|
||||||
import { getDb, type CardsDb } from '../db/connection.ts';
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
||||||
import { authMiddleware, type AuthVars } from '../middleware/auth.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';
|
import { SHARE_HANDLERS, type ShareHandlerName } from '../share-handlers/index.ts';
|
||||||
|
|
||||||
export type ShareDeps = { db?: CardsDb };
|
export type ShareDeps = { db?: CardsDb };
|
||||||
|
|
@ -18,6 +19,8 @@ export function shareRouter(deps: ShareDeps = {}): Hono<{ Variables: AuthVars }>
|
||||||
const dbOf = () => deps.db ?? getDb();
|
const dbOf = () => deps.db ?? getDb();
|
||||||
|
|
||||||
r.use('*', authMiddleware);
|
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) => {
|
r.post('/receive', async (c) => {
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
|
|
|
||||||
78
apps/api/tests/rate-limit.test.ts
Normal file
78
apps/api/tests/rate-limit.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/api/tests/setup.ts
Normal file
9
apps/api/tests/setup.ts
Normal file
|
|
@ -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';
|
||||||
|
}
|
||||||
7
apps/api/vitest.config.ts
Normal file
7
apps/api/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
25
apps/web/src/hooks.server.ts
Normal file
25
apps/web/src/hooks.server.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -21,12 +21,23 @@ export function exportMe() {
|
||||||
return api<UserExport>('/api/v1/me/export');
|
return api<UserExport>('/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). */
|
/** Löscht alle Cards-Daten des eingeloggten Users (User-JWT). */
|
||||||
export function deleteMe() {
|
export function deleteMe() {
|
||||||
return api<{ deleted: true; user_id: string; counts: { decks: number; import_jobs: number } }>(
|
return api<DeleteMeResult>('/api/v1/me/delete', { method: 'POST' });
|
||||||
'/api/v1/me/delete',
|
|
||||||
{ method: 'POST' }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserStats {
|
export interface UserStats {
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,22 @@
|
||||||
deleting = true;
|
deleting = true;
|
||||||
try {
|
try {
|
||||||
const r = await deleteMe();
|
const r = await deleteMe();
|
||||||
|
if (r.storage_ok) {
|
||||||
toasts.success(
|
toasts.success(
|
||||||
t('account.delete_done', { decks: r.counts.decks, imports: r.counts.import_jobs }),
|
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();
|
devUser.clear();
|
||||||
goto('/');
|
goto('/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,403 @@
|
||||||
# Feature Ideas
|
# 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: <beliebig>" 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
|
## 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
|
### Scheduler-Verbesserungen
|
||||||
|
|
||||||
- **Card Burial / Suspension** — Karten temporär deaktivieren ohne Löschen; häufig angefragtes Anki-Feature
|
- **FSRS-Parameter pro User optimieren** — `ts-fsrs` liefert
|
||||||
- **Geschwister-Burial** — Cloze-Cluster und basic-reverse-Seiten nicht am selben Tag wiederholen
|
`computeParameters()` aus Review-History; Schema (`decks.fsrs_settings`)
|
||||||
- **Custom Study Sessions** — Gefilterte Sitzungen: nur neue Karten, nur Fehler der letzten Woche, nach Tag filtern
|
und Per-Deck-Override sind vorbereitet, aber kein Endpoint und kein UI.
|
||||||
- **Subdeck-Unterstützung** — Hierarchische Deck-Struktur (z. B. Vokabeln → Nomen / Verben)
|
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
|
## Gamification & Motivation
|
||||||
|
|
||||||
- **Daily Streaks** — Tägliche Lernkette mit optionalem Freeze-Token
|
- **Streak-Freeze-Token** — ein Streak-Schutztag pro Woche, optional
|
||||||
- **XP + Badges** — Meilensteine (erstes Deck, 100 Karten, 30-Tage-Streak)
|
durch Credits kaufbar.
|
||||||
- **Tages-Ziele** — "Heute: 20 Karten" mit Progress-Bar im Dashboard
|
- **Streak im Header** — heute nur im Stats-Dashboard sichtbar;
|
||||||
- **Push/Email-Reminders** — "Du hast heute noch 15 fällige Karten" via mana-notify
|
Header-Glyph mit Zahl wäre Mikro-Aufwand, sichtbare Wirkung.
|
||||||
- **Estimated Mastery Date** — "Dieses Deck beherrschst du voraussichtlich in 3 Wochen" (aus FSRS-Parametern berechenbar)
|
- **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
|
## KI-Features
|
||||||
|
|
||||||
- **Auto-Cloze-Generator** — Text markieren → `{{c1::...}}` automatisch einfügen
|
- **Auto-Cloze-Generator** — Text markieren → `{{c1::...}}`
|
||||||
- **Card-Split-Vorschlag** — KI erkennt informationsreiche Karten und schlägt Aufteilung vor
|
automatisch einfügen.
|
||||||
- **Erklär-Modus** — Nach falscher Antwort: KI erklärt den Zusammenhang (opt-in)
|
- **Card-Split-Vorschlag** — KI erkennt informationsreiche Karten
|
||||||
- **Auto-Tagging** — Karten beim Erstellen / Importieren semantisch taggen
|
und schlägt Aufteilung vor.
|
||||||
- **Duplicate Detection** — Semantische Ähnlichkeit über Decks hinweg erkennen
|
- **Erklär-Modus** — Nach falscher Antwort: KI erklärt den
|
||||||
- **Card Quality Score** — Hinweis: "Diese Karte hat zu viel Text" + Verbesserungsvorschlag
|
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
|
## Analytics & Insights
|
||||||
|
|
||||||
- **Vergessenskurven-Visualisierung** — Pro Deck und Tag, aus FSRS-State ableitbar
|
- **Vergessenskurven-Visualisierung** — pro Deck und Tag, aus
|
||||||
- **Retention-Rate** — Aufgeschlüsselt nach Kategorie und Sprache
|
FSRS-State ableitbar.
|
||||||
- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend
|
- **Retention-Rate** — aufgeschlüsselt nach Kategorie und Sprache.
|
||||||
- **Karten-Schwierigkeits-Heatmap** — Welche Karten kosten die meiste Review-Zeit
|
- **Lernzeit-Tracking** — Minuten pro Session, Wochentrend.
|
||||||
- **Wöchentliche Zusammenfassung** — In-App oder per Email via mana-notify
|
- **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
|
## Import / Export
|
||||||
|
|
||||||
- **CSV Import/Export** — Einfachste Interop, relevant für Lehrer und Nutzer-Migration
|
- **PDF-Export** — druckbare Karteikarten (A6-Format,
|
||||||
- **PDF Export** — Druckbare Karteikarten (A6-Format, vorder-/rückseitig)
|
vorder-/rückseitig). Print-Route existiert schon
|
||||||
- **Web Clipper** (Browser-Extension) — Markierter Text → sofort neue Karte; eigenes Projekt
|
(`decks/[id]/print/`), aber als HTML-Print, nicht generiert-PDF.
|
||||||
- **Quizlet Import** — Größte Nutzerbasis im Markt, hohe Migrations-Relevanz
|
- **`.apkg`-Export** — Round-Trip mit dem Anki-Parser (Parser ist
|
||||||
- **SuperMemo XML** — Für Power-User aus dem SM-Ecosystem
|
schon da). Stärkste „du kannst jederzeit weggehen"-Geste,
|
||||||
- **FSRS-State Export** — Lernstand als JSON exportieren für Backup und Migration
|
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
|
## Zusammenarbeit & Community
|
||||||
|
|
||||||
- **Study Spaces** — Gemeinsame Decks für Schulklassen und Lerngruppen (braucht mana-auth Gruppen-Konzept)
|
- **Study Spaces** — gemeinsame Decks für Schulklassen und
|
||||||
- **Deck-Ratings & Kommentare** — Qualitätssicherung im Marketplace durch Community
|
Lerngruppen (braucht Gruppen-Konzept in `mana-auth`, das noch nicht
|
||||||
- **Study Challenges** — Mit Freunden auf demselben Deck messen
|
existiert; `decks.visibility = 'space'` ist im Schema vorgesehen,
|
||||||
- **Kreator-Analytics** — Für Marketplace-Publisher: Views, Forks, Abonnenten-Retention
|
aber ohne Spaces-Backend wirkungslos).
|
||||||
- **Collaborative Decks** — Team-Editing mit Rollen (Maintainer / Contributor)
|
- **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
|
## UX / Plattform
|
||||||
|
|
||||||
- **PWA Offline-Support** — Service Worker + lokaler Lern-Cache; erfordert Entscheidung über FSRS-State-Sync-Strategie (server-authoritative vs. lokal)
|
- **PWA Offline-Read-Only** als Zwischenschritt zur vollen PWA:
|
||||||
- **Keyboard Shortcuts im Study-Mode** — Space = Antwort zeigen, 1–4 = Rating
|
Service-Worker cached die letzte Queue, Bewertungen werden
|
||||||
- **Dark Mode / Theme-Switcher** — In-App-Auswahl statt nur System-Präferenz
|
gepuffert. Bricht die Server-authoritative-Invariante nicht
|
||||||
- **Bulk-Operationen** — Mehrere Karten auswählen, verschieben, taggen, löschen
|
(Puffer ist transient).
|
||||||
- **Dynamic Decks (Smart Playlists)** — Automatisch gefiltert: z. B. "Alle Karten mit Tag 'Grammatik' aus 3 Decks"
|
- **Dark Mode / Theme-Switcher** — `data-theme="forest"` ist in
|
||||||
- **Card History** — Lernverlauf pro Karte: wann wie bewertet
|
`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
|
||||||
|
`<img>` gerendert werden, ist es harmlos; bei `<object>`/`<iframe>`
|
||||||
|
oder direkter Einbettung gibt es ein XSS-Loch. Mindestens
|
||||||
|
Content-Security-Policy `img-src` strikt halten, oder SVG-Upload
|
||||||
|
separat sanitisieren (svgo + Script-Strip).
|
||||||
|
- **Security-Headers — gebaut 2026-05-12, noch nicht deployed.**
|
||||||
|
- API: `secureHeaders()` aus Hono vor `cors()` eingehängt
|
||||||
|
(`apps/api/src/index.ts:3,30`).
|
||||||
|
- Web: `apps/web/src/hooks.server.ts` neu, setzt X-Frame-Options
|
||||||
|
DENY, X-Content-Type-Options nosniff, Referrer-Policy
|
||||||
|
strict-origin-when-cross-origin, plus HSTS (180 Tage,
|
||||||
|
includeSubDomains) wenn `NODE_ENV=production`.
|
||||||
|
- **CSP bewusst ausgespart** — eine richtige Content-Security-
|
||||||
|
Policy braucht Browser-Test (inline-styles, Theme-Assets,
|
||||||
|
Markdown-Renderer). Eigener Sprint. SVG-Upload-Risiko (oben)
|
||||||
|
bleibt bis dahin ungemildert.
|
||||||
|
- **CORS lässt `localhost` in Prod durch — gebaut 2026-05-12,
|
||||||
|
noch nicht deployed.** Live-Probe hatte
|
||||||
|
`Access-Control-Allow-Origin: http://localhost:9999` gezeigt.
|
||||||
|
Patch: localhost-Regex in `apps/api/src/index.ts` per
|
||||||
|
`NODE_ENV === 'production'`-Check disabled.
|
||||||
|
- **DSGVO-Delete-Honesty — gebaut 2026-05-12, noch nicht deployed.**
|
||||||
|
Sowohl `/api/v1/dsgvo/delete` (Service-Key) als auch
|
||||||
|
`/api/v1/me/delete` (Self-Service) liefern jetzt `storage_ok: true|false`
|
||||||
|
+ optional `storage_error` im Response. Frontend zeigt im
|
||||||
|
Account-Delete-Flow eine Fehler-Toast wenn `storage_ok=false`.
|
||||||
|
- **Service-Key-Rotation** — ein statisches `CARDS_DSGVO_SERVICE_KEY`
|
||||||
|
in env, kein Revoke. Phase F-1 ist in `service-key.ts:11`
|
||||||
|
geplant (Verifikation gegen `mana-auth.apps.app_service_keys`).
|
||||||
|
Bis dahin: Rotation-Playbook in `docs/playbooks/` festschreiben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations & Resilienz
|
||||||
|
|
||||||
|
Bisher gar nicht in dieser Liste behandelt, aber Cards ist live:
|
||||||
|
|
||||||
|
- **Rate-Limiting — gebaut 2026-05-12, noch nicht deployed.**
|
||||||
|
In-Memory Sliding-Window-Middleware in
|
||||||
|
`apps/api/src/middleware/rate-limit.ts`, dependency-frei. Aktive
|
||||||
|
Limits: `share.receive` 60/min/IP, `media.upload` 30/min/user,
|
||||||
|
`decks.generate` 10/min/user, `decks.from-image` 10/min/user,
|
||||||
|
`dsgvo.*` 10/min/IP. Verifiziert in `tests/rate-limit.test.ts`
|
||||||
|
(4 Tests). Bei Multi-Container-Setup später auf Redis oder
|
||||||
|
Cloudflare-Rate-Limit umsteigen.
|
||||||
|
- **Audit-Log für DSGVO-Aktionen — gebaut 2026-05-12, noch nicht
|
||||||
|
deployed.** `apps/api/src/lib/audit.ts` schreibt strukturierte
|
||||||
|
JSON-Zeilen auf stdout (`kind: 'audit'`). Aktiv auf
|
||||||
|
`/dsgvo/export`, `/dsgvo/delete`, `/me/export`, `/me/delete`.
|
||||||
|
Persistierung in eine `audit_log`-Tabelle ist additiv möglich,
|
||||||
|
ohne Aufrufer anzupassen.
|
||||||
|
- **Health-Endpoint inkl. DB+MinIO-Probe — gebaut 2026-05-12, noch
|
||||||
|
nicht deployed.** `/healthz` bleibt simpel (Liveness), neu ist
|
||||||
|
`/healthz/details` mit echtem `SELECT 1` und MinIO-`bucketExists`-
|
||||||
|
Probe, Status 200 bei beidem grün, sonst 503 mit Latency+Error
|
||||||
|
je Probe.
|
||||||
|
- **Strukturiertes Logging + Request-IDs** end-to-end — vermutlich
|
||||||
|
via `@mana/shared-hono` vorhanden, aber im Frontend-Fehler-Toast
|
||||||
|
bisher keine sichtbare Correlation-ID.
|
||||||
|
- **Backup-Strategie für `cards`-DB + MinIO-Bucket** — hängt am
|
||||||
|
globalen Backup-Cron-TODO, ist aber jetzt mit Prod-Daten dringend.
|
||||||
|
- **`content_hash`-Backfill-Skript** — Pre-Sprint-9j-Karten haben
|
||||||
|
`content_hash = NULL`, Re-Import-Dedupe ist auf diese Karten
|
||||||
|
blind (siehe `STATUS.md` Subtilität #10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mission-Fit (Vereins-Werte aufs Produkt anwenden)
|
||||||
|
|
||||||
|
- **Anti-Lock-In als Feature, nicht als Disclaimer** — `.apkg`-
|
||||||
|
Export plus FSRS-State-Export plus eine sichtbare „Daten
|
||||||
|
mitnehmen"-Surface in `account/`. Heute ist es als
|
||||||
|
DSGVO-Export versteckt.
|
||||||
|
- **Telemetrie-Audit dokumentieren — verifiziert: 0 Tracker** —
|
||||||
|
`apps/web/src/` enthält weder PostHog noch Plausible/Matomo/Umami,
|
||||||
|
kein `gtag`, keine `track()`-Aufrufe. Das ist ein hartes
|
||||||
|
Mission-Argument, das man heute kostenlos hergibt. Vorschlag:
|
||||||
|
kleine Surface in `account/` oder im Footer („Cards trackt dich
|
||||||
|
nicht — Code-Beweis"), die auf den `git grep`-Befund im Repo
|
||||||
|
verlinkt.
|
||||||
|
- **Tier-frei-Modus erhalten** — Schema und Plumbing für
|
||||||
|
`mana-credits` sind da (`credits-client.ts`, `requireTier`), aber
|
||||||
|
MVP ist tier-frei. Bei jeder neuen Feature-Idee bewusst markieren,
|
||||||
|
ob sie tier-pflichtig wird oder nicht; default frei.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DevX & Code-Hygiene
|
||||||
|
|
||||||
|
- **Playwright-E2E in CI** — `STATUS.md` spricht von „lokalem
|
||||||
|
E2E-Smoke"; ein `playwright.config.ts` existiert im Repo nicht.
|
||||||
|
Bei Live-App mit Marketplace-Forks die schmerzhafteste
|
||||||
|
Regressions-Quelle.
|
||||||
|
- **Drizzle-Migrationen versionieren** — `drizzle.config.ts` zeigt
|
||||||
|
auf `out: './src/db/migrations'`, der Ordner existiert aber nicht
|
||||||
|
(nur `db:push`). Mit Prod-Daten ein Risiko; Wechsel auf
|
||||||
|
`drizzle-kit generate` + versionierte Migrationen.
|
||||||
|
- **Storybook / Histoire für `lib/components/`** — 20+ Svelte-
|
||||||
|
Komponenten ohne isolierte Vorschau; Lost-Pixel-Theming-Strategie
|
||||||
|
würde davon doppelt profitieren.
|
||||||
|
- **CLAUDE.md ↔ Realität synchen** — CLAUDE.md erwähnt
|
||||||
|
`deploy.yml` neben `ci.yml`, im `.github/workflows/`-Ordner liegt
|
||||||
|
aber nur `ci.yml`. Entweder Workflow nachziehen oder Doku
|
||||||
|
korrigieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Offene Punkte
|
## Offene Punkte
|
||||||
|
|
||||||
- **Schnell umsetzbar / hoher ROI:** Keyboard Shortcuts, Daily Streaks, CSV-Import — geringer Aufwand, spürbare UX-Verbesserung
|
- **Was nach diesem Hardening-Sprint übrig bleibt:**
|
||||||
- **Web Clipper** ist ein separates Browser-Extension-Projekt und braucht einen eigenen Scope
|
1. **CSP setzen** — eigener Mini-Sprint mit Browser-Test, weil
|
||||||
- **PWA Offline** ist der größte Architektur-Trade-off: die aktuelle server-authoritative FSRS-Architektur müsste um einen lokalen Sync-Layer erweitert werden
|
SVG-XSS-Risiko erst mit CSP wirklich gemildert ist.
|
||||||
- **Study Spaces** setzt ein Gruppen-Konzept in mana-auth voraus, das noch nicht existiert
|
2. **Drizzle-Migrationen versionieren** — bevor das erste Prod-
|
||||||
|
Schema-Diff weh tut. Heute läuft alles via `db:push`.
|
||||||
|
3. **FSRS-Parameter-Optimierung pro User** — größter messbarer
|
||||||
|
Lern-Effekt, Schema schon vorbereitet.
|
||||||
|
4. **Service-Key-Rotation-Playbook** in `docs/playbooks/`
|
||||||
|
anlegen, bis die `mana-auth.app_service_keys`-Anbindung
|
||||||
|
(Phase F-1) fertig ist.
|
||||||
|
5. **JWT-Storage von `localStorage` → HttpOnly-Cookie** —
|
||||||
|
XSS-Token-Diebstahl-Härtung. Größerer Umbau weil der
|
||||||
|
SSO-Callback-Flow das mitziehen muss.
|
||||||
|
- **PWA-Offline-Read-Only** ist der Architektur-Trade-off mit dem
|
||||||
|
größten Risiko: der saubere server-authoritative Pfad würde um
|
||||||
|
einen Sync-Puffer erweitert. Lieber spät als verfrüht.
|
||||||
|
- **Study Spaces** bleibt blockiert durch fehlendes Gruppen-Konzept
|
||||||
|
in `mana-auth`; Schema-Slot `visibility = 'space'` ist
|
||||||
|
vorbereitet, aber ohne Backend wirkungslos.
|
||||||
|
- **Marketplace-Reifegrad transparent in STATUS.md führen** —
|
||||||
|
aktuell taucht „Marketplace" in STATUS.md nur in der „Strategie-
|
||||||
|
B-Ausnahme"-Notiz auf; ob Marketplace im UI freigeschaltet ist
|
||||||
|
oder hinter einer Feature-Flag steckt, ist von außen nicht
|
||||||
|
ablesbar.
|
||||||
|
- **Notify-Anbindung ist offen** — `mana-notify` wird von Cards
|
||||||
|
noch nicht aufgerufen; sobald Streak/Weekly/PR-Notifies
|
||||||
|
reinkommen sollen, ist das ein eigener Sprint.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ CARDS_S3_SECRET_KEY=change-me-32-bytes-base64
|
||||||
CARDS_DSGVO_SERVICE_KEY=change-me-msk-prefix
|
CARDS_DSGVO_SERVICE_KEY=change-me-msk-prefix
|
||||||
CARDS_API_VERSION=1.0.0
|
CARDS_API_VERSION=1.0.0
|
||||||
|
|
||||||
|
# Fail-secure: in Produktion MUSS dieser Wert 'false' bleiben (oder die
|
||||||
|
# Variable ganz weggelassen werden — der Compose-Default ist 'false').
|
||||||
|
# Nur für gezielte Diagnose temporär auf 'true' setzen und sofort
|
||||||
|
# wieder zurückstellen. Wenn 'true' aktiv ist, akzeptiert die API
|
||||||
|
# `X-User-Id`-Header als vollwertige Auth (founder-Tier).
|
||||||
|
CARDS_AUTH_DEV_STUB=false
|
||||||
|
|
||||||
# Verdaccio-Token für @mana/* — nutze denselben claudebot-Token, den
|
# Verdaccio-Token für @mana/* — nutze denselben claudebot-Token, den
|
||||||
# auch die Plattform verwendet (siehe ~/.cloudflared/.npmrc oder
|
# auch die Plattform verwendet (siehe ~/.cloudflared/.npmrc oder
|
||||||
# secret_verdaccio_claudebot.md).
|
# secret_verdaccio_claudebot.md).
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,11 @@ services:
|
||||||
MANA_AUTH_URL: https://auth.mana.how
|
MANA_AUTH_URL: https://auth.mana.how
|
||||||
MANA_CREDITS_URL: https://credits.mana.how
|
MANA_CREDITS_URL: https://credits.mana.how
|
||||||
CARDS_MANA_SERVICE_KEY: ${CARDS_MANA_SERVICE_KEY:-}
|
CARDS_MANA_SERVICE_KEY: ${CARDS_MANA_SERVICE_KEY:-}
|
||||||
CARDS_AUTH_DEV_STUB: ${CARDS_AUTH_DEV_STUB:-true}
|
# Fail-secure: opt-in. Auf der Prod-Box gar nicht setzen
|
||||||
|
# ⇒ Bypass AUS. Nur für gezielte lokale Diagnose temporär
|
||||||
|
# auf 'true' setzen (und sofort wieder rausnehmen).
|
||||||
|
CARDS_AUTH_DEV_STUB: ${CARDS_AUTH_DEV_STUB:-false}
|
||||||
|
NODE_ENV: production
|
||||||
ports:
|
ports:
|
||||||
- '127.0.0.1:3191:3081'
|
- '127.0.0.1:3191:3081'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,6 @@ export function subIndexCount(type: string): number {
|
||||||
return 1;
|
return 1;
|
||||||
case 'multiple-choice':
|
case 'multiple-choice':
|
||||||
return 1;
|
return 1;
|
||||||
case 'multiple-choice':
|
|
||||||
return 1;
|
|
||||||
case 'cloze':
|
case 'cloze':
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'subIndexCount("cloze") not supported — use subIndexCountForCloze(text) from @cards/domain'
|
'subIndexCount("cloze") not supported — use subIndexCountForCloze(text) from @cards/domain'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue