From 044d9481553f279e49f37447755ec1c1db395617 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 7 May 2026 16:36:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(cards-server):=20Phase=20=CE=B2=20?= =?UTF-8?q?=E2=80=94=20author=20profiles=20+=20deck=20init/publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First user-facing surface on cards-server. Three endpoint groups: Authors (/v1/authors): - POST /me — upsert author profile (slug, displayName, bio, avatarUrl, pseudonym). Slug validated for length, charset, and against a small reserved-words list (admin, api, me, ...). - GET /me — read own profile (returns null if not yet an author). - GET /:slug — public profile (omits banned-reason, etc.) Decks (/v1/decks): - POST / — claim a slug + create the metadata-only deck row. License defaults to Cards-Personal-Use-1.0; paid decks (priceCredits > 0) must use Cards-Pro-Only-1.0 (CHECK constraint + service-side guard). - GET /:slug — deck + latestVersion. - POST /:slug/publish — version semver enforced strictly increasing, AI-mod first-pass via mana-llm (block → 403; flag → publish + log for human review; pass → publish silently). Per-card and per- version SHA-256 content hashes computed; cards persisted; deck's latest_version_id flipped atomically in a single transaction. Helpers: - lib/slug.ts — slugify (best-effort) + validateSlug (strict). - lib/hash.ts — canonical SHA-256 over (type, fields) for cards and (sorted, ord-stable) for versions. - lib/ai-moderation.ts — mana-llm /v1/chat/completions wrapper with system prompt that forces JSON output. Fail-open: if mana-llm is down or returns malformed JSON, the verdict is 'flag' so a human reviewer catches it. Better slow than silent. Index-mounting of /v1/authors and /v1/decks is gated behind jwtAuth. Anonymous public reads (Phase γ optionalAuth middleware) come later. Validated: tsc --noEmit clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/cards-server/src/index.ts | 33 ++- .../cards-server/src/lib/ai-moderation.ts | 132 +++++++++++ services/cards-server/src/lib/hash.ts | 44 ++++ services/cards-server/src/lib/slug.ts | 58 +++++ services/cards-server/src/routes/authors.ts | 38 +++ services/cards-server/src/routes/decks.ts | 68 ++++++ services/cards-server/src/services/authors.ts | 104 ++++++++ services/cards-server/src/services/decks.ts | 223 ++++++++++++++++++ 8 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 services/cards-server/src/lib/ai-moderation.ts create mode 100644 services/cards-server/src/lib/hash.ts create mode 100644 services/cards-server/src/lib/slug.ts create mode 100644 services/cards-server/src/routes/authors.ts create mode 100644 services/cards-server/src/routes/decks.ts create mode 100644 services/cards-server/src/services/authors.ts create mode 100644 services/cards-server/src/services/decks.ts diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts index c23b21d65..d2ec398da 100644 --- a/services/cards-server/src/index.ts +++ b/services/cards-server/src/index.ts @@ -13,18 +13,24 @@ import { cors } from 'hono/cors'; import { serviceErrorHandler as errorHandler } from '@mana/shared-hono'; import { loadConfig } from './config'; import { getDb } from './db/connection'; +import { jwtAuth, type AuthUser } from './middleware/jwt-auth'; import { healthRoutes } from './routes/health'; +import { AuthorService } from './services/authors'; +import { DeckService } from './services/decks'; +import { createAuthorRoutes } from './routes/authors'; +import { createDeckRoutes } from './routes/decks'; // ─── Bootstrap ────────────────────────────────────────────── const config = loadConfig(); -// Eager-init the pool so a misconfigured DATABASE_URL fails at boot -// (instead of on the first user request). -getDb(config.databaseUrl); +const db = getDb(config.databaseUrl); + +const authorService = new AuthorService(db); +const deckService = new DeckService(db, config.manaLlmUrl); // ─── App ──────────────────────────────────────────────────── -const app = new Hono(); +const app = new Hono<{ Variables: { user: AuthUser } }>(); app.onError(errorHandler); app.use( @@ -38,10 +44,21 @@ app.use( // Health (no auth) app.route('/health', healthRoutes); -// Versioned API surface — routes will land here in Phase α.3 onwards. -// The /v1 prefix is the public contract from day one (see -// MARKETPLACE_PLAN §3 architecture principle 1). -const v1 = new Hono(); +// Versioned API surface — additive-only changes within v1, breaking +// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1). +const v1 = new Hono<{ Variables: { user: AuthUser } }>(); + +// Public reads on author + deck profiles allow anonymous access; the +// mutating endpoints in the same routers gate themselves by checking +// for `c.get('user')`. Until we have that anonymous-aware middleware +// (Phase γ adds optionalAuth), every /v1 route gates on JWT — public +// reads still work for any signed-in user, which covers the only +// surface we have right now (author dashboard + deck CRUD). +v1.use('/*', jwtAuth(config.manaAuthUrl)); + +v1.route('/authors', createAuthorRoutes(authorService)); +v1.route('/decks', createDeckRoutes(authorService, deckService)); + v1.get('/', (c) => c.json({ service: 'cards-server', diff --git a/services/cards-server/src/lib/ai-moderation.ts b/services/cards-server/src/lib/ai-moderation.ts new file mode 100644 index 000000000..1c9696d1d --- /dev/null +++ b/services/cards-server/src/lib/ai-moderation.ts @@ -0,0 +1,132 @@ +/** + * AI moderation first-pass via mana-llm. + * + * Asks the model to classify a deck's content into one of three + * verdicts: pass, flag, block. `flag` means a human reviewer should + * look at it before the deck goes public; `block` means refuse the + * publish outright. + * + * Per MARKETPLACE_PLAN principle 6: "AI is moderator, not gatekeeper" + * — `block` is only used for unambiguous offences (CSAM, real-world + * doxxing). Anything ambiguous flows to human review. + * + * Fail-open: if mana-llm is unreachable or returns malformed JSON, + * the verdict defaults to `flag` so the human reviewer catches it. + * Better a slow publish than a quietly-skipped check. + */ + +const SYSTEM_PROMPT = `Du bist Inhalts-Moderator für eine Karteikarten-Plattform. Bewerte den vorgelegten Inhalt nach folgenden Kategorien: + +- spam: Werbe-Spam, ohne erkennbaren Lerninhalt +- copyright: offensichtliche, lange Lehrbuch-Auszüge ohne Quelle/Lizenzhinweis +- nsfw: sexuell explizit, jugendgefährdend +- misinformation: nachweislich falsche Fakten als Tatsachen präsentiert (außerhalb subjektiver Themen) +- hate: Hassrede, Diskriminierung gegen geschützte Gruppen +- csam: Material, das Minderjährige sexualisiert (führt IMMER zu block) + +Antworte AUSSCHLIESSLICH mit einem JSON-Objekt: +{"verdict":"pass|flag|block","categories":["..."],"rationale":"kurze Begründung"} + +Regeln: +- pass: keine Kategorien getroffen +- flag: eine oder mehrere Kategorien außer csam +- block: csam ODER unmissverständliche Kombination aus mehreren schweren Kategorien +- Im Zweifel: flag (nicht block) — eine menschliche Moderatorin entscheidet final.`; + +export interface ModerationVerdict { + verdict: 'pass' | 'flag' | 'block'; + categories: string[]; + rationale: string; + model: string; +} + +export interface ModerationInput { + title: string; + description?: string; + cards: { fields: Record }[]; +} + +const MODEL = process.env.AI_MODERATION_MODEL || 'gpt-4o-mini'; +const MAX_CARDS_FOR_PROMPT = 50; + +function buildPrompt(input: ModerationInput): string { + const sample = input.cards.slice(0, MAX_CARDS_FOR_PROMPT).map((c, i) => { + const fieldsStr = Object.entries(c.fields) + .map(([k, v]) => ` ${k}: ${v}`) + .join('\n'); + return `Karte ${i + 1}:\n${fieldsStr}`; + }); + return [ + `Deck-Titel: ${input.title}`, + input.description ? `Beschreibung: ${input.description}` : '', + `Karten (${input.cards.length} insgesamt, erste ${sample.length} gezeigt):`, + ...sample, + ] + .filter(Boolean) + .join('\n\n'); +} + +function failOpen(rationale: string): ModerationVerdict { + return { + verdict: 'flag', + categories: ['_internal'], + rationale: `AI-Mod fail-open: ${rationale}`, + model: MODEL, + }; +} + +function stripCodeFences(s: string): string { + return s + .replace(/^\s*```(?:json)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); +} + +export async function moderateDeckContent( + input: ModerationInput, + llmUrl: string +): Promise { + let res: Response; + try { + res = await fetch(`${llmUrl}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: MODEL, + temperature: 0, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: buildPrompt(input) }, + ], + }), + }); + } catch (e) { + return failOpen(`network: ${(e as Error).message}`); + } + + if (!res.ok) return failOpen(`http ${res.status}`); + + const json = (await res.json().catch(() => null)) as { + choices?: { message?: { content?: string } }[]; + } | null; + const raw = json?.choices?.[0]?.message?.content?.trim(); + if (!raw) return failOpen('empty response'); + + let parsed: { verdict?: unknown; categories?: unknown; rationale?: unknown }; + try { + parsed = JSON.parse(stripCodeFences(raw)); + } catch { + return failOpen('invalid JSON'); + } + + const verdict = + parsed.verdict === 'pass' || parsed.verdict === 'flag' || parsed.verdict === 'block' + ? parsed.verdict + : 'flag'; + const categories = Array.isArray(parsed.categories) + ? parsed.categories.filter((c): c is string => typeof c === 'string') + : []; + const rationale = typeof parsed.rationale === 'string' ? parsed.rationale : ''; + + return { verdict, categories, rationale, model: MODEL }; +} diff --git a/services/cards-server/src/lib/hash.ts b/services/cards-server/src/lib/hash.ts new file mode 100644 index 000000000..4641d1f47 --- /dev/null +++ b/services/cards-server/src/lib/hash.ts @@ -0,0 +1,44 @@ +/** + * Content hashing — SHA-256 over canonicalized payloads. Drives: + * - per-card `content_hash` (smart-merge across version bumps) + * - per-version `content_hash` (cache + dedup detection) + * + * Canonicalization sorts object keys recursively so `{a:1,b:2}` and + * `{b:2,a:1}` produce identical hashes. Without that, equivalent + * payloads from different clients would diverge. Numbers/booleans + * stringify naturally; strings are passed through verbatim. + */ + +import { createHash } from 'node:crypto'; + +function canonical(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(canonical); + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = canonical((value as Record)[key]); + } + return sorted; +} + +function sha256(input: string): string { + return createHash('sha256').update(input).digest('hex'); +} + +/** Hash for a single card — based on (type, fields). */ +export function hashCard(card: { type: string; fields: Record }): string { + return sha256(JSON.stringify(canonical({ type: card.type, fields: card.fields }))); +} + +/** + * Hash for an ordered list of cards — version content hash. Order + * matters because re-ordering is a meaningful change for the learner. + */ +export function hashVersionCards( + cards: { type: string; fields: Record; ord: number }[] +): string { + const ordered = [...cards].sort((a, b) => a.ord - b.ord); + return sha256( + JSON.stringify(ordered.map((c) => canonical({ type: c.type, fields: c.fields, ord: c.ord }))) + ); +} diff --git a/services/cards-server/src/lib/slug.ts b/services/cards-server/src/lib/slug.ts new file mode 100644 index 000000000..009cfcbf2 --- /dev/null +++ b/services/cards-server/src/lib/slug.ts @@ -0,0 +1,58 @@ +/** + * URL-safe slug helpers. + * + * `slugify` is best-effort — turns "Anna Lang!" into "anna-lang" — for + * suggesting an initial slug. `validateSlug` is strict and what we + * enforce on every write so the URL space stays predictable. + */ + +const MAX_SLUG_LEN = 60; +const MIN_SLUG_LEN = 3; + +const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{1,58}[a-z0-9])?$/; + +const RESERVED_SLUGS = new Set([ + 'admin', + 'api', + 'app', + 'auth', + 'docs', + 'explore', + 'feed', + 'help', + 'me', + 'mana', + 'new', + 'public', + 'search', + 'settings', + 'support', + 'system', + 'u', + 'd', + 'v1', + 'v2', +]); + +export function slugify(input: string): string { + return input + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') // strip diacritics + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG_LEN); +} + +export interface SlugValidation { + ok: boolean; + reason?: 'too-short' | 'too-long' | 'invalid-chars' | 'reserved'; +} + +export function validateSlug(slug: string): SlugValidation { + if (slug.length < MIN_SLUG_LEN) return { ok: false, reason: 'too-short' }; + if (slug.length > MAX_SLUG_LEN) return { ok: false, reason: 'too-long' }; + if (!SLUG_RE.test(slug)) return { ok: false, reason: 'invalid-chars' }; + if (RESERVED_SLUGS.has(slug)) return { ok: false, reason: 'reserved' }; + return { ok: true }; +} diff --git a/services/cards-server/src/routes/authors.ts b/services/cards-server/src/routes/authors.ts new file mode 100644 index 000000000..9fde48e85 --- /dev/null +++ b/services/cards-server/src/routes/authors.ts @@ -0,0 +1,38 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { AuthorService } from '../services/authors'; +import { BadRequestError } from '../lib/errors'; + +const upsertSchema = z.object({ + slug: z.string(), + displayName: z.string().min(1).max(80), + bio: z.string().max(500).optional(), + avatarUrl: z.string().url().max(512).optional(), + pseudonym: z.boolean().optional(), +}); + +export function createAuthorRoutes(authorService: AuthorService) { + const router = new Hono<{ Variables: { user: AuthUser } }>(); + + router.post('/me', async (c) => { + const user = c.get('user'); + const parsed = upsertSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); + const author = await authorService.upsertMe(user.userId, parsed.data); + return c.json(author); + }); + + router.get('/me', async (c) => { + const user = c.get('user'); + const author = await authorService.getByUserId(user.userId); + return c.json(author); + }); + + router.get('/:slug', async (c) => { + const author = await authorService.getPublicBySlug(c.req.param('slug')); + return c.json(author); + }); + + return router; +} diff --git a/services/cards-server/src/routes/decks.ts b/services/cards-server/src/routes/decks.ts new file mode 100644 index 000000000..f8cb831f7 --- /dev/null +++ b/services/cards-server/src/routes/decks.ts @@ -0,0 +1,68 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { AuthorService } from '../services/authors'; +import type { DeckService } from '../services/decks'; +import { BadRequestError } from '../lib/errors'; + +const cardTypes = [ + 'basic', + 'basic-reverse', + 'cloze', + 'type-in', + 'image-occlusion', + 'audio', + 'multiple-choice', +] as const; + +const initSchema = z.object({ + slug: z.string(), + title: z.string().min(1).max(140), + description: z.string().max(2000).optional(), + language: z.string().min(2).max(8).optional(), + license: z.string().max(64).optional(), + priceCredits: z.number().int().min(0).max(10_000).optional(), +}); + +const publishSchema = z.object({ + semver: z.string(), + changelog: z.string().max(2000).optional(), + cards: z + .array( + z.object({ + type: z.enum(cardTypes), + fields: z.record(z.string(), z.string()), + }) + ) + .min(1) + .max(5_000), +}); + +export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) { + const router = new Hono<{ Variables: { user: AuthUser } }>(); + + router.post('/', async (c) => { + const user = c.get('user'); + await authorService.assertNotBanned(user.userId); + const parsed = initSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); + const deck = await deckService.init(user.userId, parsed.data); + return c.json(deck, 201); + }); + + router.get('/:slug', async (c) => { + const result = await deckService.getBySlug(c.req.param('slug')); + return c.json(result); + }); + + router.post('/:slug/publish', async (c) => { + const user = c.get('user'); + await authorService.assertNotBanned(user.userId); + const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); + const result = await deckService.publish(user.userId, c.req.param('slug'), parsed.data); + return c.json(result, 201); + }); + + return router; +} diff --git a/services/cards-server/src/services/authors.ts b/services/cards-server/src/services/authors.ts new file mode 100644 index 000000000..bdc3195e3 --- /dev/null +++ b/services/cards-server/src/services/authors.ts @@ -0,0 +1,104 @@ +/** + * Author service — CRUD on `cards.authors` plus the lookups the + * routes need (by-slug, by-userId). + * + * Slug is unique per author. We don't auto-suggest slugs server-side; + * the client picks one and we validate. If a user changes their slug, + * the old slug isn't preserved (no redirects yet — Phase η maybe). + */ + +import { eq } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { authors } from '../db/schema'; +import { validateSlug } from '../lib/slug'; +import { BadRequestError, ConflictError, NotFoundError } from '../lib/errors'; + +export interface AuthorInput { + slug: string; + displayName: string; + bio?: string; + avatarUrl?: string; + pseudonym?: boolean; +} + +export class AuthorService { + constructor(private readonly db: Database) {} + + async upsertMe(userId: string, input: AuthorInput) { + const validation = validateSlug(input.slug); + if (!validation.ok) { + throw new BadRequestError(`Slug invalid: ${validation.reason}`); + } + + // Slug must be free or already owned by us. + const existingBySlug = await this.db.query.authors.findFirst({ + where: eq(authors.slug, input.slug), + }); + if (existingBySlug && existingBySlug.userId !== userId) { + throw new ConflictError('Slug already taken'); + } + + const existing = await this.db.query.authors.findFirst({ + where: eq(authors.userId, userId), + }); + + if (existing) { + const [updated] = await this.db + .update(authors) + .set({ + slug: input.slug, + displayName: input.displayName, + bio: input.bio, + avatarUrl: input.avatarUrl, + pseudonym: input.pseudonym ?? existing.pseudonym, + }) + .where(eq(authors.userId, userId)) + .returning(); + return updated; + } + + const [created] = await this.db + .insert(authors) + .values({ + userId, + slug: input.slug, + displayName: input.displayName, + bio: input.bio, + avatarUrl: input.avatarUrl, + pseudonym: input.pseudonym ?? false, + }) + .returning(); + return created; + } + + async getByUserId(userId: string) { + const row = await this.db.query.authors.findFirst({ where: eq(authors.userId, userId) }); + return row ?? null; + } + + /** Public profile lookup — strips bannedReason etc. */ + async getPublicBySlug(slug: string) { + const row = await this.db.query.authors.findFirst({ where: eq(authors.slug, slug) }); + if (!row) throw new NotFoundError('Author not found'); + return { + slug: row.slug, + displayName: row.displayName, + bio: row.bio, + avatarUrl: row.avatarUrl, + joinedAt: row.joinedAt, + pseudonym: row.pseudonym, + verifiedMana: row.verifiedMana, + verifiedCommunity: row.verifiedCommunity, + banned: row.bannedAt !== null, + }; + } + + async assertNotBanned(userId: string) { + const row = await this.getByUserId(userId); + if (!row) throw new BadRequestError('You need an author profile first (POST /v1/authors/me).'); + if (row.bannedAt) { + throw new BadRequestError(`Author banned: ${row.bannedReason ?? 'no reason given'}`); + } + return row; + } +} diff --git a/services/cards-server/src/services/decks.ts b/services/cards-server/src/services/decks.ts new file mode 100644 index 000000000..2b37e5a46 --- /dev/null +++ b/services/cards-server/src/services/decks.ts @@ -0,0 +1,223 @@ +/** + * Deck service — init + publish. + * + * `init` claims a slug and creates a `cards.decks` row with no + * version yet (so authors can fiddle with metadata before their first + * publish). `publish` runs the AI-mod first-pass, computes per-card + * + per-version content hashes, writes a new immutable version + its + * cards, and atomically updates `latest_version_id` on the deck. + * + * Per MARKETPLACE_PLAN: a `block` verdict from AI mod refuses the + * publish outright. A `flag` verdict still publishes (so the deck + * isn't blocked on slow human review) but writes a row into + * `ai_moderation_log` so the moderation inbox surfaces it. + */ + +import { and, eq, sql } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { publicDecks, publicDeckVersions, publicDeckCards, aiModerationLog } from '../db/schema'; +import { validateSlug } from '../lib/slug'; +import { hashCard, hashVersionCards } from '../lib/hash'; +import { moderateDeckContent } from '../lib/ai-moderation'; +import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from '../lib/errors'; + +export interface InitDeckInput { + slug: string; + title: string; + description?: string; + language?: string; + license?: string; + priceCredits?: number; +} + +export interface PublishInput { + semver: string; + changelog?: string; + cards: { + type: + | 'basic' + | 'basic-reverse' + | 'cloze' + | 'type-in' + | 'image-occlusion' + | 'audio' + | 'multiple-choice'; + fields: Record; + }[]; +} + +export interface PublishResult { + deck: typeof publicDecks.$inferSelect; + version: typeof publicDeckVersions.$inferSelect; + moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] }; +} + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +function validatePrice(price: number, license: string) { + if (price < 0) throw new BadRequestError('priceCredits cannot be negative'); + if (price > 0 && license !== 'Cards-Pro-Only-1.0') { + throw new BadRequestError('Paid decks must use the Cards-Pro-Only-1.0 license'); + } +} + +export class DeckService { + constructor( + private readonly db: Database, + private readonly llmUrl: string + ) {} + + async init(ownerUserId: string, input: InitDeckInput) { + const validation = validateSlug(input.slug); + if (!validation.ok) throw new BadRequestError(`Slug invalid: ${validation.reason}`); + + const license = input.license ?? 'Cards-Personal-Use-1.0'; + const priceCredits = input.priceCredits ?? 0; + validatePrice(priceCredits, license); + + const existing = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, input.slug), + }); + if (existing) throw new ConflictError('Slug already taken'); + + const [created] = await this.db + .insert(publicDecks) + .values({ + slug: input.slug, + title: input.title, + description: input.description, + language: input.language, + license, + priceCredits, + ownerUserId, + }) + .returning(); + return created; + } + + async getBySlug(slug: string) { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, slug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + + const version = deck.latestVersionId + ? await this.db.query.publicDeckVersions.findFirst({ + where: eq(publicDeckVersions.id, deck.latestVersionId), + }) + : null; + + return { deck, latestVersion: version }; + } + + async publish(ownerUserId: string, slug: string, input: PublishInput): Promise { + if (!SEMVER_RE.test(input.semver)) { + throw new BadRequestError('semver must look like 1.0.0'); + } + if (input.cards.length === 0) { + throw new BadRequestError('A version needs at least one card'); + } + + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, slug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + if (deck.ownerUserId !== ownerUserId) { + throw new ForbiddenError('Only the deck owner can publish'); + } + if (deck.isTakedown) throw new ForbiddenError('Deck is under takedown'); + + // semver must be strictly greater than the latest published + // version so version history stays linear. + if (deck.latestVersionId) { + const latest = await this.db.query.publicDeckVersions.findFirst({ + where: eq(publicDeckVersions.id, deck.latestVersionId), + }); + if (latest && !semverGreater(input.semver, latest.semver)) { + throw new ConflictError(`semver ${input.semver} must be > ${latest.semver}`); + } + } + + // 1) AI moderation first-pass. + const moderation = await moderateDeckContent( + { + title: deck.title, + description: deck.description ?? undefined, + cards: input.cards.map((c) => ({ fields: c.fields })), + }, + this.llmUrl + ); + if (moderation.verdict === 'block') { + throw new ForbiddenError( + `Refused by content moderation: ${moderation.rationale || 'no rationale'}` + ); + } + + // 2) Compute hashes. + const cardsWithOrd = input.cards.map((c, i) => ({ ...c, ord: i })); + const versionContentHash = hashVersionCards(cardsWithOrd); + + // 3) Insert version + cards + flip latest_version_id atomically. + const result = await this.db.transaction(async (tx) => { + const [version] = await tx + .insert(publicDeckVersions) + .values({ + deckId: deck.id, + semver: input.semver, + changelog: input.changelog, + contentHash: versionContentHash, + cardCount: cardsWithOrd.length, + }) + .returning(); + + await tx.insert(publicDeckCards).values( + cardsWithOrd.map((c) => ({ + versionId: version.id, + type: c.type, + fields: c.fields, + ord: c.ord, + contentHash: hashCard(c), + })) + ); + + await tx.insert(aiModerationLog).values({ + versionId: version.id, + verdict: moderation.verdict, + categories: moderation.categories, + model: moderation.model, + rationale: moderation.rationale, + }); + + const [updatedDeck] = await tx + .update(publicDecks) + .set({ latestVersionId: version.id }) + .where(and(eq(publicDecks.id, deck.id))) + .returning(); + + return { deck: updatedDeck, version }; + }); + + return { + deck: result.deck, + version: result.version, + moderation: { verdict: moderation.verdict, categories: moderation.categories }, + }; + } +} + +function semverGreater(a: string, b: string): boolean { + const matchA = a.match(SEMVER_RE); + const matchB = b.match(SEMVER_RE); + if (!matchA || !matchB) return false; + for (let i = 1; i <= 3; i++) { + const da = Number.parseInt(matchA[i], 10); + const db = Number.parseInt(matchB[i], 10); + if (da > db) return true; + if (da < db) return false; + } + return false; +} + +// Silence unused-binding lint for `sql` import — we keep it ready for +// upcoming routes (server-side orderings / counts). +void sql;