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:
parent
9a7068dd19
commit
7dbbf63523
40 changed files with 4004 additions and 1 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
42
apps/api/src/lib/marketplace/ai-moderation.ts
Normal file
42
apps/api/src/lib/marketplace/ai-moderation.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
64
apps/api/src/lib/marketplace/slug.ts
Normal file
64
apps/api/src/lib/marketplace/slug.ts
Normal 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 };
|
||||
}
|
||||
38
apps/api/src/lib/marketplace/version-hash.ts
Normal file
38
apps/api/src/lib/marketplace/version-hash.ts
Normal 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('');
|
||||
}
|
||||
64
apps/api/src/middleware/marketplace/optional-auth.ts
Normal file
64
apps/api/src/middleware/marketplace/optional-auth.ts
Normal 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();
|
||||
};
|
||||
148
apps/api/src/routes/marketplace/authors.ts
Normal file
148
apps/api/src/routes/marketplace/authors.ts
Normal 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;
|
||||
}
|
||||
380
apps/api/src/routes/marketplace/decks.ts
Normal file
380
apps/api/src/routes/marketplace/decks.ts
Normal 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;
|
||||
}
|
||||
54
apps/api/tests/marketplace-slug.test.ts
Normal file
54
apps/api/tests/marketplace-slug.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
49
apps/api/tests/marketplace-version-hash.test.ts
Normal file
49
apps/api/tests/marketplace-version-hash.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue