Phase 12 R2: Marketplace-Backend α + β — Authors + Deck-Init + Publish

Routes (additiv unter /api/v1/marketplace/*):
- POST/GET /authors/me — eigenes Author-Profil anlegen/updaten/lesen
- GET /authors/:slug — public Profile-Lookup (banned-reason gestrippt)
- POST /decks — Deck-Init (Slug-Validation + Pflicht-Author-Profil +
  CHECK auf paid + Pro-License)
- POST /decks/:slug/publish — Versions-Snapshot mit per-Karte
  cardContentHash aus @cards/domain, per-Version-Hash, AI-Mod-Stub-Log,
  atomarer latest_version_id-Bump in Drizzle-Transaction
- PATCH /decks/:slug — Metadaten-Update (Owner-Only)
- GET /decks/:slug — Public-Detail mit optional-auth-Middleware

Geport aus cards-decommission-base:services/cards-server/, mit
Greenfield-Anpassungen:
- Hashing über @cards/domain.cardContentHash (gemeinsame SoT
  zwischen privatem cards.cards und marketplace.deck_cards), per-
  Version-Hash als SHA-256 über sortierte Karten-Hashes mit Ord-Prefix
- AI-Moderation als R2-Stub (pass+rationale+model='stub'),
  echte mana-llm-Anbindung in späterer Welle
- Auth-Middleware-Shape an Greenfield (userId/tier/authMode in
  c.get(...) statt user-Object), optional-auth als Schwester für
  anonymen Public-Read
- Hono-typing: outer Marketplace-Decks-Router ist Partial<AuthVars>
  weil Public-GET kein JWT braucht; Auth-Subroute ist strict

Lese-Referenz:
- 3331 LOC altes cards-server-Code (routes, services, middleware,
  lib) unter docs/marketplace/archive/code/ archiviert. Read-only,
  nicht im Build-Path.

Verifikation:
- 16 neue Vitest-Tests (Slug + Version-Hash), 72 gesamt grün
- type-check 0 errors
- E2E-Smoke gegen lokale cards-api: Cardecky-Author + Deck
  r2-stoische-ethik mit 3 Karten v1.0.0 (basic + basic + cloze),
  per-Karten-Hashes geschrieben, ai_moderation_log-Row da, semver-409
  + paid-422-Errors verifiziert. Smoke-Daten danach aufgeräumt.

Verbleibend für R3+: Discovery (explore + search), Engagement (stars/
subscribe/fork), Smart-Merge mit FSRS-State-Erhalt; danach R4 PRs +
Card-Discussions, R5 Frontend-Routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-09 15:13:58 +02:00
parent 9a7068dd19
commit 7dbbf63523
40 changed files with 4004 additions and 1 deletions

View file

@ -32,3 +32,41 @@ export type {
export { importJobs } from './imports.ts';
export type { ImportJobRow, ImportJobInsert } from './imports.ts';
// Marketplace-Schema (Phase 12 R1, eigenes pgSchema('marketplace')).
// Re-Exports tragen den `public`-Prefix aus der Original-Implementation
// (`publicDecks`/`publicDeckVersions`/`publicDeckCards`), damit sie
// nicht mit den oben gelisteten privaten Lern-Tabellen kollidieren.
export {
marketplaceSchema,
authors,
authorFollows,
publicDecks,
publicDeckVersions,
publicDeckCards,
cardTypeEnum,
tagDefinitions,
deckTags,
deckStars,
deckSubscriptions,
deckForks,
deckPullRequests,
cardDiscussions,
pullRequestStatusEnum,
deckReports,
aiModerationLog,
reportCategoryEnum,
reportStatusEnum,
aiModerationVerdictEnum,
deckPurchases,
authorPayouts,
} from './marketplace/index.ts';
export type {
AuthorRow,
AuthorInsert,
PublicDeckRow,
PublicDeckInsert,
PublicDeckVersionRow,
PublicDeckCardRow,
PullRequestDiff,
} from './marketplace/index.ts';

View file

@ -13,6 +13,8 @@ import { dsgvoRouter } from './routes/dsgvo.ts';
import { meRouter } from './routes/me.ts';
import { mediaRouter } from './routes/media.ts';
import { decksGenerateRouter } from './routes/decks-generate.ts';
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
const app = new Hono();
@ -46,6 +48,11 @@ app.route('/api/v1/me', meRouter());
app.route('/api/v1/media', mediaRouter());
app.route('/api/v1/decks/generate', decksGenerateRouter());
// Marketplace (Phase 12). Eigenes pgSchema, additive Routen unter /v1/marketplace/*.
// Plan: docs/playbooks/MARKETPLACE_RESTORE.md.
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());
app.get('/', (c) =>
c.json({
app: 'cards',

View file

@ -0,0 +1,42 @@
/**
* AI-Moderation-First-Pass für Deck-Publishes.
*
* Verdict: `pass` | `flag` | `block`. Per Mission-Prinzip AI ist
* Moderator, nicht Gatekeeper" wird `block` nur für unmissverständliche
* Verstöße benutzt (CSAM, Doxxing); alles ambivalente fließt zur
* menschlichen Review als `flag`.
*
* **Aktueller Stand: Stub.** R2 implementiert nur den Pass-Through
* (`verdict='pass'`, `model='stub'`) plus den Audit-Log-Eintrag.
* Echte mana-llm-Integration kommt in einer späteren Welle (siehe
* cards-decommission-base:services/cards-server/src/lib/ai-moderation.ts
* für die alte Voll-Implementation als Lese-Referenz unter
* `docs/marketplace/archive/code/lib/ai-moderation.ts`).
*
* Fail-open im Original: bei mana-llm-Ausfall wurde `flag` gesetzt,
* damit ein menschlicher Reviewer es trotzdem sieht. Solange wir nur
* Cardecky-Decks publishen, ist der Stub `pass` ausreichend Cardecky
* ist eine kuratierte Identität.
*/
export interface ModerationVerdict {
verdict: 'pass' | 'flag' | 'block';
categories: string[];
rationale: string;
model: string;
}
export interface ModerationInput {
title: string;
description?: string;
cards: { fields: Record<string, string> }[];
}
export async function moderateDeckContent(_input: ModerationInput): Promise<ModerationVerdict> {
return {
verdict: 'pass',
categories: [],
rationale: 'R2-stub: AI-Mod nicht aktiv, alles passt durch.',
model: 'stub',
};
}

View file

@ -0,0 +1,64 @@
/**
* URL-safe Slug-Helpers für Marketplace-Author-Profile + Deck-URLs.
*
* `slugify` ist best-effort macht Anna Lang!" zu „anna-lang" als
* Vorschlag. `validateSlug` ist strikt und wird auf jeden Write
* enforced, damit der URL-Raum vorhersehbar bleibt.
*
* 1:1 ported aus
* `cards-decommission-base:services/cards-server/src/lib/slug.ts`.
*/
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',
'marketplace',
'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 type SlugInvalidReason = 'too-short' | 'too-long' | 'invalid-chars' | 'reserved';
export interface SlugValidation {
ok: boolean;
reason?: SlugInvalidReason;
}
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 };
}

View file

@ -0,0 +1,38 @@
/**
* Per-Version-Content-Hash. Pro Karte wird `cardContentHash` aus
* `@cards/domain` benutzt (gemeinsame Source-of-Truth zwischen privatem
* Lern-Stack und Marketplace) pro Version hashen wir die geordnete
* Liste von Karten-Hashes plus deren Ordinal-Position.
*
* Smart-Merge nutzt das: zwei Versionen mit identischen Karten in
* identischer Reihenfolge bekommen denselben Version-Hash; eine
* Reihenfolgen-Änderung allein ändert den Version-Hash, weil das aus
* Lern-Sicht ein anderer Verlauf ist.
*
* Original-cards-server hatte eine eigene `hashCard`/`hashVersionCards`-
* Implementation; der Greenfield-`cardContentHash` benutzt aber ein
* leicht anderes Canonical-Format (sortiertes Tupel-Array statt
* sortiertes Object). Wir vereinheitlichen auf das Greenfield-Format,
* weil ein Fork eines Marketplace-Decks die Karten-Hashes mit den
* privaten `cards.cards.content_hash` matchen können soll.
*/
import { cardContentHash } from '@cards/domain';
export interface OrderedCardForHash {
type: string;
fields: Record<string, string>;
ord: number;
}
export async function hashVersionCards(cards: OrderedCardForHash[]): Promise<string> {
const ordered = [...cards].sort((a, b) => a.ord - b.ord);
const cardHashes = await Promise.all(
ordered.map(async (c) => `${c.ord}:${await cardContentHash({ type: c.type, fields: c.fields })}`)
);
const data = new TextEncoder().encode(cardHashes.join('|'));
const buf = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

View file

@ -0,0 +1,64 @@
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { AuthVars, Tier } from '../auth.ts';
/**
* Optional-Auth-Middleware für Marketplace-Public-Endpoints.
*
* Setzt `userId`/`tier`/`authMode` wenn ein gültiger Bearer-Token da
* ist, lehnt aber **nie** ab. Public-Read (Explore, Deck-Detail, Author-
* Profile) funktioniert anonym; signed-in User sehen zusätzlich ihren
* Star/Subscribe/Fork-State.
*
* Schwester-Middleware zu `authMiddleware` (strict, in `../auth.ts`).
* Beide nutzen denselben JWKS-Cache + dasselbe Algo, dieser ist nur
* der Read-Path-Companion.
*
* Geschichte: 1:1 ported from `cards-decommission-base:services/cards-server/src/middleware/optional-auth.ts`,
* mit Anpassung auf den Greenfield-`AuthVars`-Shape (`userId`/`tier`
* statt `user`-Object).
*/
const MANA_AUTH_URL = process.env.MANA_AUTH_URL ?? 'https://auth.mana.how';
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks() {
if (!jwksCache) {
jwksCache = createRemoteJWKSet(new URL('/api/auth/jwks', MANA_AUTH_URL));
}
return jwksCache;
}
function tierFromClaim(raw: unknown): Tier {
if (typeof raw !== 'string') return 'public';
if (raw === 'guest' || raw === 'public' || raw === 'beta' || raw === 'alpha' || raw === 'founder') {
return raw;
}
return 'public';
}
export const optionalAuthMiddleware: MiddlewareHandler<{ Variables: Partial<AuthVars> }> = async (
c,
next
) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
await next();
return;
}
const token = authHeader.slice(7).trim();
try {
const { payload } = await jwtVerify(token, getJwks(), {});
const sub = typeof payload.sub === 'string' ? payload.sub : null;
if (sub) {
c.set('userId', sub);
c.set('tier', tierFromClaim(payload.tier));
c.set('authMode', 'jwt');
}
} catch {
// Bad Token = anonymous fortfahren. Strict-Middleware ist für
// Mutation-Endpoints zuständig.
}
await next();
};

View file

@ -0,0 +1,148 @@
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { getDb, type CardsDb } from '../../db/connection.ts';
import { authors } from '../../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import { validateSlug } from '../../lib/marketplace/slug.ts';
/**
* Author-Routen für den Marketplace.
*
* - `POST /me` eigenes Author-Profil anlegen oder updaten (Slug,
* Display-Name, Bio, Avatar, Pseudonym-Modus). Idempotent: existiert
* bereits ein Profil zur User-ID, wird es ge-upsertet.
* - `GET /me` eigenes Profil lesen, `null` wenn nicht angelegt.
* - `GET /:slug` public Profile-Lookup. Banned-Reason wird gestrippt.
*
* Geschichte: ported aus
* `cards-decommission-base:services/cards-server/src/routes/authors.ts`
* + `services/authors.ts`. Auth-Shape an Greenfield angepasst (`userId`
* aus `c.get('userId')`, nicht `c.get('user').userId`).
*/
export type AuthorsDeps = { db?: CardsDb };
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(),
});
function toPublicProfile(row: {
slug: string;
displayName: string;
bio: string | null;
avatarUrl: string | null;
joinedAt: Date;
pseudonym: boolean;
verifiedMana: boolean;
verifiedCommunity: boolean;
bannedAt: Date | null;
}) {
return {
slug: row.slug,
display_name: row.displayName,
bio: row.bio,
avatar_url: row.avatarUrl,
joined_at: row.joinedAt.toISOString(),
pseudonym: row.pseudonym,
verified_mana: row.verifiedMana,
verified_community: row.verifiedCommunity,
banned: row.bannedAt !== null,
};
}
export function authorsRouter(deps: AuthorsDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
// /me/* benötigt Auth. /:slug ist public — wir splitten am Router.
const meRouter = new Hono<{ Variables: AuthVars }>();
meRouter.use('*', authMiddleware);
meRouter.post('/', async (c) => {
const userId = c.get('userId');
const body = await c.req.json().catch(() => null);
const parsed = UpsertSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const validation = validateSlug(parsed.data.slug);
if (!validation.ok) {
return c.json({ error: 'invalid_slug', reason: validation.reason }, 422);
}
const db = dbOf();
// Slug muss frei sein oder uns gehören.
const [bySlug] = await db
.select()
.from(authors)
.where(eq(authors.slug, parsed.data.slug))
.limit(1);
if (bySlug && bySlug.userId !== userId) {
return c.json({ error: 'slug_taken' }, 409);
}
const [existing] = await db
.select()
.from(authors)
.where(eq(authors.userId, userId))
.limit(1);
if (existing) {
const [updated] = await db
.update(authors)
.set({
slug: parsed.data.slug,
displayName: parsed.data.displayName,
bio: parsed.data.bio,
avatarUrl: parsed.data.avatarUrl,
pseudonym: parsed.data.pseudonym ?? existing.pseudonym,
})
.where(eq(authors.userId, userId))
.returning();
return c.json(toPublicProfile(updated));
}
const [created] = await db
.insert(authors)
.values({
userId,
slug: parsed.data.slug,
displayName: parsed.data.displayName,
bio: parsed.data.bio,
avatarUrl: parsed.data.avatarUrl,
pseudonym: parsed.data.pseudonym ?? false,
})
.returning();
return c.json(toPublicProfile(created), 201);
});
meRouter.get('/', async (c) => {
const userId = c.get('userId');
const [row] = await dbOf().select().from(authors).where(eq(authors.userId, userId)).limit(1);
if (!row) return c.json(null);
return c.json(toPublicProfile(row));
});
r.route('/me', meRouter);
// Public Profile-Lookup.
r.get('/:slug', async (c) => {
const slug = c.req.param('slug');
const [row] = await dbOf().select().from(authors).where(eq(authors.slug, slug)).limit(1);
if (!row) return c.json({ error: 'not_found' }, 404);
return c.json(toPublicProfile(row));
});
return r;
}

View file

@ -0,0 +1,380 @@
import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { cardContentHash } from '@cards/domain';
import { getDb, type CardsDb } from '../../db/connection.ts';
import {
aiModerationLog,
authors,
publicDeckCards,
publicDeckVersions,
publicDecks,
} from '../../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../../middleware/auth.ts';
import { optionalAuthMiddleware } from '../../middleware/marketplace/optional-auth.ts';
import { moderateDeckContent } from '../../lib/marketplace/ai-moderation.ts';
import { validateSlug } from '../../lib/marketplace/slug.ts';
import { hashVersionCards } from '../../lib/marketplace/version-hash.ts';
/**
* Deck-Routen für den Marketplace.
*
* - `POST /` Deck-Init (Slug, Titel, optionale Lizenz/Preis).
* - `GET /:slug` Public-Deck-Detail mit `latest_version`.
* Optional-Auth: signed-in User sieht später
* zusätzlich Subscribe/Star-State (R3).
* - `POST /:slug/publish` Neue Version publishen. Nur Owner.
* Karten + Hashes + AI-Mod-Log + atomarer
* Bump des `latest_version_id`.
* - `PATCH /:slug` Metadaten-Update. Nur Owner.
*
* Geschichte: ported aus
* `cards-decommission-base:services/cards-server/src/{routes,services}/decks.ts`,
* mit Greenfield-Anpassungen (Hashing über `@cards/domain`,
* AI-Mod-Stub statt mana-llm-Call, Auth-Vars-Shape).
*/
export type MarketplaceDecksDeps = { db?: CardsDb };
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const InitSchema = z.object({
slug: z.string(),
title: z.string().min(1).max(120),
description: z.string().max(2000).optional(),
language: z
.string()
.regex(/^[a-z]{2}$/, 'language must be ISO-639-1 (e.g. de, en)')
.optional(),
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
});
const PatchSchema = z.object({
title: z.string().min(1).max(120).optional(),
description: z.string().max(2000).optional(),
language: z.string().regex(/^[a-z]{2}$/).optional(),
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
});
const CardTypeSchema = z.enum([
'basic',
'basic-reverse',
'cloze',
'type-in',
'image-occlusion',
'audio',
'multiple-choice',
]);
const PublishSchema = z.object({
semver: z.string().regex(SEMVER_RE, 'semver must look like 1.0.0'),
changelog: z.string().max(2000).optional(),
cards: z
.array(
z.object({
type: CardTypeSchema,
fields: z.record(z.string()),
})
)
.min(1, 'A version needs at least one card'),
});
function semverGreater(a: string, b: string): boolean {
const ma = a.match(SEMVER_RE);
const mb = b.match(SEMVER_RE);
if (!ma || !mb) return false;
for (let i = 1; i <= 3; i++) {
const da = Number.parseInt(ma[i], 10);
const db = Number.parseInt(mb[i], 10);
if (da > db) return true;
if (da < db) return false;
}
return false;
}
function toDeckDto(row: typeof publicDecks.$inferSelect) {
return {
id: row.id,
slug: row.slug,
title: row.title,
description: row.description,
language: row.language,
license: row.license,
price_credits: row.priceCredits,
owner_user_id: row.ownerUserId,
latest_version_id: row.latestVersionId,
is_featured: row.isFeatured,
is_takedown: row.isTakedown,
created_at: row.createdAt.toISOString(),
};
}
function toVersionDto(row: typeof publicDeckVersions.$inferSelect) {
return {
id: row.id,
deck_id: row.deckId,
semver: row.semver,
changelog: row.changelog,
content_hash: row.contentHash,
card_count: row.cardCount,
published_at: row.publishedAt.toISOString(),
deprecated_at: row.deprecatedAt?.toISOString() ?? null,
};
}
export function marketplaceDecksRouter(
deps: MarketplaceDecksDeps = {}
): Hono<{ Variables: Partial<AuthVars> }> {
const r = new Hono<{ Variables: Partial<AuthVars> }>();
const dbOf = () => deps.db ?? getDb();
// Path-scoped Middleware: GET /:slug ist optional-auth (anonymer
// Read erlaubt), alles andere strict-auth.
r.get('/:slug', optionalAuthMiddleware, async (c) => {
const slug = c.req.param('slug');
const db = dbOf();
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
let version: typeof publicDeckVersions.$inferSelect | null = null;
if (deck.latestVersionId) {
const [v] = await db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1);
version = v ?? null;
}
return c.json({
deck: toDeckDto(deck),
latest_version: version ? toVersionDto(version) : null,
});
});
// Authenticated endpoints folgen.
const auth = new Hono<{ Variables: AuthVars }>();
auth.use('*', authMiddleware);
// POST / — Deck-Init.
auth.post('/', async (c) => {
const userId = c.get('userId');
const body = await c.req.json().catch(() => null);
const parsed = InitSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const validation = validateSlug(parsed.data.slug);
if (!validation.ok) {
return c.json({ error: 'invalid_slug', reason: validation.reason }, 422);
}
const license = parsed.data.license ?? 'Cardecky-Personal-Use-1.0';
const priceCredits = parsed.data.priceCredits ?? 0;
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
return c.json(
{
error: 'paid_decks_require_pro_license',
detail: 'priceCredits > 0 ⇒ license must be Cardecky-Pro-Only-1.0',
},
422
);
}
const db = dbOf();
// Author-Profil-Pflicht — kein Decksanlegen ohne authors-Row.
const [author] = await db.select().from(authors).where(eq(authors.userId, userId)).limit(1);
if (!author) {
return c.json(
{ error: 'no_author_profile', detail: 'POST /api/v1/marketplace/authors/me first' },
400
);
}
if (author.bannedAt) {
return c.json({ error: 'author_banned', reason: author.bannedReason ?? 'no_reason' }, 403);
}
const [bySlug] = await db
.select()
.from(publicDecks)
.where(eq(publicDecks.slug, parsed.data.slug))
.limit(1);
if (bySlug) return c.json({ error: 'slug_taken' }, 409);
const [created] = await db
.insert(publicDecks)
.values({
slug: parsed.data.slug,
title: parsed.data.title,
description: parsed.data.description,
language: parsed.data.language,
license,
priceCredits,
ownerUserId: userId,
})
.returning();
return c.json(toDeckDto(created), 201);
});
// PATCH /:slug — Metadaten.
auth.patch('/:slug', async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => null);
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const db = dbOf();
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
if (deck.ownerUserId !== userId) return c.json({ error: 'forbidden' }, 403);
if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403);
const license = parsed.data.license ?? deck.license;
const priceCredits = parsed.data.priceCredits ?? deck.priceCredits;
if (priceCredits > 0 && license !== 'Cardecky-Pro-Only-1.0') {
return c.json({ error: 'paid_decks_require_pro_license' }, 422);
}
const [updated] = await db
.update(publicDecks)
.set({
...(parsed.data.title !== undefined && { title: parsed.data.title }),
...(parsed.data.description !== undefined && { description: parsed.data.description }),
...(parsed.data.language !== undefined && { language: parsed.data.language }),
...(parsed.data.license !== undefined && { license }),
...(parsed.data.priceCredits !== undefined && { priceCredits }),
})
.where(and(eq(publicDecks.id, deck.id)))
.returning();
return c.json(toDeckDto(updated));
});
// POST /:slug/publish — neue Version.
auth.post('/:slug/publish', async (c) => {
const userId = c.get('userId');
const slug = c.req.param('slug');
const body = await c.req.json().catch(() => null);
const parsed = PublishSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const db = dbOf();
const [deck] = await db.select().from(publicDecks).where(eq(publicDecks.slug, slug)).limit(1);
if (!deck) return c.json({ error: 'not_found' }, 404);
if (deck.ownerUserId !== userId) return c.json({ error: 'forbidden' }, 403);
if (deck.isTakedown) return c.json({ error: 'takedown_active' }, 403);
// Semver muss strikt größer als latest sein — lineare History.
if (deck.latestVersionId) {
const [latest] = await db
.select()
.from(publicDeckVersions)
.where(eq(publicDeckVersions.id, deck.latestVersionId))
.limit(1);
if (latest && !semverGreater(parsed.data.semver, latest.semver)) {
return c.json(
{ error: 'semver_not_greater', latest: latest.semver, got: parsed.data.semver },
409
);
}
}
// 1) AI-Mod (Stub in R2).
const moderation = await moderateDeckContent({
title: deck.title,
description: deck.description ?? undefined,
cards: parsed.data.cards.map((card) => ({ fields: card.fields })),
});
if (moderation.verdict === 'block') {
return c.json(
{ error: 'moderation_block', categories: moderation.categories, rationale: moderation.rationale },
403
);
}
// 2) Hashes berechnen.
const cardsWithOrd = parsed.data.cards.map((card, i) => ({ ...card, ord: i }));
const versionContentHash = await hashVersionCards(cardsWithOrd);
const cardHashes = await Promise.all(
cardsWithOrd.map((card) => cardContentHash({ type: card.type, fields: card.fields }))
);
// 3) Insert Version + Cards + AI-Log + flip latest_version_id atomar.
const result = await db.transaction(async (tx) => {
const [version] = await tx
.insert(publicDeckVersions)
.values({
deckId: deck.id,
semver: parsed.data.semver,
changelog: parsed.data.changelog,
contentHash: versionContentHash,
cardCount: cardsWithOrd.length,
})
.returning();
await tx.insert(publicDeckCards).values(
cardsWithOrd.map((card, i) => ({
versionId: version.id,
type: card.type,
fields: card.fields,
ord: card.ord,
contentHash: cardHashes[i],
}))
);
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(eq(publicDecks.id, deck.id))
.returning();
return { deck: updatedDeck, version };
});
return c.json(
{
deck: toDeckDto(result.deck),
version: toVersionDto(result.version),
moderation: {
verdict: moderation.verdict,
categories: moderation.categories,
model: moderation.model,
},
},
201
);
});
// Auth-Sub-Router gemountet auf '/'. Hono routet zuerst exakte
// Pfade auf `r` (GET /:slug), Rest fließt auf den auth-Mount.
r.route('/', auth);
return r;
}

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { slugify, validateSlug } from '../src/lib/marketplace/slug.ts';
describe('slugify', () => {
it('lowercases and dasherizes', () => {
expect(slugify('Anna Lang!')).toBe('anna-lang');
});
it('strips leading/trailing dashes', () => {
expect(slugify(' hello world ')).toBe('hello-world');
});
it('drops diacritics', () => {
expect(slugify('Café Crème')).toBe('cafe-creme');
});
it('caps at 60 chars', () => {
const slug = slugify('a'.repeat(120));
expect(slug.length).toBeLessThanOrEqual(60);
});
});
describe('validateSlug', () => {
it('accepts simple lowercase slugs', () => {
expect(validateSlug('anna-lang')).toEqual({ ok: true });
});
it('rejects too short', () => {
expect(validateSlug('ab')).toEqual({ ok: false, reason: 'too-short' });
});
it('rejects too long', () => {
expect(validateSlug('a'.repeat(70))).toEqual({ ok: false, reason: 'too-long' });
});
it('rejects uppercase', () => {
expect(validateSlug('Anna-Lang')).toEqual({ ok: false, reason: 'invalid-chars' });
});
it('rejects underscore', () => {
expect(validateSlug('anna_lang')).toEqual({ ok: false, reason: 'invalid-chars' });
});
it('rejects leading dash', () => {
expect(validateSlug('-anna')).toEqual({ ok: false, reason: 'invalid-chars' });
});
it('rejects reserved slugs', () => {
expect(validateSlug('admin')).toEqual({ ok: false, reason: 'reserved' });
expect(validateSlug('explore')).toEqual({ ok: false, reason: 'reserved' });
expect(validateSlug('marketplace')).toEqual({ ok: false, reason: 'reserved' });
});
});

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { cardContentHash } from '@cards/domain';
import { hashVersionCards } from '../src/lib/marketplace/version-hash.ts';
describe('hashVersionCards', () => {
const card1 = { type: 'basic', fields: { front: 'Q1', back: 'A1' }, ord: 0 };
const card2 = { type: 'basic', fields: { front: 'Q2', back: 'A2' }, ord: 1 };
it('is deterministic', async () => {
const a = await hashVersionCards([card1, card2]);
const b = await hashVersionCards([card1, card2]);
expect(a).toBe(b);
});
it('changes when card order changes', async () => {
const original = await hashVersionCards([card1, card2]);
const swapped = await hashVersionCards([
{ ...card1, ord: 1 },
{ ...card2, ord: 0 },
]);
expect(original).not.toBe(swapped);
});
it('changes when a field changes', async () => {
const original = await hashVersionCards([card1, card2]);
const tweaked = await hashVersionCards([
card1,
{ ...card2, fields: { ...card2.fields, back: 'A2-edited' } },
]);
expect(original).not.toBe(tweaked);
});
it('input order independent (sorts by ord)', async () => {
const inOrder = await hashVersionCards([card1, card2]);
const reversedInput = await hashVersionCards([card2, card1]);
expect(inOrder).toBe(reversedInput);
});
it('uses cardContentHash for per-card identity (consumes @cards/domain SoT)', async () => {
// Smoke: changing fields without changing the type must change the
// per-card hash (cardContentHash semantics) and therefore the
// version hash.
const a = await cardContentHash({ type: 'basic', fields: { front: 'X', back: 'Y' } });
const b = await cardContentHash({ type: 'basic', fields: { front: 'X', back: 'Y2' } });
expect(a).not.toBe(b);
});
});