diff --git a/.env.example b/.env.example index e46684c..f77bfec 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7f5d9d4..e83a88a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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({ diff --git a/apps/api/src/lib/inbox-deck.ts b/apps/api/src/lib/inbox-deck.ts new file mode 100644 index 0000000..5d9a09d --- /dev/null +++ b/apps/api/src/lib/inbox-deck.ts @@ -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; +} diff --git a/apps/api/src/lib/search.ts b/apps/api/src/lib/search.ts new file mode 100644 index 0000000..c90d47e --- /dev/null +++ b/apps/api/src/lib/search.ts @@ -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; + 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 }; +} diff --git a/apps/api/src/middleware/service-key.ts b/apps/api/src/middleware/service-key.ts new file mode 100644 index 0000000..bfc34a5 --- /dev/null +++ b/apps/api/src/middleware/service-key.ts @@ -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(); + }; +} diff --git a/apps/api/src/routes/dsgvo.ts b/apps/api/src/routes/dsgvo.ts new file mode 100644 index 0000000..69ca893 --- /dev/null +++ b/apps/api/src/routes/dsgvo.ts @@ -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; +} diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts new file mode 100644 index 0000000..b201da5 --- /dev/null +++ b/apps/api/src/routes/search.ts @@ -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; +} diff --git a/apps/api/src/routes/share.ts b/apps/api/src/routes/share.ts new file mode 100644 index 0000000..192c24d --- /dev/null +++ b/apps/api/src/routes/share.ts @@ -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('.') || ''}: ${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; +} diff --git a/apps/api/src/routes/tools.ts b/apps/api/src/routes/tools.ts new file mode 100644 index 0000000..7018c8d --- /dev/null +++ b/apps/api/src/routes/tools.ts @@ -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; +} diff --git a/apps/api/src/share-handlers/index.ts b/apps/api/src/share-handlers/index.ts new file mode 100644 index 0000000..6fa7443 --- /dev/null +++ b/apps/api/src/share-handlers/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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; diff --git a/apps/api/tests/dsgvo.test.ts b/apps/api/tests/dsgvo.test.ts new file mode 100644 index 0000000..b127d7a --- /dev/null +++ b/apps/api/tests/dsgvo.test.ts @@ -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'); + }); +}); diff --git a/apps/api/tests/search.test.ts b/apps/api/tests/search.test.ts new file mode 100644 index 0000000..5120308 --- /dev/null +++ b/apps/api/tests/search.test.ts @@ -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); + }); +}); diff --git a/apps/api/tests/share.test.ts b/apps/api/tests/share.test.ts new file mode 100644 index 0000000..f5fdb50 --- /dev/null +++ b/apps/api/tests/share.test.ts @@ -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 = {}) { + 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'); + }); +}); diff --git a/apps/api/tests/tools.test.ts b/apps/api/tests/tools.test.ts new file mode 100644 index 0000000..071a145 --- /dev/null +++ b/apps/api/tests/tools.test.ts @@ -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'); + }); +}); diff --git a/packages/cards-domain/src/index.ts b/packages/cards-domain/src/index.ts index 92f8426..4d82e68 100644 --- a/packages/cards-domain/src/index.ts +++ b/packages/cards-domain/src/index.ts @@ -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 diff --git a/packages/cards-domain/src/protocol/envelope.ts b/packages/cards-domain/src/protocol/envelope.ts new file mode 100644 index 0000000..ea0bc48 --- /dev/null +++ b/packages/cards-domain/src/protocol/envelope.ts @@ -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/"'), + 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; + +/** 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); +} diff --git a/packages/cards-domain/src/protocol/index.ts b/packages/cards-domain/src/protocol/index.ts new file mode 100644 index 0000000..41275f7 --- /dev/null +++ b/packages/cards-domain/src/protocol/index.ts @@ -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'; diff --git a/packages/cards-domain/src/protocol/payloads.ts b/packages/cards-domain/src/protocol/payloads.ts new file mode 100644 index 0000000..c286c02 --- /dev/null +++ b/packages/cards-domain/src/protocol/payloads.ts @@ -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; + +/** + * `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; + +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; + +/** 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 }; +} diff --git a/packages/cards-domain/src/protocol/search.ts b/packages/cards-domain/src/protocol/search.ts new file mode 100644 index 0000000..06bebe8 --- /dev/null +++ b/packages/cards-domain/src/protocol/search.ts @@ -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; + +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;