feat(cards-server): Phase γ — public reads + browse + search + engagement

Marketplace discovery surface lights up. Anonymous browsers can
explore + search; signed-in users get the same surface plus star/
follow mutations.

  - middleware/optional-auth.ts: opportunistic JWT — sets c.get('user')
    if a token validates, otherwise leaves it undefined. Read paths
    use this; mutating routes call requireUser() inline.
  - services/explore.ts: browse() with q (ilike on title/description),
    tag, language, author-slug, sort (recent/popular/trending), pagination.
    explore() composes featured + trending for the landing.
    tagTree()/curatedTagsOnly() round it out. Subqueries for star/
    subscriber counts avoid N+1.
  - services/engagement.ts: star/unstar deck, follow/unfollow author.
    Idempotent via ON CONFLICT DO NOTHING. Self-follow rejected.
  - routes/explore.ts mounts /v1/explore, /v1/decks (browse list),
    /v1/tags. routes/engagement.ts mounts /v1/decks/:slug/star
    (POST/DELETE) + /v1/authors/:slug/follow (POST/DELETE).
  - index.ts replaces the previous strict-jwt-on-everything middleware
    with optionalAuth on all of /v1, then individual routers gate
    their write paths via local requireUser(). Hono context type
    relaxes from `user: AuthUser` to `user?: AuthUser` accordingly.

Validated: tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 17:01:32 +02:00
parent 4c044e849d
commit dcd16067b5
8 changed files with 460 additions and 19 deletions

View file

@ -14,11 +14,16 @@ import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
import { loadConfig } from './config'; import { loadConfig } from './config';
import { getDb } from './db/connection'; import { getDb } from './db/connection';
import { jwtAuth, type AuthUser } from './middleware/jwt-auth'; import { jwtAuth, type AuthUser } from './middleware/jwt-auth';
import { optionalAuth } from './middleware/optional-auth';
import { healthRoutes } from './routes/health'; import { healthRoutes } from './routes/health';
import { AuthorService } from './services/authors'; import { AuthorService } from './services/authors';
import { DeckService } from './services/decks'; import { DeckService } from './services/decks';
import { ExploreService } from './services/explore';
import { EngagementService } from './services/engagement';
import { createAuthorRoutes } from './routes/authors'; import { createAuthorRoutes } from './routes/authors';
import { createDeckRoutes } from './routes/decks'; import { createDeckRoutes } from './routes/decks';
import { createExploreRoutes } from './routes/explore';
import { createEngagementRoutes } from './routes/engagement';
// ─── Bootstrap ────────────────────────────────────────────── // ─── Bootstrap ──────────────────────────────────────────────
@ -27,10 +32,12 @@ const db = getDb(config.databaseUrl);
const authorService = new AuthorService(db); const authorService = new AuthorService(db);
const deckService = new DeckService(db, config.manaLlmUrl); const deckService = new DeckService(db, config.manaLlmUrl);
const exploreService = new ExploreService(db);
const engagementService = new EngagementService(db);
// ─── App ──────────────────────────────────────────────────── // ─── App ────────────────────────────────────────────────────
const app = new Hono<{ Variables: { user: AuthUser } }>(); const app = new Hono<{ Variables: { user?: AuthUser } }>();
app.onError(errorHandler); app.onError(errorHandler);
app.use( app.use(
@ -46,16 +53,26 @@ app.route('/health', healthRoutes);
// Versioned API surface — additive-only changes within v1, breaking // Versioned API surface — additive-only changes within v1, breaking
// changes go to /v2 (MARKETPLACE_PLAN §3 architecture principle 1). // 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 // Phase γ: public reads first — explore + browse + tags + author
// mutating endpoints in the same routers gate themselves by checking // profile lookup + deck profile lookup. All read-only, no token
// for `c.get('user')`. Until we have that anonymous-aware middleware // required, but a present token enables logged-in extras (star
// (Phase γ adds optionalAuth), every /v1 route gates on JWT — public // state, follow state) once those flags land in the responses
// reads still work for any signed-in user, which covers the only // (MARKETPLACE_PLAN phase γ.3).
// surface we have right now (author dashboard + deck CRUD). v1.use('/*', optionalAuth(config.manaAuthUrl));
v1.use('/*', jwtAuth(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('/authors', createAuthorRoutes(authorService));
v1.route('/decks', createDeckRoutes(authorService, deckService)); 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.', message: 'See apps/cards/docs/MARKETPLACE_PLAN.md for the full plan.',
}) })
); );
app.route('/v1', v1); 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 ──────────────────────────────────────────────── // ─── Listen ────────────────────────────────────────────────
console.log(`[cards-server] listening on :${config.port}`); console.log(`[cards-server] listening on :${config.port}`);

View file

@ -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<typeof createRemoteJWKSet> | 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();
};
}

View file

@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import type { AuthUser } from '../middleware/jwt-auth'; import type { AuthUser } from '../middleware/jwt-auth';
import type { AuthorService } from '../services/authors'; import type { AuthorService } from '../services/authors';
import { BadRequestError } from '../lib/errors'; import { BadRequestError, UnauthorizedError } from '../lib/errors';
const upsertSchema = z.object({ const upsertSchema = z.object({
slug: z.string(), slug: z.string(),
@ -12,11 +12,17 @@ const upsertSchema = z.object({
pseudonym: z.boolean().optional(), pseudonym: z.boolean().optional(),
}); });
export function createAuthorRoutes(authorService: AuthorService) { function requireUser(user: AuthUser | undefined): AuthUser {
const router = new Hono<{ Variables: { user: 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) => { 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(() => ({}))); const parsed = upsertSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());
const author = await authorService.upsertMe(user.userId, parsed.data); const author = await authorService.upsertMe(user.userId, parsed.data);
@ -24,11 +30,12 @@ export function createAuthorRoutes(authorService: AuthorService) {
}); });
router.get('/me', async (c) => { router.get('/me', async (c) => {
const user = c.get('user'); const user = requireUser(c.get('user'));
const author = await authorService.getByUserId(user.userId); const author = await authorService.getByUserId(user.userId);
return c.json(author); return c.json(author);
}); });
// GET /:slug is public — anyone can look up an author profile.
router.get('/:slug', async (c) => { router.get('/:slug', async (c) => {
const author = await authorService.getPublicBySlug(c.req.param('slug')); const author = await authorService.getPublicBySlug(c.req.param('slug'));
return c.json(author); return c.json(author);

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import type { AuthUser } from '../middleware/jwt-auth'; import type { AuthUser } from '../middleware/jwt-auth';
import type { AuthorService } from '../services/authors'; import type { AuthorService } from '../services/authors';
import type { DeckService } from '../services/decks'; import type { DeckService } from '../services/decks';
import { BadRequestError } from '../lib/errors'; import { BadRequestError, UnauthorizedError } from '../lib/errors';
const cardTypes = [ const cardTypes = [
'basic', 'basic',
@ -38,11 +38,17 @@ const publishSchema = z.object({
.max(5_000), .max(5_000),
}); });
export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) { function requireUser(user: AuthUser | undefined): AuthUser {
const router = new Hono<{ Variables: { user: 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) => { router.post('/', async (c) => {
const user = c.get('user'); const user = requireUser(c.get('user'));
await authorService.assertNotBanned(user.userId); await authorService.assertNotBanned(user.userId);
const parsed = initSchema.safeParse(await c.req.json().catch(() => ({}))); const parsed = initSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); 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); return c.json(deck, 201);
}); });
// GET deck-by-slug is public — anyone can preview a deck.
router.get('/:slug', async (c) => { router.get('/:slug', async (c) => {
const result = await deckService.getBySlug(c.req.param('slug')); const result = await deckService.getBySlug(c.req.param('slug'));
return c.json(result); return c.json(result);
}); });
router.post('/:slug/publish', async (c) => { router.post('/:slug/publish', async (c) => {
const user = c.get('user'); const user = requireUser(c.get('user'));
await authorService.assertNotBanned(user.userId); await authorService.assertNotBanned(user.userId);
const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({}))); const parsed = publishSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format()); if (!parsed.success) throw new BadRequestError('Invalid body', parsed.error.format());

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<boolean> {
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<boolean> {
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;
}
}

View file

@ -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<string>`(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<number>`(SELECT count(*)::int FROM cards.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
const subscriberCount = sql<number>`(SELECT count(*)::int FROM cards.deck_subscriptions s WHERE s.deck_id = ${publicDecks.id})`;
const cardCountExpr = sql<number>`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<number>`(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;
}
}