TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { FastifyInstance } from 'fastify';2import { z } from 'zod';3import { decodeCursor, encodeCursor, envelope, problem, sendList } from '../lib/envelope.js';4import * as q from '../lib/queries.js';5import { openapiDocument } from '../openapi.js';67const Paging = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), cursor: z.string().optional(), format: z.enum(['json', 'csv']).optional() });8const 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() });910function parse<T>(schema: z.ZodType<T>, input: unknown): { ok: true; data: T } | { ok: false; error: string } {11 const r = schema.safeParse(input);12 return r.success ? { ok: true, data: r.data } : { ok: false, error: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') };13}1415export async function v1Routes(app: FastifyInstance) {16 app.get('/v1/openapi.json', async () => openapiDocument());1718 app.get('/v1/stats', async () => envelope(await q.platformStats()));1920 app.get('/v1/assets/search', async (req, reply) => {21 const p = parse(Paging.extend({ q: z.string().max(200).optional(), category: z.string().max(80).optional() }), req.query);22 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);23 const offset = decodeCursor(p.data.cursor);24 const rows = await q.searchAssets({ q: p.data.q, category: p.data.category, limit: p.data.limit + 1, offset });25 const page = rows.slice(0, p.data.limit);26 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<string, unknown>);27 });2829 app.get<{ Params: { id: string } }>('/v1/assets/:id', async (req, reply) => {30 const asset = await q.getAsset(req.params.id);31 if (!asset) return problem(reply, 404, 'Asset not found');32 reply.header('cache-control', 'public, max-age=60, s-maxage=300');33 return envelope(asset);34 });3536 app.get<{ Params: { id: string } }>('/v1/assets/:id/sales', async (req, reply) => {37 const p = parse(Paging.extend({ variant: z.string().optional(), include_flagged: z.coerce.boolean().optional() }), req.query);38 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);39 const asset = await q.getAsset(req.params.id);40 if (!asset) return problem(reply, 404, 'Asset not found');41 const offset = decodeCursor(p.data.cursor);42 const rows = await q.assetSales(asset.id as string, { limit: p.data.limit + 1, offset, variantId: p.data.variant, includeFlagged: p.data.include_flagged });43 const page = rows.slice(0, p.data.limit);44 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<string, unknown>);45 });4647 app.get<{ Params: { id: string } }>('/v1/assets/:id/listings', async (req, reply) => {48 const p = parse(Paging.extend({ availability: z.enum(['available', 'sold', 'ended', 'removed']).optional() }), req.query);49 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);50 const asset = await q.getAsset(req.params.id);51 if (!asset) return problem(reply, 404, 'Asset not found');52 const offset = decodeCursor(p.data.cursor);53 const rows = await q.assetListings(asset.id as string, { limit: p.data.limit + 1, offset, availability: p.data.availability });54 const page = rows.slice(0, p.data.limit);55 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<string, unknown>);56 });5758 app.get<{ Params: { id: string } }>('/v1/assets/:id/depth', async (req, reply) => {59 const p = parse(z.object({ variant: z.string().optional() }), req.query);60 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);61 const asset = await q.getAsset(req.params.id);62 if (!asset) return problem(reply, 404, 'Asset not found');63 const data = await q.assetDepth(asset.id as string, p.data.variant);64 if (!data) return problem(reply, 404, 'Asset not found');65 reply.header('cache-control', 'public, max-age=120, s-maxage=300');66 return reply.send(envelope(data, { asset_id: asset.id }));67 });6869 app.get<{ Params: { id: string } }>('/v1/assets/:id/auctions', async (req, reply) => {70 const p = parse(Paging.extend({ status: z.enum(['live', 'upcoming', 'ended']).optional() }), req.query);71 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);72 const asset = await q.getAsset(req.params.id);73 if (!asset) return problem(reply, 404, 'Asset not found');74 const offset = decodeCursor(p.data.cursor);75 const rows = await q.auctionLots({ assetId: asset.id as string, status: p.data.status, limit: p.data.limit + 1, offset });76 const page = rows.slice(0, p.data.limit);77 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<string, unknown>);78 });7980 app.get('/v1/auctions/lots', async (req, reply) => {81 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);82 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);83 const offset = decodeCursor(p.data.cursor);84 let assetId: string | undefined;85 if (p.data.asset) {86 const asset = await q.getAsset(p.data.asset);87 if (!asset) return problem(reply, 404, 'Asset not found');88 assetId = asset.id as string;89 }90 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 });91 const page = rows.slice(0, p.data.limit);92 reply.header('cache-control', 'public, max-age=60, s-maxage=120');93 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<string, unknown>);94 });9596 app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => {97 const p = parse(Range.extend({ variant: z.string().optional() }), req.query);98 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);99 const asset = await q.getAsset(req.params.id);100 if (!asset) return problem(reply, 404, 'Asset not found');101 const rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to });102 reply.header('cache-control', 'public, max-age=300');103 return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>);104 });105106 app.get('/v1/categories', async (req, reply) => {107 const rows = await q.listCategories();108 reply.header('cache-control', 'public, max-age=3600');109 return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);110 });111112 app.get('/v1/indices', async (req, reply) => {113 const rows = await q.listIndices();114 const shaped = rows.map((r) => {115 const v = r.value as number | null;116 const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null);117 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 };118 });119 reply.header('cache-control', 'public, max-age=300');120 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<string, unknown>);121 });122123 app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => {124 const p = parse(Range, req.query);125 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);126 const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data);127 reply.header('cache-control', 'public, max-age=300');128 return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>);129 });130131 app.get('/v1/markets', async (req, reply) => {132 const rows = await q.listMarkets();133 reply.header('cache-control', 'public, max-age=300');134 return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);135 });136137 app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => {138 const m = await q.getMarket(req.params.slug);139 if (!m) return problem(reply, 404, 'Market not found');140 reply.header('cache-control', 'public, max-age=300');141 return envelope(m);142 });143144 app.get('/v1/trending', async (req, reply) => {145 const p = parse(Paging.extend({ category: z.string().optional() }), req.query);146 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);147 const offset = decodeCursor(p.data.cursor);148 const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset });149 const page = rows.slice(0, p.data.limit);150 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>);151 });152153 app.get('/v1/sales/latest', async (req, reply) => {154 const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query);155 if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);156 const offset = decodeCursor(p.data.cursor);157 const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset });158 const page = rows.slice(0, p.data.limit);159 return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>);160 });161162 app.get('/v1/records', async (req, reply) => {163 const rows = await q.recordSales();164 reply.header('cache-control', 'public, max-age=600');165 return sendList(reply, rows, { count: rows.length, note: 'Highest verified transaction per family; bundles and low-confidence records excluded.' }, req.query as Record<string, unknown>);166 });167}168