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:
parent
89a7a9250b
commit
0328caa333
19 changed files with 1371 additions and 0 deletions
|
|
@ -34,3 +34,11 @@ NPM_AUTH_TOKEN=
|
|||
|
||||
# === MinIO (über mana-media) ===
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { healthRoute } from './routes/health.ts';
|
|||
import { decksRouter } from './routes/decks.ts';
|
||||
import { cardsRouter } from './routes/cards.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();
|
||||
|
||||
|
|
@ -31,6 +35,10 @@ app.route('/.well-known/mana-app.json', manifestRoute);
|
|||
app.route('/api/v1/decks', decksRouter());
|
||||
app.route('/api/v1/cards', cardsRouter());
|
||||
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) =>
|
||||
c.json({
|
||||
|
|
|
|||
40
apps/api/src/lib/inbox-deck.ts
Normal file
40
apps/api/src/lib/inbox-deck.ts
Normal 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;
|
||||
}
|
||||
76
apps/api/src/lib/search.ts
Normal file
76
apps/api/src/lib/search.ts
Normal 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 };
|
||||
}
|
||||
41
apps/api/src/middleware/service-key.ts
Normal file
41
apps/api/src/middleware/service-key.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
121
apps/api/src/routes/dsgvo.ts
Normal file
121
apps/api/src/routes/dsgvo.ts
Normal 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;
|
||||
}
|
||||
67
apps/api/src/routes/search.ts
Normal file
67
apps/api/src/routes/search.ts
Normal 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;
|
||||
}
|
||||
119
apps/api/src/routes/share.ts
Normal file
119
apps/api/src/routes/share.ts
Normal 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;
|
||||
}
|
||||
145
apps/api/src/routes/tools.ts
Normal file
145
apps/api/src/routes/tools.ts
Normal 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;
|
||||
}
|
||||
130
apps/api/src/share-handlers/index.ts
Normal file
130
apps/api/src/share-handlers/index.ts
Normal 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;
|
||||
110
apps/api/tests/dsgvo.test.ts
Normal file
110
apps/api/tests/dsgvo.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
67
apps/api/tests/search.test.ts
Normal file
67
apps/api/tests/search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
144
apps/api/tests/share.test.ts
Normal file
144
apps/api/tests/share.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
96
apps/api/tests/tools.test.ts
Normal file
96
apps/api/tests/tools.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,4 +9,5 @@
|
|||
|
||||
export * from './schemas/index.ts';
|
||||
export * from './fsrs.ts';
|
||||
export * from './protocol/index.ts';
|
||||
// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser
|
||||
|
|
|
|||
69
packages/cards-domain/src/protocol/envelope.ts
Normal file
69
packages/cards-domain/src/protocol/envelope.ts
Normal 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);
|
||||
}
|
||||
7
packages/cards-domain/src/protocol/index.ts
Normal file
7
packages/cards-domain/src/protocol/index.ts
Normal 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';
|
||||
83
packages/cards-domain/src/protocol/payloads.ts
Normal file
83
packages/cards-domain/src/protocol/payloads.ts
Normal 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 };
|
||||
}
|
||||
39
packages/cards-domain/src/protocol/search.ts
Normal file
39
packages/cards-domain/src/protocol/search.ts
Normal 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>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue