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>
121 lines
3.9 KiB
TypeScript
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;
|
|
}
|