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>
This commit is contained in:
Till 2026-05-08 17:10:35 +02:00
parent 89a7a9250b
commit 0328caa333
19 changed files with 1371 additions and 0 deletions

View file

@ -34,3 +34,11 @@ NPM_AUTH_TOKEN=
# === MinIO (über mana-media) === # === MinIO (über mana-media) ===
CARDS_MEDIA_BUCKET=cards-storage CARDS_MEDIA_BUCKET=cards-storage
# === DSGVO Service-Key ===
# X-Service-Key-Wert, mit dem mana-admin DSGVO-Export/Delete-Calls
# autorisiert. In Prod aus mana-auth.app_service_keys.
CARDS_DSGVO_SERVICE_KEY=msk_dev_change_me_dsgvo
# === Public-URL für Deep-Links in Share-Handlers + Search-Hits ===
CARDS_PUBLIC_URL=http://localhost:3082

View file

@ -6,6 +6,10 @@ import { healthRoute } from './routes/health.ts';
import { decksRouter } from './routes/decks.ts'; import { decksRouter } from './routes/decks.ts';
import { cardsRouter } from './routes/cards.ts'; import { cardsRouter } from './routes/cards.ts';
import { reviewsRouter } from './routes/reviews.ts'; import { reviewsRouter } from './routes/reviews.ts';
import { shareRouter } from './routes/share.ts';
import { toolsRouter } from './routes/tools.ts';
import { searchRouter } from './routes/search.ts';
import { dsgvoRouter } from './routes/dsgvo.ts';
const app = new Hono(); const app = new Hono();
@ -31,6 +35,10 @@ app.route('/.well-known/mana-app.json', manifestRoute);
app.route('/api/v1/decks', decksRouter()); app.route('/api/v1/decks', decksRouter());
app.route('/api/v1/cards', cardsRouter()); app.route('/api/v1/cards', cardsRouter());
app.route('/api/v1/reviews', reviewsRouter()); app.route('/api/v1/reviews', reviewsRouter());
app.route('/api/v1/share', shareRouter());
app.route('/api/v1/tools', toolsRouter());
app.route('/api/v1/search', searchRouter());
app.route('/api/v1/dsgvo', dsgvoRouter());
app.get('/', (c) => app.get('/', (c) =>
c.json({ c.json({

View file

@ -0,0 +1,40 @@
import { and, eq } from 'drizzle-orm';
import type { CardsDb } from '../db/connection.ts';
import { decks } from '../db/schema/index.ts';
import { ulid } from './ulid.ts';
/** Stabiler Name des Inbox-Decks pro User. Auto-Created bei erstem Share. */
export const INBOX_DECK_NAME = 'Inbox';
/**
* Holt das Inbox-Deck eines Users oder legt es neu an.
* Wird von allen Share-Receive-Handlern benutzt eingehende
* Shares landen immer in der Inbox, der User kann sie später
* in andere Decks umsortieren.
*/
export async function ensureInboxDeck(db: CardsDb, userId: string) {
const [existing] = await db
.select()
.from(decks)
.where(and(eq(decks.userId, userId), eq(decks.name, INBOX_DECK_NAME)))
.limit(1);
if (existing) return existing;
const now = new Date();
const [created] = await db
.insert(decks)
.values({
id: ulid(),
userId,
name: INBOX_DECK_NAME,
description: 'Eingehende Shares aus anderen Apps. Sortiere sie in eigene Decks um.',
color: '#888888',
visibility: 'private',
fsrsSettings: {},
createdAt: now,
updatedAt: now,
})
.returning();
return created;
}

View file

@ -0,0 +1,76 @@
import { and, eq, sql } from 'drizzle-orm';
import type { CardsDb } from '../db/connection.ts';
import { cards, decks } from '../db/schema/index.ts';
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
export type SearchHit = {
id: string;
type: 'card';
title: string;
snippet?: string;
link: string;
score: number;
created_at?: string;
updated_at?: string;
};
/**
* MVP-Suche: case-insensitive ILIKE auf der `fields` JSONB-Spalte
* und auf Deck-Name. Vector-/Trigram-Suche kommt später (eigener
* Index, separate Phase).
*
* Score-Heuristik MVP: 1.0 für Treffer (kein Ranking), absteigend
* sortiert nach updated_at. Echtes Scoring wenn pg_trgm/vector
* eingerichtet sind.
*/
export async function searchUserCards(
db: CardsDb,
userId: string,
query: string,
maxResults = 30
): Promise<{ hits: SearchHit[]; tookMs: number }> {
const t0 = Date.now();
const pattern = `%${query.replace(/[%_]/g, '\\$&')}%`;
const rows = await db
.select({
id: cards.id,
deckId: cards.deckId,
fields: cards.fields,
createdAt: cards.createdAt,
updatedAt: cards.updatedAt,
deckName: decks.name,
})
.from(cards)
.innerJoin(decks, eq(decks.id, cards.deckId))
.where(
and(
eq(cards.userId, userId),
sql`(${cards.fields}::text ILIKE ${pattern} OR ${decks.name} ILIKE ${pattern})`
)
)
.orderBy(sql`${cards.updatedAt} DESC`)
.limit(Math.min(maxResults, 200));
const hits = rows.map((r): SearchHit => {
const fields = r.fields as Record<string, string>;
const front = fields.front ?? '';
const back = fields.back ?? '';
const title = front.split('\n')[0]?.slice(0, 200) ?? r.deckName;
const snippet = back.slice(0, 200) || undefined;
return {
id: r.id,
type: 'card',
title: title || '(leer)',
snippet,
link: `${APP_BASE_URL}/c/${r.id}`,
score: 1.0,
created_at: r.createdAt.toISOString(),
updated_at: r.updatedAt.toISOString(),
};
});
return { hits, tookMs: Date.now() - t0 };
}

View file

@ -0,0 +1,41 @@
import type { MiddlewareHandler } from 'hono';
/**
* Service-Key-Middleware für DSGVO-Endpunkte und sonstige
* service-zu-service-Calls.
*
* Heute (Phase 5): vergleicht `X-Service-Key`-Header per
* constant-time-Compare gegen `process.env.CARDS_DSGVO_SERVICE_KEY`.
*
* Phase F-1: ersetzt durch Verifikation gegen mana-auth's
* `apps.app_service_keys` Tabelle (caller-App = `mana-admin`).
*/
function constantTimeEquals(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}
export function serviceKeyAuth(opts: { envVar: string }): MiddlewareHandler {
return async (c, next) => {
const expected = process.env[opts.envVar];
if (!expected) {
return c.json(
{
error: 'service_key_not_configured',
detail: `${opts.envVar} env-var is not set`,
},
500
);
}
const provided = c.req.header('X-Service-Key');
if (!provided || !constantTimeEquals(provided, expected)) {
return c.json({ error: 'service_key_invalid' }, 401);
}
await next();
};
}

View file

@ -0,0 +1,121 @@
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;
}

View file

@ -0,0 +1,67 @@
import { Hono } from 'hono';
import {
SEARCH_ENVELOPE_VERSION,
type SearchResultEnvelope,
} from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { searchUserCards } from '../lib/search.ts';
const APP_VERSION = process.env.CARDS_API_VERSION ?? '0.0.0';
export type SearchDeps = { db?: CardsDb };
/**
* GET /api/v1/search?q=...
*
* Liefert eine `SearchResultEnvelope` (Phase 5.5 MVP) im Format,
* das der mana-search Aggregator erwartet. Aufrufer ist mana-search,
* der parallel an alle App-Endpoints fan-outet.
*/
export function searchRouter(deps: SearchDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.get('/', async (c) => {
const userId = c.get('userId');
const q = c.req.query('q')?.trim();
const limit = Number(c.req.query('limit') ?? 30);
if (!q) {
return c.json({ error: 'missing_query' }, 422);
}
if (q.length > 500) {
return c.json({ error: 'query_too_long', max: 500 }, 422);
}
const { hits, tookMs } = await searchUserCards(dbOf(), userId, q, limit);
const envelope: SearchResultEnvelope = {
envelope_version: SEARCH_ENVELOPE_VERSION,
query: q,
app: 'cards',
app_version: APP_VERSION,
results: hits.map((h) => ({
id: h.id,
type: 'card',
title: h.title,
snippet: h.snippet,
link: h.link,
score: h.score,
created_at: h.created_at,
updated_at: h.updated_at,
})),
total: hits.length,
partial: false,
took_ms: tookMs,
};
return c.json(envelope);
});
return r;
}

View file

@ -0,0 +1,119 @@
import { Hono } from 'hono';
import { parseEnvelope, validatePayloadForType } from '@cards/domain';
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 { SHARE_HANDLERS, type ShareHandlerName } from '../share-handlers/index.ts';
export type ShareDeps = { db?: CardsDb };
/** Manifest-Lookup: Type → Handler-Name. */
const ACCEPTS_BY_TYPE = new Map(manifest.accepts.map((a) => [a.type, a.handler] as const));
export function shareRouter(deps: ShareDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.post('/receive', async (c) => {
const userId = c.get('userId');
// Body kann `{ envelope, delivery_token }` oder direkt der Envelope sein.
const body = await c.req.json().catch(() => null);
if (!body || typeof body !== 'object') {
return c.json({ accepted: false, reason: 'invalid_json' }, 400);
}
const wrapped = body as { envelope?: unknown; delivery_token?: string };
const rawEnvelope = wrapped.envelope ?? body;
// const deliveryToken = wrapped.delivery_token; // Phase F-1: validieren
// 1. Schema-Validierung (inkl. Cross-User-Sperre)
const parsed = parseEnvelope(rawEnvelope);
if (!parsed.success) {
return c.json(
{
accepted: false,
reason: 'envelope_invalid',
issues: parsed.error.issues.map(
(i) => `${i.path.join('.') || '<root>'}: ${i.message}`
),
},
422
);
}
const env = parsed.data;
// 2. User-Match
if (env.to.user_id !== userId) {
return c.json({ accepted: false, reason: 'user_id_mismatch' }, 403);
}
// 3. Empfänger-App-Match
if (env.to.app !== manifest.id) {
return c.json(
{
accepted: false,
reason: 'wrong_recipient',
expected: manifest.id,
actual: env.to.app,
},
422
);
}
// 4. Type akzeptiert?
const handlerName = ACCEPTS_BY_TYPE.get(env.type);
if (!handlerName) {
return c.json(
{ accepted: false, reason: 'type_not_accepted', type: env.type },
422
);
}
// 5. Payload validieren gegen sein eigenes Schema
const payloadCheck = validatePayloadForType(env.type, env.payload);
if (!payloadCheck.success) {
return c.json(
{
accepted: false,
reason: payloadCheck.error,
issues: 'issues' in payloadCheck ? payloadCheck.issues : undefined,
},
422
);
}
// 6. Handler holen + dispatchen
const handler = SHARE_HANDLERS[handlerName as ShareHandlerName];
if (!handler) {
return c.json(
{ accepted: false, reason: 'handler_not_registered', handler: handlerName },
501
);
}
try {
const result = await handler(dbOf(), userId, payloadCheck.data as never);
return c.json({
accepted: true,
target_link: result.target_link,
resulting_id: result.resulting_id,
});
} catch (e) {
return c.json(
{
accepted: false,
reason: 'handler_error',
message: (e as Error).message,
},
500
);
}
});
return r;
}

View file

@ -0,0 +1,145 @@
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import {
CardsCreateInputSchema,
CardsSearchInputSchema,
newReview,
subIndexCount,
} from '@cards/domain';
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 { ulid } from '../lib/ulid.ts';
import { searchUserCards } from '../lib/search.ts';
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
const APP_VERSION = process.env.CARDS_API_VERSION ?? '0.0.0';
export type ToolsDeps = { db?: CardsDb };
/**
* Tool-Invoke-Endpoint für mana-mcp / Persona-Runner / Claude.
* Dispatch nach `:name`. Auth: User-JWT (X-User-Id-Header im Dev-Stub).
*
* Phase F-1: zusätzlich Service-Key-Pfad für mcp-getriggerte Calls
* mit user-on-behalf-of-Token.
*/
export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.post('/:name', async (c) => {
const userId = c.get('userId');
const name = c.req.param('name');
const body = await c.req.json().catch(() => null);
if (body == null) return c.json({ error: 'invalid_json' }, 400);
switch (name) {
case 'cards.create': {
const parsed = CardsCreateInputSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const [deck] = await dbOf()
.select({ id: decks.id, userId: decks.userId })
.from(decks)
.where(eq(decks.id, parsed.data.deck_id))
.limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403);
const cardId = ulid();
const now = new Date();
const [row] = await dbOf().transaction(async (tx) => {
const [card] = await tx
.insert(cards)
.values({
id: cardId,
deckId: parsed.data.deck_id,
userId,
type: parsed.data.type,
fields: parsed.data.fields,
mediaRefs: parsed.data.media_refs ?? [],
createdAt: now,
updatedAt: now,
})
.returning();
const initial = Array.from(
{ length: subIndexCount(parsed.data.type) },
(_, i) => i
).map((subIndex) => {
const r = newReview({ userId, cardId, subIndex, now });
return {
cardId: r.card_id,
subIndex: r.sub_index,
userId: r.user_id,
due: new Date(r.due),
stability: r.stability,
difficulty: r.difficulty,
elapsedDays: r.elapsed_days,
scheduledDays: r.scheduled_days,
learningSteps: r.learning_steps,
reps: r.reps,
lapses: r.lapses,
state: r.state,
lastReview: r.last_review ? new Date(r.last_review) : null,
};
});
if (initial.length > 0) await tx.insert(reviews).values(initial);
return [card];
});
return c.json({
id: row.id,
deck_id: row.deckId,
user_id: row.userId,
type: row.type,
fields: row.fields,
media_refs: row.mediaRefs ?? [],
content_hash: row.contentHash,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
});
}
case 'cards.search': {
const parsed = CardsSearchInputSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const max = parsed.data.max_results ?? 30;
const { hits, tookMs } = await searchUserCards(dbOf(), userId, parsed.data.query, max);
return c.json({
query: parsed.data.query,
results: hits.map((h) => ({
id: h.id,
type: 'card' as const,
title: h.title,
snippet: h.snippet,
link: h.link,
score: h.score,
})),
total: hits.length,
took_ms: tookMs,
app: 'cards',
app_version: APP_VERSION,
base_url: APP_BASE_URL,
});
}
default:
return c.json({ error: 'unknown_tool', name }, 404);
}
});
return r;
}

View file

@ -0,0 +1,130 @@
import { newReview, subIndexCount } from '@cards/domain';
import type { QuotePayload, TextPayload, UrlPayload } from '@cards/domain';
import type { CardsDb } from '../db/connection.ts';
import { cards, reviews } from '../db/schema/index.ts';
import { ensureInboxDeck } from '../lib/inbox-deck.ts';
import { ulid } from '../lib/ulid.ts';
/**
* Antwort-Shape eines Share-Handlers wird vom Receive-Endpoint
* an den sendenden Peer zurückgegeben.
*/
export type HandlerResult = {
target_link: string;
resulting_id: string;
};
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://cardecky.mana.how';
/**
* Legt eine basic-Karte mit (front,back) im Inbox-Deck an, inkl.
* der ts-fsrs-Initial-Reviews. Gibt Karte-ID + Deep-Link zurück.
*/
async function persistCardInInbox(
db: CardsDb,
userId: string,
front: string,
back: string
): Promise<HandlerResult> {
const inbox = await ensureInboxDeck(db, userId);
const cardId = ulid();
const now = new Date();
await db.transaction(async (tx) => {
await tx.insert(cards).values({
id: cardId,
deckId: inbox.id,
userId,
type: 'basic',
fields: { front, back },
mediaRefs: [],
createdAt: now,
updatedAt: now,
});
const initial = Array.from({ length: subIndexCount('basic') }, (_, i) => i).map(
(subIndex) => {
const r = newReview({ userId, cardId, subIndex, now });
return {
cardId: r.card_id,
subIndex: r.sub_index,
userId: r.user_id,
due: new Date(r.due),
stability: r.stability,
difficulty: r.difficulty,
elapsedDays: r.elapsed_days,
scheduledDays: r.scheduled_days,
learningSteps: r.learning_steps,
reps: r.reps,
lapses: r.lapses,
state: r.state,
lastReview: r.last_review ? new Date(r.last_review) : null,
};
}
);
if (initial.length > 0) {
await tx.insert(reviews).values(initial);
}
});
return {
target_link: `${APP_BASE_URL}/c/${cardId}`,
resulting_id: cardId,
};
}
/**
* mana/quote Karte. Front = Zitat-Text, Back = Quelle (URL bevorzugt,
* sonst Source-String, sonst leer).
*/
export async function createCardFromQuote(
db: CardsDb,
userId: string,
payload: QuotePayload
): Promise<HandlerResult> {
const front = payload.text;
const back = payload.source_url ?? payload.source ?? '';
return persistCardInInbox(db, userId, front, back);
}
/**
* mana/url Karte. Front = Titel (oder URL falls kein Titel),
* Back = URL + Description/Snippet.
*/
export async function saveLinkAsCard(
db: CardsDb,
userId: string,
payload: UrlPayload
): Promise<HandlerResult> {
const front = payload.title ?? payload.url;
const summary = payload.description ?? payload.snippet ?? '';
const back = summary ? `${payload.url}\n\n${summary}` : payload.url;
return persistCardInInbox(db, userId, front, back);
}
/**
* mana/text Karte. Front = erste Zeile (oder erste 200 Zeichen),
* Back = Rest. Heuristisch User kann später trennen.
*/
export async function createCardFromText(
db: CardsDb,
userId: string,
payload: TextPayload
): Promise<HandlerResult> {
const text = payload.text.trim();
const firstNl = text.indexOf('\n');
const frontEnd = firstNl > 0 && firstNl < 200 ? firstNl : Math.min(200, text.length);
const front = text.slice(0, frontEnd).trim();
const back = text.slice(frontEnd).trim();
return persistCardInInbox(db, userId, front, back || '(weiter ausarbeiten)');
}
/** Manifest-Handler-Map. Key = `accepts[].handler` aus app-manifest.json. */
export const SHARE_HANDLERS = {
create_card_from_quote: createCardFromQuote,
save_link_as_card: saveLinkAsCard,
create_card_from_text: createCardFromText,
} as const;
export type ShareHandlerName = keyof typeof SHARE_HANDLERS;

View file

@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Hono } from 'hono';
import { dsgvoRouter } from '../src/routes/dsgvo.ts';
import type { CardsDb } from '../src/db/connection.ts';
const ORIG_ENV = process.env.CARDS_DSGVO_SERVICE_KEY;
const TEST_KEY = 'msk_test_dsgvo_key_42';
beforeEach(() => {
process.env.CARDS_DSGVO_SERVICE_KEY = TEST_KEY;
});
afterEach(() => {
if (ORIG_ENV === undefined) {
delete process.env.CARDS_DSGVO_SERVICE_KEY;
} else {
process.env.CARDS_DSGVO_SERVICE_KEY = ORIG_ENV;
}
});
function buildApp() {
const stub = {
select: () => ({ from: () => ({ where: () => [] as never[] }) }),
delete: () => ({ where: () => ({ returning: async () => [] }) }),
transaction: async (fn: (tx: unknown) => unknown) =>
fn({
delete: () => ({ where: () => ({ returning: async () => [] }) }),
}),
};
const app = new Hono();
app.route('/api/v1/dsgvo', dsgvoRouter({ db: stub as unknown as CardsDb }));
return { app };
}
describe('dsgvoRouter — Service-Key-Gate', () => {
it('GET /export ohne Service-Key ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/dsgvo/export?user_id=u-1');
expect(res.status).toBe(401);
});
it('POST /delete mit falschem Key ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/dsgvo/delete', {
method: 'POST',
headers: { 'X-Service-Key': 'wrong', 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: 'u-1' }),
});
expect(res.status).toBe(401);
});
it('GET /export mit Key + ohne user_id ist 400', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/dsgvo/export', {
headers: { 'X-Service-Key': TEST_KEY },
});
expect(res.status).toBe(400);
});
it('GET /export mit Key + user_id liefert JSON-Bundle', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/dsgvo/export?user_id=u-1', {
headers: { 'X-Service-Key': TEST_KEY },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
user_id: string;
app: string;
data: { decks: unknown[]; cards: unknown[]; reviews: unknown[] };
};
expect(body.user_id).toBe('u-1');
expect(body.app).toBe('cards');
expect(Array.isArray(body.data.decks)).toBe(true);
expect(Array.isArray(body.data.cards)).toBe(true);
expect(Array.isArray(body.data.reviews)).toBe(true);
});
it('POST /delete mit Key + user_id liefert counts', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/dsgvo/delete', {
method: 'POST',
headers: { 'X-Service-Key': TEST_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: 'u-1' }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
deleted: boolean;
user_id: string;
counts: { decks: number; import_jobs: number };
};
expect(body.deleted).toBe(true);
expect(body.user_id).toBe('u-1');
expect(body.counts.decks).toBe(0);
expect(body.counts.import_jobs).toBe(0);
});
});
describe('dsgvoRouter — Key-not-configured Pfad', () => {
it('Wenn ENV fehlt → 500 service_key_not_configured', async () => {
delete process.env.CARDS_DSGVO_SERVICE_KEY;
const { app } = buildApp();
const res = await app.request('/api/v1/dsgvo/export?user_id=u-1', {
headers: { 'X-Service-Key': 'whatever' },
});
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('service_key_not_configured');
});
});

View file

@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { searchRouter } from '../src/routes/search.ts';
import type { CardsDb } from '../src/db/connection.ts';
function buildApp() {
const stub = {
select: () => ({
from: () => ({
innerJoin: () => ({
where: () => ({ orderBy: () => ({ limit: () => [] }) }),
}),
}),
}),
};
const app = new Hono();
app.route('/api/v1/search', searchRouter({ db: stub as unknown as CardsDb }));
return { app };
}
describe('searchRouter — Auth-Gate', () => {
it('GET ohne X-User-Id ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/search?q=foo');
expect(res.status).toBe(401);
});
});
describe('searchRouter — Query-Validation', () => {
it('GET ohne q ist 422', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/search', {
headers: { 'X-User-Id': 'u-1' },
});
expect(res.status).toBe(422);
});
it('GET mit q über 500 Zeichen ist 422', async () => {
const { app } = buildApp();
const long = 'x'.repeat(501);
const res = await app.request(`/api/v1/search?q=${encodeURIComponent(long)}`, {
headers: { 'X-User-Id': 'u-1' },
});
expect(res.status).toBe(422);
});
it('GET mit gültigem q liefert SearchResultEnvelope', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/search?q=Konfuzius', {
headers: { 'X-User-Id': 'u-1' },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
envelope_version: string;
query: string;
app: string;
results: unknown[];
partial: boolean;
};
expect(body.envelope_version).toBe('0.1');
expect(body.query).toBe('Konfuzius');
expect(body.app).toBe('cards');
expect(Array.isArray(body.results)).toBe(true);
expect(body.partial).toBe(false);
});
});

View file

@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { shareRouter } from '../src/routes/share.ts';
import type { CardsDb } from '../src/db/connection.ts';
function buildApp() {
const stub = {} as CardsDb;
const app = new Hono();
app.route('/api/v1/share', shareRouter({ db: stub }));
return { app };
}
const userA = '00000000-0000-0000-0000-00000000aaaa';
const userB = '00000000-0000-0000-0000-00000000bbbb';
function envelope(overrides: Record<string, unknown> = {}) {
return {
envelope_version: '0.1',
share_id: '01HZ0EJW6V6N4SM3X5RHKR8B5T', // ULID-Pattern
from: {
app: 'zitate',
app_version: '1.0.0',
user_id: userA,
timestamp: new Date().toISOString(),
},
to: {
app: 'cards',
user_id: userA,
},
type: 'mana/quote',
payload: { text: 'Lernen ohne Nachdenken ist verlorene Mühe', source: 'Konfuzius' },
intent: 'user_action',
consent_recorded_at: new Date().toISOString(),
...overrides,
};
}
describe('shareRouter — Auth-Gate', () => {
it('POST /receive ohne X-User-Id ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
body: JSON.stringify(envelope()),
});
expect(res.status).toBe(401);
});
});
describe('shareRouter — Envelope-Validation', () => {
it('Body kein JSON → 400', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: 'not-json',
});
expect(res.status).toBe(400);
});
it('Cross-User-Share ist 422 (envelope_invalid)', async () => {
const { app } = buildApp();
const env = envelope({
to: { app: 'cards', user_id: userB }, // anderer User → Cross-User
});
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: JSON.stringify(env),
});
expect(res.status).toBe(422);
const body = (await res.json()) as { reason: string };
expect(body.reason).toBe('envelope_invalid');
});
it('User-Mismatch (envelope.to.user_id != X-User-Id) ist 403', async () => {
const { app } = buildApp();
const env = envelope({
from: { ...envelope().from, user_id: userB },
to: { app: 'cards', user_id: userB },
});
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: JSON.stringify(env),
});
expect(res.status).toBe(403);
const body = (await res.json()) as { reason: string };
expect(body.reason).toBe('user_id_mismatch');
});
it('Wrong-Recipient (to.app != cards) ist 422', async () => {
const { app } = buildApp();
const env = envelope({ to: { app: 'memoro', user_id: userA } });
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: JSON.stringify(env),
});
expect(res.status).toBe(422);
const body = (await res.json()) as { reason: string };
expect(body.reason).toBe('wrong_recipient');
});
it('Unbekannter Type ist 422', async () => {
const { app } = buildApp();
const env = envelope({ type: 'mana/unknown-thing', payload: {} });
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: JSON.stringify(env),
});
expect(res.status).toBe(422);
const body = (await res.json()) as { reason: string };
expect(body.reason).toBe('type_not_accepted');
});
it('Quote-Payload ohne text ist 422', async () => {
const { app } = buildApp();
const env = envelope({ payload: { source: 'X' } });
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: JSON.stringify(env),
});
expect(res.status).toBe(422);
const body = (await res.json()) as { reason: string };
expect(body.reason).toBe('invalid_payload');
});
it('Wrapped Body { envelope, delivery_token } akzeptiert', async () => {
const { app } = buildApp();
const env = envelope({ to: { app: 'memoro', user_id: userA } });
const res = await app.request('/api/v1/share/receive', {
method: 'POST',
headers: { 'X-User-Id': userA, 'Content-Type': 'application/json' },
body: JSON.stringify({ envelope: env, delivery_token: 'tok_xyz' }),
});
// Wrong-Recipient nicht 422 → Wrapper wurde korrekt unwrapped (sonst wäre es envelope_invalid)
expect(res.status).toBe(422);
const body = (await res.json()) as { reason: string };
expect(body.reason).toBe('wrong_recipient');
});
});

View file

@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { toolsRouter } from '../src/routes/tools.ts';
import type { CardsDb } from '../src/db/connection.ts';
function buildApp() {
const stub = {
select: () => ({
from: () => ({
where: () => ({ limit: () => [] }),
innerJoin: () => ({
where: () => ({ orderBy: () => ({ limit: () => [] }) }),
}),
}),
}),
};
const app = new Hono();
app.route('/api/v1/tools', toolsRouter({ db: stub as unknown as CardsDb }));
return { app };
}
describe('toolsRouter — Auth-Gate', () => {
it('POST /:name ohne X-User-Id ist 401', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/tools/cards.create', {
method: 'POST',
body: '{}',
});
expect(res.status).toBe(401);
});
});
describe('toolsRouter — Tool-Dispatch', () => {
it('Unbekanntes Tool ist 404', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/tools/cards.unknown', {
method: 'POST',
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
body: '{}',
});
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('unknown_tool');
});
it('cards.create mit invalid input ist 422', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/tools/cards.create', {
method: 'POST',
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
body: '{}',
});
expect(res.status).toBe(422);
});
it('cards.create mit gültigem Input erreicht Deck-Lookup (404 bei stub)', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/tools/cards.create', {
method: 'POST',
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
body: JSON.stringify({
deck_id: 'd-1',
type: 'basic',
fields: { front: 'Q', back: 'A' },
}),
});
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('deck_not_found');
});
it('cards.search ohne query ist 422', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/tools/cards.search', {
method: 'POST',
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
body: '{}',
});
expect(res.status).toBe(422);
});
it('cards.search mit gültigem query → 200 mit envelope-Shape', async () => {
const { app } = buildApp();
const res = await app.request('/api/v1/tools/cards.search', {
method: 'POST',
headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'Konfuzius' }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { query: string; results: unknown[]; app: string };
expect(body.query).toBe('Konfuzius');
expect(Array.isArray(body.results)).toBe(true);
expect(body.app).toBe('cards');
});
});

View file

@ -9,4 +9,5 @@
export * from './schemas/index.ts'; export * from './schemas/index.ts';
export * from './fsrs.ts'; export * from './fsrs.ts';
export * from './protocol/index.ts';
// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser // export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser

View file

@ -0,0 +1,69 @@
// TEMPORARY MIRROR von @mana/shared-share-protocol/envelope.
//
// Solange `pkg.mana.how` für Cards nicht erreichbar ist (NPM_AUTH_TOKEN
// in `~/.npmrc` fehlt), halten wir die Schemas hier lokal. Sobald
// Verdaccio offen ist, wird diese Datei gegen einen Re-Export aus
// `@mana/shared-share-protocol` getauscht — alle Imports sind in der
// Form `import { ShareEnvelopeSchema } from '@cards/domain'` formuliert,
// damit der Swap eine reine 1-Liner-Edit-Aufgabe ist.
//
// Bei jedem Update der mana-Spec muss diese Datei nachgezogen werden,
// bis der Swap erfolgt. Stand: 2026-05-08, ENVELOPE_VERSION 0.1.
import { z } from 'zod';
export const ENVELOPE_VERSION = '0.1' as const;
const ULID_REGEX = /^[0-9A-HJKMNP-TV-Z]{26}$/;
const TYPE_NAME_REGEX = /^mana\/[a-z][a-z0-9-]+$/;
const SignatureSchema = z.object({
algorithm: z.literal('eddsa'),
key_id: z.string().min(1),
signature: z.string().min(1),
});
export const ShareEnvelopeSchema = z.object({
envelope_version: z.literal(ENVELOPE_VERSION),
share_id: z.string().regex(ULID_REGEX, 'must be ULID'),
from: z.object({
app: z.string().min(1),
app_version: z.string().min(1),
user_id: z.string().uuid(),
timestamp: z.string().datetime(),
instance_id: z.string().max(120).optional(),
}),
to: z.object({
app: z.string().min(1),
user_id: z.string().uuid(),
}),
type: z.string().regex(TYPE_NAME_REGEX, 'must be "mana/<kind>"'),
payload: z.unknown(),
source_link: z.string().max(2000).optional(),
user_note: z.string().max(500).optional(),
ttl_seconds: z.number().int().positive().optional(),
intent: z.enum(['user_action', 'automation', 'agent_tool']).default('user_action'),
consent_recorded_at: z.string().datetime(),
signature: SignatureSchema.optional(),
});
export type ShareEnvelope = z.infer<typeof ShareEnvelopeSchema>;
/** Strict-Variante: Cross-User-Shares hart verboten. */
export const ShareEnvelopeStrictSchema = ShareEnvelopeSchema.refine(
(env) => env.from.user_id === env.to.user_id,
{
message: 'cross-user shares forbidden — from.user_id must equal to.user_id',
path: ['to', 'user_id'],
}
);
export function parseEnvelope(raw: unknown) {
return ShareEnvelopeStrictSchema.safeParse(raw);
}

View file

@ -0,0 +1,7 @@
// Public Re-Exports der lokalen Protocol-Mirror.
// Wenn @mana/shared-share-protocol via Verdaccio verfügbar ist,
// wird hier auf einen Re-Export aus dem npm-Paket umgestellt.
export * from './envelope.ts';
export * from './payloads.ts';
export * from './search.ts';

View file

@ -0,0 +1,83 @@
// TEMPORARY MIRROR — siehe envelope.ts.
//
// Payload-Schemas für die `accepts[]` aus dem Cards-Manifest:
// `mana/quote`, `mana/url`, `mana/text`. Andere known types
// (`mana/transcript`, `mana/link`, `mana/note`, etc.) werden wir
// erst ergänzen, wenn Cards sie aktiv akzeptiert.
import { z } from 'zod';
export const MANA_TYPE_QUOTE = 'mana/quote' as const;
export const MANA_TYPE_URL = 'mana/url' as const;
export const MANA_TYPE_TEXT = 'mana/text' as const;
export const QuotePayloadSchema = z
.object({
text: z.string().min(1).max(8000),
source: z.string().max(500).optional(),
source_url: z.string().url().optional(),
source_kind: z
.enum(['book', 'article', 'talk', 'conversation', 'transcript', 'link', 'manual', 'other'])
.optional(),
language: z
.string()
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
.optional(),
tags: z.array(z.string().max(64)).max(50).optional(),
})
.strict();
export type QuotePayload = z.infer<typeof QuotePayloadSchema>;
/**
* `mana/url` externe Web-Adresse mit optionalen OG-Metadaten.
* (Mana-Spec nennt dies `mana/link`. Wir akzeptieren dieselben
* Felder, aber unter unserem deklarierten Type-Namen `mana/url`.)
*/
export const UrlPayloadSchema = z
.object({
url: z.string().url(),
title: z.string().max(500).optional(),
description: z.string().max(2000).optional(),
snippet: z.string().max(2000).optional(),
image_url: z.string().url().optional(),
site_name: z.string().max(200).optional(),
favicon_url: z.string().url().optional(),
})
.strict();
export type UrlPayload = z.infer<typeof UrlPayloadSchema>;
export const TextPayloadSchema = z
.object({
text: z.string().min(1).max(50000),
format: z.enum(['plain', 'markdown', 'html']).default('plain'),
source: z.string().max(500).optional(),
source_url: z.string().url().optional(),
})
.strict();
export type TextPayload = z.infer<typeof TextPayloadSchema>;
/** Map vom Type-String zum Payload-Schema. */
export const PAYLOAD_SCHEMAS = {
[MANA_TYPE_QUOTE]: QuotePayloadSchema,
[MANA_TYPE_URL]: UrlPayloadSchema,
[MANA_TYPE_TEXT]: TextPayloadSchema,
} as const;
export type AcceptedShareType = keyof typeof PAYLOAD_SCHEMAS;
export function validatePayloadForType(
type: string,
payload: unknown
): { success: true; data: unknown } | { success: false; error: 'unknown_type' | 'invalid_payload'; issues?: string[] } {
const schema = PAYLOAD_SCHEMAS[type as AcceptedShareType];
if (!schema) return { success: false, error: 'unknown_type' };
const r = schema.safeParse(payload);
if (!r.success) {
return {
success: false,
error: 'invalid_payload',
issues: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
};
}
return { success: true, data: r.data };
}

View file

@ -0,0 +1,39 @@
// TEMPORARY MIRROR — siehe envelope.ts.
import { z } from 'zod';
export const SEARCH_ENVELOPE_VERSION = '0.1' as const;
export const SearchHitSchema = z.object({
id: z.string().min(1),
type: z.string().min(1),
title: z.string().min(1).max(300),
snippet: z.string().max(1000).optional(),
link: z.string().min(1),
score: z.number().min(0).max(1),
highlights: z
.array(
z.object({
field: z.string().max(80),
fragment: z.string().max(500),
})
)
.max(10)
.optional(),
meta: z.record(z.string(), z.unknown()).optional(),
created_at: z.string().datetime().optional(),
updated_at: z.string().datetime().optional(),
});
export type SearchHit = z.infer<typeof SearchHitSchema>;
export const SearchResultEnvelopeSchema = z.object({
envelope_version: z.literal(SEARCH_ENVELOPE_VERSION),
query: z.string().min(1).max(500),
app: z.string().min(1),
app_version: z.string().min(1),
results: z.array(SearchHitSchema).max(200),
total: z.number().int().nonnegative(),
partial: z.boolean(),
took_ms: z.number().int().nonnegative(),
});
export type SearchResultEnvelope = z.infer<typeof SearchResultEnvelopeSchema>;