cards/apps/api/src/routes/dsgvo.ts
Till 0328caa333 Phase 5: Föderations-Endpunkte — Cards ist föderierter Peer
Endpoints (alle Pfade aus app-manifest.json):
- POST /api/v1/share/receive — User-JWT-Auth, ShareEnvelope-Strict-
  Validation (cross-user-forbidden), Recipient-Match, Type-Accept-
  Lookup über Manifest, Payload-Schema-Validation, Handler-Dispatch
- POST /api/v1/tools/:name — User-JWT, dispatch nach `cards.create`
  und `cards.search` mit Tool-Schemas aus @cards/domain
- GET /api/v1/search — User-JWT, ILIKE auf cards.fields jsonb +
  decks.name, baut SearchResultEnvelope für mana-search-Aggregator
- GET /api/v1/dsgvo/export?user_id=… — Service-Key, voll-Bundle aller
  Cards-Daten des Users (decks, cards, reviews, study_sessions, tags,
  media_refs, import_jobs)
- POST /api/v1/dsgvo/delete — Service-Key, kaskadiert via FK-Cascade
  decks → cards → reviews/media_refs/card_tags/tags/study_sessions
  plus separates Cleanup von import_jobs

Share-Handlers (apps/api/src/share-handlers/):
- create_card_from_quote (mana/quote → front=text, back=source)
- save_link_as_card (mana/url → front=title, back=url+description)
- create_card_from_text (mana/text → front=erste-zeile, back=rest)
Alle landen via ensureInboxDeck() in einem auto-erstellten "Inbox"-Deck
pro User, inklusive automatischer FSRS-Reviews-Init in Transaktion.

Lokales Protocol-Mirror in @cards/domain/src/protocol/ (envelope,
payloads, search): TEMPORARY-Markierung mit Swap-Plan auf
@mana/shared-share-protocol via Verdaccio sobald NPM_AUTH_TOKEN da ist.
Spec-strict — UUID für user_id, ULID für share_id, Crockford-Base32.

Service-Key-Middleware mit constant-time-Compare gegen
process.env.CARDS_DSGVO_SERVICE_KEY (Phase F-1: ersetzt durch
mana-auth.app_service_keys-Lookup).

Tests:
- 70 Vitest-Tests grün (27 cards-domain + 43 apps/api):
  - share.test.ts: Auth-Gate, Cross-User-Sperre, User-Mismatch (403),
    Wrong-Recipient (422), Unknown-Type (422), Invalid-Payload (422),
    Wrapped { envelope, delivery_token }-Body akzeptiert
  - tools.test.ts: Auth, Unknown-Tool (404), cards.create-Validation,
    cards.search-Envelope-Shape
  - search.test.ts: Auth, Missing-Query (422), Query-too-long (422),
    Envelope-Version 0.1 + envelope-Felder
  - dsgvo.test.ts: Service-Key-Gate (401), Missing-User-ID (400),
    Export-Bundle-Shape, Delete-Counts, Key-not-configured (500)
- pnpm run type-check  4/4 packages
- E2E-Smoke gegen Postgres: Quote-Share→Inbox-Deck→Karte→Search-Hit→
  DSGVO-Export+Delete-Roundtrip clean (alle 3 Tabellen 0 nach delete)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:10:35 +02:00

121 lines
3.9 KiB
TypeScript

import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb, type CardsDb } from '../db/connection.ts';
import {
cards,
decks,
importJobs,
mediaRefs,
reviews,
studySessions,
tags,
} from '../db/schema/index.ts';
import { serviceKeyAuth } from '../middleware/service-key.ts';
export type DsgvoDeps = { db?: CardsDb };
/**
* DSGVO-Endpunkte. Aufgerufen von mana-admin im Verein-DSGVO-
* Fan-Out (Auskunft Art. 15/20 + Löschung Art. 17).
*
* Auth: Service-Key (`X-Service-Key` muss `CARDS_DSGVO_SERVICE_KEY`
* matchen). Phase F-1: ersetzt durch mana-auth-Service-Key-Lookup.
*/
export function dsgvoRouter(deps: DsgvoDeps = {}): Hono {
const r = new Hono();
const dbOf = () => deps.db ?? getDb();
r.use('*', serviceKeyAuth({ envVar: 'CARDS_DSGVO_SERVICE_KEY' }));
/**
* Voll-Export aller Cards-Daten eines Users. Liefert serialisier-
* bares JSON. mana-admin packt das mit den Antworten anderer Apps
* in einen ZIP für den User.
*/
r.get('/export', async (c) => {
const userId = c.req.query('user_id');
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
const db = dbOf();
const [decksRows, cardsRows, reviewsRows, sessionsRows, tagsRows, mediaRows, importsRows] =
await Promise.all([
db.select().from(decks).where(eq(decks.userId, userId)),
db.select().from(cards).where(eq(cards.userId, userId)),
db.select().from(reviews).where(eq(reviews.userId, userId)),
db.select().from(studySessions).where(eq(studySessions.userId, userId)),
db.select().from(tags).where(eq(tags.userId, userId)),
db.select().from(mediaRefs).where(eq(mediaRefs.userId, userId)),
db.select().from(importJobs).where(eq(importJobs.userId, userId)),
]);
return c.json({
user_id: userId,
exported_at: new Date().toISOString(),
app: 'cards',
app_version: process.env.CARDS_API_VERSION ?? '0.0.0',
data: {
decks: decksRows.map((d) => ({ ...d, createdAt: d.createdAt.toISOString(), updatedAt: d.updatedAt.toISOString() })),
cards: cardsRows.map((x) => ({
...x,
createdAt: x.createdAt.toISOString(),
updatedAt: x.updatedAt.toISOString(),
})),
reviews: reviewsRows.map((r) => ({
...r,
due: r.due.toISOString(),
lastReview: r.lastReview ? r.lastReview.toISOString() : null,
})),
study_sessions: sessionsRows.map((s) => ({
...s,
startedAt: s.startedAt.toISOString(),
finishedAt: s.finishedAt ? s.finishedAt.toISOString() : null,
})),
tags: tagsRows.map((t) => ({ ...t, createdAt: t.createdAt.toISOString() })),
media_refs: mediaRows.map((m) => ({ ...m, createdAt: m.createdAt.toISOString() })),
import_jobs: importsRows.map((j) => ({
...j,
createdAt: j.createdAt.toISOString(),
finishedAt: j.finishedAt ? j.finishedAt.toISOString() : null,
})),
},
});
});
/**
* Vollständige Löschung aller Cards-Daten eines Users. FK-Cascades
* räumen automatisch:
* decks → cards → reviews
* cards → media_refs
* cards → card_tags
* decks → tags
* decks → study_sessions
* Verbleibend: import_jobs (eigene Tabelle ohne FK) — wird separat gelöscht.
*/
r.post('/delete', async (c) => {
const body = await c.req.json().catch(() => null);
const userId = (body as { user_id?: string } | null)?.user_id;
if (!userId) return c.json({ error: 'missing_user_id' }, 400);
const db = dbOf();
const [deletedDecks, deletedImports] = await db.transaction(async (tx) => {
const dd = await tx.delete(decks).where(eq(decks.userId, userId)).returning({ id: decks.id });
const di = await tx
.delete(importJobs)
.where(eq(importJobs.userId, userId))
.returning({ id: importJobs.id });
return [dd, di];
});
return c.json({
deleted: true,
user_id: userId,
counts: {
decks: deletedDecks.length,
import_jobs: deletedImports.length,
},
});
});
return r;
}