import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { decodeCursor, encodeCursor, envelope, problem, sendList } from '../lib/envelope.js'; import * as q from '../lib/queries.js'; import { openapiDocument } from '../openapi.js'; const Paging = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), cursor: z.string().optional(), format: z.enum(['json', 'csv']).optional() }); const Range = z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), format: z.enum(['json', 'csv']).optional() }); function parse(schema: z.ZodType, input: unknown): { ok: true; data: T } | { ok: false; error: string } { const r = schema.safeParse(input); return r.success ? { ok: true, data: r.data } : { ok: false, error: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') }; } export async function v1Routes(app: FastifyInstance) { app.get('/v1/openapi.json', async () => openapiDocument()); app.get('/v1/stats', async () => envelope(await q.platformStats())); app.get('/v1/assets/search', async (req, reply) => { const p = parse(Paging.extend({ q: z.string().max(200).optional(), category: z.string().max(80).optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const offset = decodeCursor(p.data.cursor); const rows = await q.searchAssets({ q: p.data.q, category: p.data.category, limit: p.data.limit + 1, offset }); const page = rows.slice(0, p.data.limit); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, query: p.data.q ?? null }, req.query as Record); }); app.get<{ Params: { id: string } }>('/v1/assets/:id', async (req, reply) => { const asset = await q.getAsset(req.params.id); if (!asset) return problem(reply, 404, 'Asset not found'); reply.header('cache-control', 'public, max-age=60, s-maxage=300'); return envelope(asset); }); app.get<{ Params: { id: string } }>('/v1/assets/:id/sales', async (req, reply) => { const p = parse(Paging.extend({ variant: z.string().optional(), include_flagged: z.coerce.boolean().optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const asset = await q.getAsset(req.params.id); if (!asset) return problem(reply, 404, 'Asset not found'); const offset = decodeCursor(p.data.cursor); const rows = await q.assetSales(asset.id as string, { limit: p.data.limit + 1, offset, variantId: p.data.variant, includeFlagged: p.data.include_flagged }); const page = rows.slice(0, p.data.limit); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id }, req.query as Record); }); app.get<{ Params: { id: string } }>('/v1/assets/:id/listings', async (req, reply) => { const p = parse(Paging.extend({ availability: z.enum(['available', 'sold', 'ended', 'removed']).optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const asset = await q.getAsset(req.params.id); if (!asset) return problem(reply, 404, 'Asset not found'); const offset = decodeCursor(p.data.cursor); const rows = await q.assetListings(asset.id as string, { limit: p.data.limit + 1, offset, availability: p.data.availability }); const page = rows.slice(0, p.data.limit); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'Listing prices are asks, not confirmed transactions.' }, req.query as Record); }); app.get<{ Params: { id: string } }>('/v1/assets/:id/depth', async (req, reply) => { const p = parse(z.object({ variant: z.string().optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const asset = await q.getAsset(req.params.id); if (!asset) return problem(reply, 404, 'Asset not found'); const data = await q.assetDepth(asset.id as string, p.data.variant); if (!data) return problem(reply, 404, 'Asset not found'); reply.header('cache-control', 'public, max-age=120, s-maxage=300'); return reply.send(envelope(data, { asset_id: asset.id })); }); app.get<{ Params: { id: string } }>('/v1/assets/:id/auctions', async (req, reply) => { const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const asset = await q.getAsset(req.params.id); if (!asset) return problem(reply, 404, 'Asset not found'); const offset = decodeCursor(p.data.cursor); const rows = await q.auctionLots({ assetId: asset.id as string, status: p.data.status, limit: p.data.limit + 1, offset }); const page = rows.slice(0, p.data.limit); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'all_in_* = hammer + estimated buyer premium (see fee_basis); taxes, duties and shipping excluded. Bids are not confirmed transactions.' }, req.query as Record); }); app.get('/v1/auctions/lots', async (req, reply) => { const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).optional(), ending_within_hours: z.coerce.number().int().min(1).max(24 * 90).optional(), category: z.string().max(80).optional(), house: z.string().max(120).optional(), below_riv: z.coerce.boolean().optional(), asset: z.string().max(80).optional(), sort: z.enum(['ending', 'discount']).optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const offset = decodeCursor(p.data.cursor); let assetId: string | undefined; if (p.data.asset) { const asset = await q.getAsset(p.data.asset); if (!asset) return problem(reply, 404, 'Asset not found'); assetId = asset.id as string; } const rows = await q.auctionLots({ status: p.data.status, endingWithinHours: p.data.ending_within_hours, category: p.data.category, house: p.data.house, belowRiv: p.data.below_riv, assetId, sort: p.data.sort, limit: p.data.limit + 1, offset }); const page = rows.slice(0, p.data.limit); reply.header('cache-control', 'public, max-age=60, s-maxage=120'); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, note: 'all_in_* = hammer + estimated buyer premium (see fee_basis); taxes, duties and shipping excluded. bid_vs_riv = (all-in bid − RIV) / RIV, null when the comparison did not pass the gates. Analytical data, not advice.' }, req.query as Record); }); app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => { const p = parse(Range.extend({ variant: z.string().optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const asset = await q.getAsset(req.params.id); if (!asset) return problem(reply, 404, 'Asset not found'); const rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to }); reply.header('cache-control', 'public, max-age=300'); return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record); }); app.get('/v1/categories', async (req, reply) => { const rows = await q.listCategories(); reply.header('cache-control', 'public, max-age=3600'); return sendList(reply, rows, { count: rows.length }, req.query as Record); }); app.get('/v1/indices', async (req, reply) => { const rows = await q.listIndices(); const shaped = rows.map((r) => { const v = r.value as number | null; const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null); return { ...r, change_1d: pct(r.value_1d), change_7d: pct(r.value_7d), change_30d: pct(r.value_30d), change_1y: pct(r.value_1y), change_ytd: pct(r.value_ytd), published: v !== null }; }); reply.header('cache-control', 'public, max-age=300'); return sendList(reply, shaped, { count: shaped.length, note: 'An index is published only once it has enough priced constituents; unpublished indices report value=null.' }, req.query as Record); }); app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => { const p = parse(Range, req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data); reply.header('cache-control', 'public, max-age=300'); return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record); }); app.get('/v1/markets', async (req, reply) => { const rows = await q.listMarkets(); reply.header('cache-control', 'public, max-age=300'); return sendList(reply, rows, { count: rows.length }, req.query as Record); }); app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => { const m = await q.getMarket(req.params.slug); if (!m) return problem(reply, 404, 'Market not found'); reply.header('cache-control', 'public, max-age=300'); return envelope(m); }); app.get('/v1/trending', async (req, reply) => { const p = parse(Paging.extend({ category: z.string().optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const offset = decodeCursor(p.data.cursor); const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset }); const page = rows.slice(0, p.data.limit); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record); }); app.get('/v1/sales/latest', async (req, reply) => { const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query); if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); const offset = decodeCursor(p.data.cursor); const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset }); const page = rows.slice(0, p.data.limit); return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record); }); app.get('/v1/records', async (req, reply) => { const rows = await q.recordSales(); reply.header('cache-control', 'public, max-age=600'); return sendList(reply, rows, { count: rows.length, note: 'Highest verified transaction per family; bundles and low-confidence records excluded.' }, req.query as Record); }); }