diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts index d2ec398da..904424384 100644 --- a/services/cards-server/src/index.ts +++ b/services/cards-server/src/index.ts @@ -14,11 +14,16 @@ 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 { optionalAuth } from './middleware/optional-auth'; import { healthRoutes } from './routes/health'; import { AuthorService } from './services/authors'; import { DeckService } from './services/decks'; +import { ExploreService } from './services/explore'; +import { EngagementService } from './services/engagement'; import { createAuthorRoutes } from './routes/authors'; import { createDeckRoutes } from './routes/decks'; +import { createExploreRoutes } from './routes/explore'; +import { createEngagementRoutes } from './routes/engagement'; // ─── Bootstrap ────────────────────────────────────────────── @@ -27,10 +32,12 @@ const db = getDb(config.databaseUrl); const authorService = new AuthorService(db); const deckService = new DeckService(db, config.manaLlmUrl); +const exploreService = new ExploreService(db); +const engagementService = new EngagementService(db); // ─── App ──────────────────────────────────────────────────── -const app = new Hono<{ Variables: { user: AuthUser } }>(); +const app = new Hono<{ Variables: { user?: AuthUser } }>(); app.onError(errorHandler); app.use( @@ -46,16 +53,26 @@ app.route('/health', healthRoutes); // 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 } }>(); +// +// Two auth tiers: +// - jwtAuth: strict, used on writes (publish, profile updates, +// star/follow). 401 if missing/invalid token. +// - optionalAuth: opportunistic, used on every read. Sets +// c.get('user') if a token validates, otherwise leaves it +// undefined and lets the route serve anonymous content. +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)); +// Phase γ: public reads first — explore + browse + tags + author +// profile lookup + deck profile lookup. All read-only, no token +// required, but a present token enables logged-in extras (star +// state, follow state) once those flags land in the responses +// (MARKETPLACE_PLAN phase γ.3). +v1.use('/*', optionalAuth(config.manaAuthUrl)); +// Mounted routers handle their own per-route auth requirements +// via requireUser() helpers when needed. +v1.route('/', createExploreRoutes(exploreService)); +v1.route('/', createEngagementRoutes(engagementService)); v1.route('/authors', createAuthorRoutes(authorService)); v1.route('/decks', createDeckRoutes(authorService, deckService)); @@ -66,8 +83,14 @@ v1.get('/', (c) => message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.', }) ); + app.route('/v1', v1); +// Keep jwtAuth around — re-exported for callers that need to wrap +// individual mutating subroutes by hand. Not currently used at the +// app-level since we moved to optionalAuth + requireUser per route. +void jwtAuth; + // ─── Listen ──────────────────────────────────────────────── console.log(`[cards-server] listening on :${config.port}`); diff --git a/services/cards-server/src/middleware/optional-auth.ts b/services/cards-server/src/middleware/optional-auth.ts new file mode 100644 index 000000000..f503c856f --- /dev/null +++ b/services/cards-server/src/middleware/optional-auth.ts @@ -0,0 +1,51 @@ +/** + * Optional JWT — sets `c.get('user')` when a valid Bearer token is + * present, but never rejects the request. Routes that need an + * authenticated user fall back to `null` and decide what to do + * (most public endpoints just hide private fields; mutation endpoints + * still throw 401 explicitly). + * + * Why a separate middleware? `jwtAuth` is the strict gate for write + * paths — same JWKS, same algo, but rejecting early. `optionalAuth` + * is the read-path companion: it lets cards-api.mana.how serve the + * marketplace surface to anonymous browsers (search engines, anti- + * link-rot, share-link previews) while still recognising signed-in + * users for star/follow state. + */ + +import type { MiddlewareHandler } from 'hono'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import type { AuthUser } from './jwt-auth'; + +let jwks: ReturnType | null = null; +function getJwks(authUrl: string) { + if (!jwks) jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); + return jwks; +} + +export function optionalAuth(authUrl: string): MiddlewareHandler { + return async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + await next(); + return; + } + const token = authHeader.slice(7); + try { + const { payload } = await jwtVerify(token, getJwks(authUrl), { + issuer: authUrl, + audience: 'mana', + }); + const user: AuthUser = { + userId: payload.sub || '', + email: (payload.email as string) || '', + role: (payload.role as string) || 'user', + }; + c.set('user', user); + } catch { + // Bad token = anonymous; the strict middleware rejects on + // write paths. + } + await next(); + }; +} diff --git a/services/cards-server/src/routes/authors.ts b/services/cards-server/src/routes/authors.ts index 9fde48e85..f5172c93f 100644 --- a/services/cards-server/src/routes/authors.ts +++ b/services/cards-server/src/routes/authors.ts @@ -2,7 +2,7 @@ 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'; +import { BadRequestError, UnauthorizedError } from '../lib/errors'; const upsertSchema = z.object({ slug: z.string(), @@ -12,11 +12,17 @@ const upsertSchema = z.object({ pseudonym: z.boolean().optional(), }); -export function createAuthorRoutes(authorService: AuthorService) { - const router = new Hono<{ Variables: { user: AuthUser } }>(); +function requireUser(user: AuthUser | undefined): AuthUser { + if (!user || !user.userId) throw new UnauthorizedError(); + return user; +} +export function createAuthorRoutes(authorService: AuthorService) { + const router = new Hono<{ Variables: { user?: AuthUser } }>(); + + // POST /me + GET /me are write/private — auth required. router.post('/me', async (c) => { - const user = c.get('user'); + const user = requireUser(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); @@ -24,11 +30,12 @@ export function createAuthorRoutes(authorService: AuthorService) { }); router.get('/me', async (c) => { - const user = c.get('user'); + const user = requireUser(c.get('user')); const author = await authorService.getByUserId(user.userId); return c.json(author); }); + // GET /:slug is public — anyone can look up an author profile. router.get('/:slug', async (c) => { const author = await authorService.getPublicBySlug(c.req.param('slug')); return c.json(author); diff --git a/services/cards-server/src/routes/decks.ts b/services/cards-server/src/routes/decks.ts index f8cb831f7..8b0d38605 100644 --- a/services/cards-server/src/routes/decks.ts +++ b/services/cards-server/src/routes/decks.ts @@ -3,7 +3,7 @@ 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'; +import { BadRequestError, UnauthorizedError } from '../lib/errors'; const cardTypes = [ 'basic', @@ -38,11 +38,17 @@ const publishSchema = z.object({ .max(5_000), }); -export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) { - const router = new Hono<{ Variables: { user: AuthUser } }>(); +function requireUser(user: AuthUser | undefined): AuthUser { + if (!user || !user.userId) throw new UnauthorizedError(); + return user; +} +export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) { + const router = new Hono<{ Variables: { user?: AuthUser } }>(); + + // Init = write, auth required. router.post('/', async (c) => { - const user = c.get('user'); + const user = requireUser(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()); @@ -50,13 +56,14 @@ export function createDeckRoutes(authorService: AuthorService, deckService: Deck return c.json(deck, 201); }); + // GET deck-by-slug is public — anyone can preview a deck. 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'); + const user = requireUser(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()); diff --git a/services/cards-server/src/routes/engagement.ts b/services/cards-server/src/routes/engagement.ts new file mode 100644 index 000000000..822f25043 --- /dev/null +++ b/services/cards-server/src/routes/engagement.ts @@ -0,0 +1,39 @@ +import { Hono } from 'hono'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { EngagementService } from '../services/engagement'; +import { UnauthorizedError } from '../lib/errors'; + +function requireUser(user: AuthUser | undefined): AuthUser { + if (!user || !user.userId) throw new UnauthorizedError(); + return user; +} + +export function createEngagementRoutes(service: EngagementService) { + const router = new Hono<{ Variables: { user?: AuthUser } }>(); + + router.post('/decks/:slug/star', async (c) => { + const user = requireUser(c.get('user')); + await service.starDeck(user.userId, c.req.param('slug')); + return c.json({ ok: true }); + }); + + router.delete('/decks/:slug/star', async (c) => { + const user = requireUser(c.get('user')); + await service.unstarDeck(user.userId, c.req.param('slug')); + return c.json({ ok: true }); + }); + + router.post('/authors/:slug/follow', async (c) => { + const user = requireUser(c.get('user')); + await service.followAuthor(user.userId, c.req.param('slug')); + return c.json({ ok: true }); + }); + + router.delete('/authors/:slug/follow', async (c) => { + const user = requireUser(c.get('user')); + await service.unfollowAuthor(user.userId, c.req.param('slug')); + return c.json({ ok: true }); + }); + + return router; +} diff --git a/services/cards-server/src/routes/explore.ts b/services/cards-server/src/routes/explore.ts new file mode 100644 index 000000000..f09c87e34 --- /dev/null +++ b/services/cards-server/src/routes/explore.ts @@ -0,0 +1,40 @@ +import { Hono } from 'hono'; +import type { AuthUser } from '../middleware/jwt-auth'; +import type { ExploreService, SortOption } from '../services/explore'; + +const sorts: SortOption[] = ['recent', 'popular', 'trending']; + +export function createExploreRoutes(service: ExploreService) { + const router = new Hono<{ Variables: { user?: AuthUser } }>(); + + router.get('/explore', async (c) => { + const result = await service.explore(); + return c.json(result); + }); + + router.get('/decks', async (c) => { + const url = new URL(c.req.url); + const sortParam = url.searchParams.get('sort'); + const sort = sorts.includes(sortParam as SortOption) ? (sortParam as SortOption) : 'recent'; + const limit = parseInt(url.searchParams.get('limit') ?? '20', 10); + const offset = parseInt(url.searchParams.get('offset') ?? '0', 10); + + const result = await service.browse({ + q: url.searchParams.get('q') ?? undefined, + tag: url.searchParams.get('tag') ?? undefined, + language: url.searchParams.get('lang') ?? undefined, + authorSlug: url.searchParams.get('author') ?? undefined, + sort, + limit, + offset, + }); + return c.json(result); + }); + + router.get('/tags', async (c) => { + const tree = await service.tagTree(); + return c.json(tree); + }); + + return router; +} diff --git a/services/cards-server/src/services/engagement.ts b/services/cards-server/src/services/engagement.ts new file mode 100644 index 000000000..749547925 --- /dev/null +++ b/services/cards-server/src/services/engagement.ts @@ -0,0 +1,79 @@ +/** + * Star + Follow primitives. Both are idempotent and safe to retry. + */ + +import { and, eq } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { authorFollows, authors, deckStars, publicDecks } from '../db/schema'; +import { ConflictError, NotFoundError } from '../lib/errors'; + +export class EngagementService { + constructor(private readonly db: Database) {} + + async starDeck(userId: string, deckSlug: string) { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, deckSlug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + await this.db.insert(deckStars).values({ userId, deckId: deck.id }).onConflictDoNothing(); + } + + async unstarDeck(userId: string, deckSlug: string) { + const deck = await this.db.query.publicDecks.findFirst({ + where: eq(publicDecks.slug, deckSlug), + }); + if (!deck) throw new NotFoundError('Deck not found'); + await this.db + .delete(deckStars) + .where(and(eq(deckStars.userId, userId), eq(deckStars.deckId, deck.id))); + } + + async isDeckStarred(userId: string, deckSlug: string): Promise { + const row = await this.db + .select({ id: deckStars.deckId }) + .from(deckStars) + .innerJoin(publicDecks, eq(publicDecks.id, deckStars.deckId)) + .where(and(eq(deckStars.userId, userId), eq(publicDecks.slug, deckSlug))) + .limit(1); + return row.length > 0; + } + + async followAuthor(followerUserId: string, authorSlug: string) { + const author = await this.db.query.authors.findFirst({ + where: eq(authors.slug, authorSlug), + }); + if (!author) throw new NotFoundError('Author not found'); + if (author.userId === followerUserId) { + throw new ConflictError('You cannot follow yourself'); + } + await this.db + .insert(authorFollows) + .values({ followerUserId, authorUserId: author.userId }) + .onConflictDoNothing(); + } + + async unfollowAuthor(followerUserId: string, authorSlug: string) { + const author = await this.db.query.authors.findFirst({ + where: eq(authors.slug, authorSlug), + }); + if (!author) throw new NotFoundError('Author not found'); + await this.db + .delete(authorFollows) + .where( + and( + eq(authorFollows.followerUserId, followerUserId), + eq(authorFollows.authorUserId, author.userId) + ) + ); + } + + async isFollowing(followerUserId: string, authorSlug: string): Promise { + const row = await this.db + .select({ id: authorFollows.authorUserId }) + .from(authorFollows) + .innerJoin(authors, eq(authors.userId, authorFollows.authorUserId)) + .where(and(eq(authorFollows.followerUserId, followerUserId), eq(authors.slug, authorSlug))) + .limit(1); + return row.length > 0; + } +} diff --git a/services/cards-server/src/services/explore.ts b/services/cards-server/src/services/explore.ts new file mode 100644 index 000000000..f1c846c9d --- /dev/null +++ b/services/cards-server/src/services/explore.ts @@ -0,0 +1,195 @@ +/** + * Discovery service — browse, search, featured, trending, per-author + * deck lists, tag hierarchy. Pure read-only. + * + * Search uses Postgres `to_tsvector` over (title, description) so we + * don't depend on a separate index for Phase γ; Meilisearch lands in + * Phase ι if/when this becomes the bottleneck. Trending = simple + * recent-stars-velocity over the last 7 days; gamed at small N, fine + * once volume picks up — replaceable without API changes. + */ + +import { and, desc, eq, gte, ilike, isNull, or, sql, count } from 'drizzle-orm'; +import type { Database } from '../db/connection'; +import { + authors, + deckStars, + deckSubscriptions, + deckTags, + publicDecks, + publicDeckVersions, + tagDefinitions, +} from '../db/schema'; + +export interface DeckListEntry { + slug: string; + title: string; + description: string | null; + language: string | null; + license: string; + priceCredits: number; + cardCount: number; + starCount: number; + subscriberCount: number; + isFeatured: boolean; + createdAt: Date; + owner: { slug: string; displayName: string; verifiedMana: boolean; verifiedCommunity: boolean }; +} + +const SORT_OPTIONS = ['recent', 'popular', 'trending'] as const; +export type SortOption = (typeof SORT_OPTIONS)[number]; + +export interface BrowseFilter { + q?: string; + tag?: string; + language?: string; + authorSlug?: string; + sort?: SortOption; + limit?: number; + offset?: number; +} + +export class ExploreService { + constructor(private readonly db: Database) {} + + async browse(filter: BrowseFilter): Promise<{ items: DeckListEntry[]; total: number }> { + const limit = Math.min(filter.limit ?? 20, 100); + const offset = filter.offset ?? 0; + const sort = filter.sort ?? 'recent'; + + // Base join: deck × owner-author × latest-version. We hit + // Drizzle's relational query API for predictable joins instead + // of building a giant select-with-joins by hand. + const conditions = [eq(publicDecks.isTakedown, false)]; + if (filter.language) conditions.push(eq(publicDecks.language, filter.language)); + if (filter.q) { + conditions.push( + or( + ilike(publicDecks.title, `%${filter.q}%`), + ilike(publicDecks.description, `%${filter.q}%`) + )! + ); + } + if (filter.authorSlug) { + conditions.push( + eq( + publicDecks.ownerUserId, + sql`(SELECT user_id FROM cards.authors WHERE slug = ${filter.authorSlug} LIMIT 1)` + ) + ); + } + if (filter.tag) { + conditions.push( + sql`EXISTS (SELECT 1 FROM cards.deck_tags dt JOIN cards.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})` + ); + } + + // Pre-compute counts via subqueries; avoids N+1. + const starCount = sql`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`; + const subscriberCount = sql`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`; + const cardCountExpr = sql`COALESCE((SELECT v.card_count FROM cards.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`; + + const sortClause = + sort === 'popular' + ? desc(starCount) + : sort === 'trending' + ? desc( + sql`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id} AND s.starred_at >= now() - interval '7 days')` + ) + : desc(publicDecks.createdAt); + + const baseQuery = this.db + .select({ + slug: publicDecks.slug, + title: publicDecks.title, + description: publicDecks.description, + language: publicDecks.language, + license: publicDecks.license, + priceCredits: publicDecks.priceCredits, + cardCount: cardCountExpr, + starCount, + subscriberCount, + isFeatured: publicDecks.isFeatured, + createdAt: publicDecks.createdAt, + ownerSlug: authors.slug, + ownerDisplayName: authors.displayName, + ownerVerifiedMana: authors.verifiedMana, + ownerVerifiedCommunity: authors.verifiedCommunity, + }) + .from(publicDecks) + .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) + .where(and(...conditions)) + .orderBy(sortClause) + .limit(limit) + .offset(offset); + + const totalQuery = this.db + .select({ value: count() }) + .from(publicDecks) + .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId)) + .where(and(...conditions)); + + const [rows, totalResult] = await Promise.all([baseQuery, totalQuery]); + + return { + items: rows.map((r) => ({ + slug: r.slug, + title: r.title, + description: r.description, + language: r.language, + license: r.license, + priceCredits: r.priceCredits, + cardCount: Number(r.cardCount), + starCount: Number(r.starCount), + subscriberCount: Number(r.subscriberCount), + isFeatured: r.isFeatured, + createdAt: r.createdAt, + owner: { + slug: r.ownerSlug, + displayName: r.ownerDisplayName, + verifiedMana: r.ownerVerifiedMana, + verifiedCommunity: r.ownerVerifiedCommunity, + }, + })), + total: totalResult[0]?.value ?? 0, + }; + } + + /** Featured + Trending side-by-side for the /explore landing. */ + async explore(): Promise<{ featured: DeckListEntry[]; trending: DeckListEntry[] }> { + const [featuredResult, trendingResult] = await Promise.all([ + this.browse({ sort: 'popular', limit: 8 }).then((r) => + r.items.filter((d) => d.isFeatured).slice(0, 8) + ), + this.browse({ sort: 'trending', limit: 8 }), + ]); + return { featured: featuredResult, trending: trendingResult.items }; + } + + async tagTree() { + const rows = await this.db + .select() + .from(tagDefinitions) + .orderBy(tagDefinitions.parentId, tagDefinitions.name); + return rows; + } + + async curatedTagsOnly() { + return this.db + .select() + .from(tagDefinitions) + .where(eq(tagDefinitions.curated, true)) + .orderBy(tagDefinitions.name); + } + + // Silence unused-binding lint for imports that downstream queries + // will pull in. + _keepAlive() { + void deckSubscriptions; + void deckStars; + void deckTags; + void publicDeckVersions; + void isNull; + void gte; + } +}