SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%

AI package, scanner, research, admin console, public API (agent F2)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 17 days ago (Sep 7, 2026) parent ea481cc

76 changed files +7,807 −7

modified apps/api/package.json +33 −1
@@ -1 +1,33 @@
1 {"name":"@rareindex/api","version":"0.1.0","private":true,"type":"module","scripts":{"typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run --passWithNoTests","build":"tsc -p tsconfig.json --noEmit"},"dependencies":{"@rareindex/shared":"workspace:*"},"devDependencies":{"@types/node":"^24.0.0","typescript":"^5.9.3","vitest":"^3.2.0"}}
1 +{
2 + "name": "@rareindex/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/server.ts",
8 + "start": "tsx src/server.ts",
9 + "build": "tsc -p tsconfig.json --noEmit",
10 + "typecheck": "tsc -p tsconfig.json --noEmit",
11 + "test": "vitest run --passWithNoTests",
12 + "create-key": "tsx scripts/create-key.ts"
13 + },
14 + "dependencies": {
15 + "@fastify/compress": "^9.0.0",
16 + "@fastify/cors": "^11.0.0",
17 + "@fastify/etag": "^6.0.0",
18 + "@fastify/rate-limit": "^11.0.0",
19 + "@rareindex/connectors": "workspace:*",
20 + "@rareindex/database": "workspace:*",
21 + "@rareindex/shared": "workspace:*",
22 + "@rareindex/taxonomy": "workspace:*",
23 + "fastify": "^5.6.0",
24 + "fastify-plugin": "^6.0.0",
25 + "zod": "^4.0.0"
26 + },
27 + "devDependencies": {
28 + "@types/node": "^24.0.0",
29 + "tsx": "^4.20.0",
30 + "typescript": "^5.9.3",
31 + "vitest": "^3.2.0"
32 + }
33 +}
added apps/api/scripts/create-key.ts +21 −0
@@ -0,0 +1,21 @@
1 +/**
2 + * Mint an API key. Prints the plaintext ONCE; only its hash is stored.
3 + * pnpm --filter @rareindex/api create-key -- --name "Research desk" --tier professional [--user usr_xxx]
4 + */
5 +import { closeDb } from '@rareindex/database';
6 +import { mintApiKey, TIERS, type Tier } from '../src/lib/keys.js';
7 +
8 +const args = process.argv.slice(2);
9 +const get = (flag: string) => {
10 + const i = args.indexOf(flag);
11 + return i >= 0 ? args[i + 1] : undefined;
12 +};
13 +const name = get('--name') ?? 'default';
14 +const tier = (get('--tier') ?? 'free') as Tier;
15 +if (!(tier in TIERS)) {
16 + console.error(`unknown tier ${tier}; choose one of ${Object.keys(TIERS).join(', ')}`);
17 + process.exit(1);
18 +}
19 +const minted = await mintApiKey({ name, tier, userId: get('--user') ?? null });
20 +console.log(`API key created (${minted.tier}, id ${minted.id}). Store it now — it will not be shown again:\n\n ${minted.key}\n`);
21 +await closeDb();
added apps/api/scripts/gen-openapi.ts +9 −0
@@ -0,0 +1,9 @@
1 +import { writeFileSync } from 'node:fs';
2 +import path from 'node:path';
3 +import { fileURLToPath } from 'node:url';
4 +import { openapiDocument } from '../src/openapi.js';
5 +
6 +const here = path.dirname(fileURLToPath(import.meta.url));
7 +const out = path.resolve(here, '../../../docs/openapi.json');
8 +writeFileSync(out, JSON.stringify(openapiDocument(), null, 2) + '\n');
9 +console.log(`wrote ${out}`);
added apps/api/src/app.test.ts +97 −0
@@ -0,0 +1,97 @@
1 +import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2 +import type { FastifyInstance } from 'fastify';
3 +import { closeDb, getDb, apiKeys, eq } from '@rareindex/database';
4 +import { buildApp } from './app.js';
5 +import { mintApiKey } from './lib/keys.js';
6 +import { toCsv, encodeCursor, decodeCursor } from './lib/envelope.js';
7 +
8 +/** Integration tests against DATABASE_URL (rareindex_ai locally). They only read except for a temporary API key. */
9 +let app: FastifyInstance;
10 +let key: { id: string; key: string };
11 +
12 +beforeAll(async () => {
13 + process.env.LOG_LEVEL = 'silent';
14 + app = await buildApp({ logger: false });
15 + key = await mintApiKey({ name: 'vitest', tier: 'hobby' });
16 +});
17 +afterAll(async () => {
18 + await getDb().delete(apiKeys).where(eq(apiKeys.id, key.id));
19 + await app.close();
20 + await closeDb();
21 +});
22 +
23 +describe('api', () => {
24 + it('health and root', async () => {
25 + const h = await app.inject({ method: 'GET', url: '/healthz' });
26 + expect(h.statusCode).toBe(200);
27 + expect(h.json().ok).toBe(true);
28 + const r = await app.inject({ method: 'GET', url: '/' });
29 + expect(r.json().openapi).toBe('/v1/openapi.json');
30 + });
31 + it('serves openapi', async () => {
32 + const r = await app.inject({ method: 'GET', url: '/v1/openapi.json' });
33 + expect(r.statusCode).toBe(200);
34 + expect(r.json().paths['/v1/assets/search']).toBeTruthy();
35 + });
36 + it('anonymous requests use the public tier with rate-limit headers', async () => {
37 + const r = await app.inject({ method: 'GET', url: '/v1/categories' });
38 + expect(r.statusCode).toBe(200);
39 + expect(r.headers['x-ratelimit-limit']).toBe('20');
40 + expect(r.headers['x-request-id']).toBeTruthy();
41 + const body = r.json();
42 + expect(body.meta.count).toBeGreaterThan(100);
43 + expect(body.meta.attribution).toMatch(/RareIndex/);
44 + expect(body.data.find((c: { slug: string }) => c.slug === 'pokemon')).toBeTruthy();
45 + });
46 + it('rejects unknown keys and accepts minted keys with tier limits', async () => {
47 + const bad = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: 'Bearer ri_live_nope_nope' } });
48 + expect(bad.statusCode).toBe(401);
49 + expect(bad.headers['content-type']).toMatch(/problem\+json/);
50 + const good = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: `Bearer ${key.key}` } });
51 + expect(good.statusCode).toBe(200);
52 + expect(good.headers['x-ratelimit-limit']).toBe('120');
53 + const idx = good.json();
54 + expect(idx.data.find((i: { ticker: string }) => i.ticker === 'RARE')).toBeTruthy();
55 + expect(idx.data.every((i: { published: boolean; value: unknown }) => i.published === (i.value !== null))).toBe(true);
56 + });
57 + it('validates queries and returns problem+json', async () => {
58 + const r = await app.inject({ method: 'GET', url: '/v1/assets/search?limit=9999' });
59 + expect(r.statusCode).toBe(400);
60 + expect(r.json().title).toBe('Invalid query');
61 + });
62 + it('404 on unknown asset/market', async () => {
63 + expect((await app.inject({ method: 'GET', url: '/v1/assets/does-not-exist' })).statusCode).toBe(404);
64 + expect((await app.inject({ method: 'GET', url: '/v1/markets/does-not-exist' })).statusCode).toBe(404);
65 + });
66 + it('search, trending, sales, records, markets and csv export respond', async () => {
67 + for (const url of ['/v1/assets/search?q=charizard', '/v1/trending', '/v1/sales/latest', '/v1/records', '/v1/markets', '/v1/markets/pokemon', '/v1/indices/RARE-TCG/history', '/v1/stats']) {
68 + const r = await app.inject({ method: 'GET', url });
69 + expect(r.statusCode, url).toBe(200);
70 + }
71 + const csv = await app.inject({ method: 'GET', url: '/v1/categories?format=csv' });
72 + expect(csv.headers['content-type']).toMatch(/text\/csv/);
73 + expect(csv.body.split('\n')[0]).toContain('slug');
74 + });
75 + it('rate limits by key', async () => {
76 + const small = await mintApiKey({ name: 'vitest-small', tier: 'free' });
77 + try {
78 + let last = 200;
79 + for (let i = 0; i < 65; i++) {
80 + const r = await app.inject({ method: 'GET', url: '/v1/stats', headers: { authorization: `Bearer ${small.key}` } });
81 + last = r.statusCode;
82 + if (last === 429) break;
83 + }
84 + expect(last).toBe(429);
85 + } finally {
86 + await getDb().delete(apiKeys).where(eq(apiKeys.id, small.id));
87 + }
88 + });
89 +});
90 +
91 +describe('envelope helpers', () => {
92 + it('csv + cursor', () => {
93 + expect(toCsv([{ a: 1, b: 'x,y' }, { a: 2, b: null }])).toBe('a,b\n1,"x,y"\n2,\n');
94 + expect(decodeCursor(encodeCursor(150))).toBe(150);
95 + expect(decodeCursor('garbage')).toBe(0);
96 + });
97 +});
added apps/api/src/app.ts +71 −0
@@ -0,0 +1,71 @@
1 +import Fastify, { type FastifyInstance } from 'fastify';
2 +import cors from '@fastify/cors';
3 +import compress from '@fastify/compress';
4 +import etag from '@fastify/etag';
5 +import rateLimit from '@fastify/rate-limit';
6 +import { randomUUID } from 'node:crypto';
7 +import { getSql } from '@rareindex/database';
8 +import { authPlugin, rateKeyFor, rateLimitFor } from './plugins/auth.js';
9 +import { v1Routes } from './routes/v1.js';
10 +import { internalRoutes } from './routes/internal.js';
11 +import { problem } from './lib/envelope.js';
12 +
13 +export interface BuildOptions {
14 + logger?: boolean;
15 +}
16 +
17 +export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> {
18 + const app = Fastify({
19 + logger: opts.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] },
20 + genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(),
21 + trustProxy: true,
22 + });
23 +
24 + await app.register(cors, { origin: true, methods: ['GET', 'HEAD', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'] });
25 + await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'] });
26 + await app.register(etag);
27 + await app.register(authPlugin);
28 + await app.register(rateLimit, {
29 + global: true,
30 + max: (req) => rateLimitFor(req),
31 + timeWindow: '1 minute',
32 + keyGenerator: (req) => rateKeyFor(req),
33 + errorResponseBuilder: (req, ctx) => ({
34 + statusCode: 429,
35 + type: 'https://www.rareindex.io/api-docs#errors',
36 + title: 'Rate limit exceeded',
37 + status: 429,
38 + detail: `Limit ${ctx.max} requests per ${ctx.after}. Retry after ${ctx.ttl} ms.`,
39 + instance: req.url,
40 + request_id: req.id,
41 + }),
42 + });
43 +
44 + app.addHook('onSend', async (req, reply, payload) => {
45 + reply.header('x-request-id', req.id);
46 + return payload;
47 + });
48 +
49 + app.setErrorHandler((err: Error & { statusCode?: number; status?: number; title?: string; detail?: string }, req, reply) => {
50 + const status = err.statusCode ?? err.status ?? 500;
51 + if (status >= 500) req.log.error({ err }, 'unhandled api error');
52 + const title = status >= 500 ? 'Internal error' : (err.title ?? err.message);
53 + const detail = status >= 500 ? 'The request could not be completed.' : (err.detail ?? err.message);
54 + return problem(reply, status, title, detail);
55 + });
56 + app.setNotFoundHandler((req, reply) => problem(reply, 404, 'Not found', `No route for ${req.method} ${req.url}`));
57 +
58 + app.get('/healthz', async (_req, reply) => {
59 + try {
60 + await getSql()`select 1`;
61 + return { ok: true, service: 'rareindex-api', time: new Date().toISOString() };
62 + } catch (err) {
63 + return reply.code(503).send({ ok: false, service: 'rareindex-api', error: err instanceof Error ? err.message : String(err) });
64 + }
65 + });
66 + app.get('/', async () => ({ name: 'RareIndex Public API', docs: 'https://www.rareindex.io/api-docs', openapi: '/v1/openapi.json' }));
67 +
68 + await app.register(v1Routes);
69 + await app.register(internalRoutes);
70 + return app;
71 +}
added apps/api/src/lib/envelope.ts +65 −0
@@ -0,0 +1,65 @@
1 +import type { FastifyReply } from 'fastify';
2 +
3 +export const ATTRIBUTION = 'Data © RareIndex.io and its sources; valuations are estimates, listing prices are not confirmed transactions. See https://www.rareindex.io/data for methodology and terms.';
4 +
5 +export interface Meta {
6 + count: number;
7 + cursor?: string | null;
8 + as_of: string;
9 + attribution: string;
10 + [k: string]: unknown;
11 +}
12 +
13 +export function envelope<T>(data: T, meta: Partial<Meta> & { count?: number } = {}) {
14 + const count = meta.count ?? (Array.isArray(data) ? data.length : 1);
15 + return { data, meta: { count, cursor: meta.cursor ?? null, as_of: new Date().toISOString(), attribution: ATTRIBUTION, ...meta } };
16 +}
17 +
18 +export class ApiProblem extends Error {
19 + constructor(
20 + public status: number,
21 + public title: string,
22 + detail?: string,
23 + public type = 'about:blank',
24 + ) {
25 + super(detail ?? title);
26 + }
27 +}
28 +
29 +export function problem(reply: FastifyReply, status: number, title: string, detail?: string, extra: Record<string, unknown> = {}) {
30 + return reply
31 + .code(status)
32 + .type('application/problem+json')
33 + .send({ type: 'https://www.rareindex.io/api-docs#errors', title, status, detail: detail ?? title, instance: reply.request.url, request_id: reply.request.id, ...extra });
34 +}
35 +
36 +/** Opaque offset cursor. */
37 +export function encodeCursor(offset: number): string {
38 + return Buffer.from(String(offset), 'utf8').toString('base64url');
39 +}
40 +export function decodeCursor(cursor: string | undefined | null): number {
41 + if (!cursor) return 0;
42 + const n = Number(Buffer.from(cursor, 'base64url').toString('utf8'));
43 + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
44 +}
45 +
46 +/** Minimal CSV export for flat row arrays. */
47 +export function toCsv(rows: Array<Record<string, unknown>>): string {
48 + if (rows.length === 0) return '';
49 + const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))];
50 + const esc = (v: unknown) => {
51 + if (v === null || v === undefined) return '';
52 + const s = v instanceof Date ? v.toISOString() : typeof v === 'object' ? JSON.stringify(v) : String(v);
53 + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
54 + };
55 + return [cols.join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))].join('\n') + '\n';
56 +}
57 +
58 +export function wantsCsv(query: Record<string, unknown>): boolean {
59 + return String(query.format ?? '').toLowerCase() === 'csv';
60 +}
61 +
62 +export function sendList(reply: FastifyReply, rows: Array<Record<string, unknown>>, meta: Partial<Meta> = {}, query: Record<string, unknown> = {}) {
63 + if (wantsCsv(query)) return reply.type('text/csv; charset=utf-8').header('content-disposition', 'attachment; filename="rareindex-export.csv"').send(toCsv(rows));
64 + return reply.send(envelope(rows, meta));
65 +}
added apps/api/src/lib/keys.ts +54 −0
@@ -0,0 +1,54 @@
1 +import { randomBytes } from 'node:crypto';
2 +import { sha256, newId } from '@rareindex/shared';
3 +import { getDb, apiKeys, eq } from '@rareindex/database';
4 +
5 +/** API tiers (§158). Limits are per key per minute + a daily quota. */
6 +export const TIERS = {
7 + public: { rateLimitPerMinute: 20, dailyQuota: 500 },
8 + free: { rateLimitPerMinute: 60, dailyQuota: 1_000 },
9 + hobby: { rateLimitPerMinute: 120, dailyQuota: 10_000 },
10 + professional: { rateLimitPerMinute: 600, dailyQuota: 100_000 },
11 + research: { rateLimitPerMinute: 600, dailyQuota: 250_000 },
12 + enterprise: { rateLimitPerMinute: 3_000, dailyQuota: 5_000_000 },
13 +} as const;
14 +export type Tier = keyof typeof TIERS;
15 +
16 +export interface MintedKey {
17 + id: string;
18 + key: string;
19 + prefix: string;
20 + tier: Tier;
21 +}
22 +
23 +/** Create a key: `ri_live_<prefix>_<secret>`; only the hash is stored. The plaintext is returned once. */
24 +export async function mintApiKey(input: { name: string; tier?: Tier; userId?: string | null }): Promise<MintedKey> {
25 + const tier = input.tier ?? 'free';
26 + const prefix = randomBytes(4).toString('hex');
27 + const secret = randomBytes(24).toString('base64url');
28 + const key = `ri_live_${prefix}_${secret}`;
29 + const id = newId('apiKey');
30 + const t = TIERS[tier];
31 + await getDb().insert(apiKeys).values({ id, userId: input.userId ?? null, name: input.name, prefix, keyHash: sha256(key), tier, rateLimitPerMinute: t.rateLimitPerMinute, dailyQuota: t.dailyQuota });
32 + return { id, key, prefix, tier };
33 +}
34 +
35 +export interface ResolvedKey {
36 + id: string;
37 + tier: Tier;
38 + rateLimitPerMinute: number;
39 + dailyQuota: number;
40 + userId: string | null;
41 +}
42 +
43 +export function parseBearer(header: string | undefined): string | null {
44 + if (!header) return null;
45 + const m = header.match(/^Bearer\s+(\S+)$/i);
46 + return m ? m[1]! : null;
47 +}
48 +
49 +export async function resolveApiKey(key: string): Promise<ResolvedKey | null> {
50 + if (!key.startsWith('ri_live_')) return null;
51 + const [row] = await getDb().select().from(apiKeys).where(eq(apiKeys.keyHash, sha256(key))).limit(1);
52 + if (!row || row.revokedAt) return null;
53 + return { id: row.id, tier: (row.tier as Tier) ?? 'free', rateLimitPerMinute: row.rateLimitPerMinute, dailyQuota: row.dailyQuota, userId: row.userId };
54 +}
added apps/api/src/lib/queries.ts +156 −0
@@ -0,0 +1,156 @@
1 +import { getDb, sql } from '@rareindex/database';
2 +
3 +/**
4 + * Read-only query helpers for the public API. Plain SQL through drizzle's `sql` tag: every
5 + * function returns already-shaped rows so route handlers stay thin. Nothing here writes.
6 + */
7 +type Row = Record<string, unknown>;
8 +async function rows<T = Row>(q: ReturnType<typeof sql>): Promise<T[]> {
9 + const res = await getDb().execute(q);
10 + return res as unknown as T[];
11 +}
12 +
13 +export const ASSET_COLUMNS = sql`a.id, a.slug, a.title, a.name, a.category_slug, a.family_slug, a.brand, a.franchise, a.set_name, a.set_code, a.number, a.year, a.edition, a.variant, a.language, a.hero_image_url, a.identifiers, a.data_quality,
14 + s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, s.riv_sample_size, s.latest_sale_usd, s.latest_sale_at, s.change_7d, s.change_30d, s.change_1y, s.ath_usd, s.atl_usd,
15 + s.sales_count, s.sales_30d, s.active_listings, s.min_ask_usd, s.liquidity_score, s.rarity_score, s.momentum_30d, s.trending_score, s.value_opportunity, s.updated_at as stats_updated_at`;
16 +
17 +export async function searchAssets(opts: { q?: string; category?: string; limit: number; offset: number }) {
18 + const q = opts.q?.trim();
19 + const catFilter = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``;
20 + if (q) {
21 + const tsq = q
22 + .split(/\s+/)
23 + .filter(Boolean)
24 + .map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, ''))
25 + .filter(Boolean)
26 + .map((t) => `${t}:*`)
27 + .join(' & ');
28 + return rows(sql`
29 + select ${ASSET_COLUMNS},
30 + (coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})), 0) * 2 + similarity(a.title, ${q}) + coalesce(log(1 + s.sales_count), 0) * 0.05) as score
31 + from assets a left join asset_stats s on s.asset_id = a.id
32 + where (a.search @@ to_tsquery('simple', ${tsq}) or a.title % ${q} or a.identifiers::text ilike ${'%' + q + '%'}) ${catFilter}
33 + order by score desc, s.sales_count desc nulls last
34 + limit ${opts.limit} offset ${opts.offset}`);
35 + }
36 + return rows(sql`
37 + select ${ASSET_COLUMNS}, 0 as score from assets a left join asset_stats s on s.asset_id = a.id
38 + where true ${catFilter}
39 + order by s.sales_count desc nulls last, a.updated_at desc
40 + limit ${opts.limit} offset ${opts.offset}`);
41 +}
42 +
43 +export async function getAsset(idOrSlug: string) {
44 + const [asset] = await rows(sql`select ${ASSET_COLUMNS}, a.description, a.reference, a.model, a.series, a.rarity, a.production_quantity, a.original_msrp, a.original_msrp_currency, a.release_date, a.metadata, a.created_at, a.updated_at
45 + from assets a left join asset_stats s on s.asset_id = a.id where a.id = ${idOrSlug} or a.slug = ${idOrSlug} limit 1`);
46 + if (!asset) return null;
47 + const id = asset.id as string;
48 + const variants = await rows(sql`select v.id, v.variant_key, v.label, v.grader, v.grade, v.qualifier, v.condition, v.completeness, v.is_default,
49 + vs.riv_usd, vs.riv_low_usd, vs.riv_high_usd, vs.riv_confidence, vs.riv_sample_size, vs.latest_sale_usd, vs.latest_sale_at, vs.change_30d, vs.change_1y, vs.sales_count, vs.sales_30d, vs.active_listings, vs.min_ask_usd, vs.liquidity_score
50 + from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${id} order by vs.sales_count desc nulls last, v.label`);
51 + const [valuation] = await rows(sql`select id, variant_id, computed_at, riv_usd, low_usd, high_usd, confidence, confidence_label, sample_size, window_days, methods, method, notes from valuations where asset_id = ${id} and variant_id is null order by computed_at desc limit 1`);
52 + const sources = await rows(sql`select source_id, count(*)::int as sales from sales where asset_id = ${id} and status = 'valid' group by source_id order by sales desc`);
53 + return { ...(asset as Row & { id: string }), variants, valuation: valuation ?? null, sources };
54 +}
55 +
56 +export async function assetSales(assetId: string, opts: { limit: number; offset: number; variantId?: string; includeFlagged?: boolean }) {
57 + const statusFilter = opts.includeFlagged ? sql`and status <> 'excluded'` : sql`and status = 'valid'`;
58 + const variantFilter = opts.variantId ? sql`and variant_id = ${opts.variantId}` : sql``;
59 + return rows(sql`select id, variant_id, source_id, source_url, sale_type, sale_date, price, currency, price_usd, buyer_premium_included, condition, grader, grade, certification_number, auction_house, lot_number, image_urls, raw_title, confidence, data_quality, status, flags
60 + from sales where asset_id = ${assetId} ${statusFilter} ${variantFilter} order by sale_date desc limit ${opts.limit} offset ${opts.offset}`);
61 +}
62 +
63 +export async function assetListings(assetId: string, opts: { limit: number; offset: number; availability?: string }) {
64 + const avail = opts.availability ?? 'available';
65 + return rows(sql`select id, variant_id, source_id, source_url, listing_type, price, currency, price_usd, seller, location, shipping_cost, condition, grader, grade, certification_number, image_urls, raw_title, listed_at, ends_at, availability, bid_count, first_seen_at, last_seen_at, discount_to_riv, flags
66 + from listings where asset_id = ${assetId} and availability = ${avail} order by price_usd asc nulls last limit ${opts.limit} offset ${opts.offset}`);
67 +}
68 +
69 +export async function assetHistory(assetId: string, opts: { variantId?: string; from?: string; to?: string }) {
70 + const variant = opts.variantId ?? '';
71 + const from = opts.from ?? '1900-01-01';
72 + const to = opts.to ?? '2999-12-31';
73 + return rows(sql`select date, riv_usd, latest_sale_usd, median_usd, sales_count, volume_usd, listings_count, min_ask_usd, observation_usd
74 + from price_snapshots where asset_id = ${assetId} and variant_id = ${variant} and date between ${from} and ${to} order by date asc`);
75 +}
76 +
77 +export async function listCategories() {
78 + return rows(sql`select c.slug, c.parent_slug, c.family_slug, c.name, c.short_name, c.description, c.level, c.phase, c.active, c.index_ticker, c.condition_scale, c.graders,
79 + (select count(*)::int from assets a where a.category_slug = c.slug) as tracked_assets
80 + from categories c where c.active order by c.sort_order`);
81 +}
82 +
83 +export async function listIndices() {
84 + return rows(sql`with latest as (
85 + select distinct on (index_id) index_id, date, value, constituents_count, transactions, volume_usd, median_sale_usd, market_cap_est_usd, market_cap_confidence, liquidity_score, momentum, tracked_assets, coverage
86 + from index_values order by index_id, date desc)
87 + select i.id, i.ticker, i.name, i.description, i.parent_ticker, i.family_slugs, i.methodology, i.weighting, i.base_date, i.base_value, i.min_constituents, i.is_flagship, i.color,
88 + l.date as as_of, l.value, l.constituents_count, l.transactions, l.volume_usd, l.median_sale_usd, l.market_cap_est_usd, l.market_cap_confidence, l.liquidity_score, l.momentum, l.tracked_assets, l.coverage,
89 + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '1 day' order by date desc limit 1) as value_1d,
90 + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '7 day' order by date desc limit 1) as value_7d,
91 + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '30 day' order by date desc limit 1) as value_30d,
92 + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '365 day' order by date desc limit 1) as value_1y,
93 + (select value from index_values v where v.index_id = i.id and v.date <= date_trunc('year', l.date)::date order by date desc limit 1) as value_ytd
94 + from indices i left join latest l on l.index_id = i.id where i.active order by i.is_flagship desc, i.ticker`);
95 +}
96 +
97 +export async function indexHistory(ticker: string, opts: { from?: string; to?: string }) {
98 + const from = opts.from ?? '1900-01-01';
99 + const to = opts.to ?? '2999-12-31';
100 + return rows(sql`select v.date, v.value, v.constituents_count, v.transactions, v.volume_usd, v.median_sale_usd, v.avg_sale_usd, v.market_cap_est_usd, v.liquidity_score, v.momentum, v.coverage
101 + from index_values v join indices i on i.id = v.index_id where i.ticker = ${ticker} and v.date between ${from} and ${to} order by v.date asc`);
102 +}
103 +
104 +export async function listMarkets() {
105 + return rows(sql`with latest as (
106 + select distinct on (category_slug) * from category_snapshots order by category_slug, date desc)
107 + select c.slug, c.name, c.family_slug, c.level, c.index_ticker, l.date as as_of, l.index_value, l.tracked_assets, l.assets_with_valuation, l.sales, l.volume_usd, l.median_sale_usd, l.active_listings, l.market_cap_est_usd, l.liquidity_score, l.change_1d, l.change_7d, l.change_30d, l.change_1y,
108 + (select count(*)::int from assets a where a.family_slug = c.slug or a.category_slug = c.slug) as assets_now
109 + from categories c left join latest l on l.category_slug = c.slug where c.active and c.level = 0 order by c.sort_order`);
110 +}
111 +
112 +export async function getMarket(slug: string) {
113 + const [category] = await rows(sql`select slug, parent_slug, family_slug, name, description, level, phase, index_ticker, condition_scale, graders from categories where slug = ${slug}`);
114 + if (!category) return null;
115 + const [snapshot] = await rows(sql`select * from category_snapshots where category_slug = ${slug} order by date desc limit 1`);
116 + const scope = sql`(a.category_slug = ${slug} or a.family_slug = ${slug})`;
117 + const [counts] = await rows(sql`select count(*)::int as tracked_assets, count(s.riv_usd)::int as valued_assets, coalesce(sum(s.sales_count),0)::int as sales_total, coalesce(sum(s.active_listings),0)::int as active_listings from assets a left join asset_stats s on s.asset_id = a.id where ${scope}`);
118 + const gainers = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.change_30d is not null and s.riv_sample_size >= 3 order by s.change_30d desc limit 10`);
119 + const losers = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.change_30d is not null and s.riv_sample_size >= 3 order by s.change_30d asc limit 10`);
120 + const mostValuable = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.riv_usd is not null order by s.riv_usd desc limit 10`);
121 + const mostLiquid = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.liquidity_score is not null order by s.liquidity_score desc limit 10`);
122 + const recentSales = await rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, sa.source_id, sa.source_url, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade from sales sa join assets a on a.id = sa.asset_id where ${scope} and sa.status = 'valid' order by sa.sale_date desc limit 20`);
123 + const history = await rows(sql`select date, index_value, sales, volume_usd, median_sale_usd, active_listings, tracked_assets from category_snapshots where category_slug = ${slug} order by date asc`);
124 + return { category, snapshot: snapshot ?? null, counts, gainers, losers, most_valuable: mostValuable, most_liquid: mostLiquid, recent_sales: recentSales, history };
125 +}
126 +
127 +export async function trending(opts: { category?: string; limit: number; offset: number }) {
128 + const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``;
129 + return rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where s.trending_score is not null ${cat} order by s.trending_score desc limit ${opts.limit} offset ${opts.offset}`);
130 +}
131 +
132 +export async function latestSales(opts: { category?: string; minUsd?: number; limit: number; offset: number }) {
133 + const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``;
134 + const min = opts.minUsd ? sql`and sa.price_usd >= ${opts.minUsd}` : sql``;
135 + return rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, a.category_slug, a.hero_image_url, sa.source_id, sa.source_url, sa.sale_type, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.condition, sa.auction_house, sa.confidence
136 + from sales sa join assets a on a.id = sa.asset_id where sa.status = 'valid' ${cat} ${min} order by sa.sale_date desc, sa.created_at desc limit ${opts.limit} offset ${opts.offset}`);
137 +}
138 +
139 +/** Record sales: highest verified transaction per family (§154). */
140 +export async function recordSales() {
141 + return rows(sql`select distinct on (a.family_slug) a.family_slug, c.name as family_name, sa.id as sale_id, sa.asset_id, a.slug as asset_slug, a.title, sa.source_id, sa.source_url, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.auction_house
142 + from sales sa join assets a on a.id = sa.asset_id join categories c on c.slug = a.family_slug
143 + where sa.status = 'valid' and sa.confidence >= 0.8 and sa.is_bundle = false
144 + order by a.family_slug, sa.price_usd desc`);
145 +}
146 +
147 +export async function platformStats() {
148 + const [r] = await rows(sql`select
149 + (select count(*)::int from assets) as assets,
150 + (select count(*)::int from sales where status = 'valid') as sales,
151 + (select count(*)::int from listings where availability = 'available') as listings,
152 + (select count(*)::int from sources where active) as sources,
153 + (select count(*)::int from connectors where status = 'active') as connectors,
154 + (select count(*)::int from categories where active) as categories`);
155 + return r;
156 +}
added apps/api/src/openapi.ts +91 −0
@@ -0,0 +1,91 @@
1 +/** OpenAPI 3.1 document for the public API (§157). Served at /v1/openapi.json and written to docs/openapi.json by scripts/gen-openapi.ts. */
2 +
3 +const paging = [
4 + { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } },
5 + { name: 'cursor', in: 'query', schema: { type: 'string' }, description: 'Opaque cursor from meta.cursor' },
6 + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] }, description: 'csv returns a flat export of the rows' },
7 +];
8 +const range = [
9 + { name: 'from', in: 'query', schema: { type: 'string', format: 'date' } },
10 + { name: 'to', in: 'query', schema: { type: 'string', format: 'date' } },
11 + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } },
12 +];
13 +const envelopeOf = (ref: string, list = true) => ({
14 + type: 'object',
15 + properties: {
16 + data: list ? { type: 'array', items: { $ref: ref } } : { $ref: ref },
17 + meta: { $ref: '#/components/schemas/Meta' },
18 + },
19 +});
20 +const ok = (ref: string, list = true) => ({ 200: { description: 'OK', content: { 'application/json': { schema: envelopeOf(ref, list) } } } });
21 +const errors = {
22 + 400: { description: 'Invalid query', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } },
23 + 401: { description: 'Invalid API key', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } },
24 + 404: { description: 'Not found', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } },
25 + 429: { description: 'Rate limit or quota exceeded', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } },
26 +};
27 +
28 +export function openapiDocument() {
29 + return {
30 + openapi: '3.1.0',
31 + info: {
32 + title: 'RareIndex Public API',
33 + version: '1.0.0',
34 + description:
35 + 'Structured market data for collectible assets: canonical assets, observed sales, live listings, price history, category markets and the RareIndex indices. Valuations are estimates with confidence and sample size; listing prices are not confirmed transactions. Attribution to RareIndex and the original sources is required.',
36 + contact: { name: 'RareIndex', url: 'https://www.rareindex.io/api-docs', email: 'api@rareindex.io' },
37 + termsOfService: 'https://www.rareindex.io/about#terms',
38 + },
39 + servers: [{ url: 'https://www.rareindex.io/api', description: 'Production (proxied by the web app)' }, { url: 'http://localhost:8211', description: 'Local API service' }],
40 + security: [{ bearerAuth: [] }, {}],
41 + tags: [
42 + { name: 'assets' },
43 + { name: 'markets' },
44 + { name: 'indices' },
45 + { name: 'sales' },
46 + { name: 'reference' },
47 + ],
48 + paths: {
49 + '/v1/assets/search': { get: { tags: ['assets'], summary: 'Search canonical assets', parameters: [{ name: 'q', in: 'query', schema: { type: 'string' }, description: 'Natural language query, e.g. "1999 Charizard PSA 10"' }, { name: 'category', in: 'query', schema: { type: 'string' }, description: 'Category or family slug' }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } },
50 + '/v1/assets/{id}': { get: { tags: ['assets'], summary: 'Asset detail with variants, latest valuation and sources', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Asset id (rare_…) or slug' }], responses: { ...ok('#/components/schemas/AssetDetail', false), ...errors } } },
51 + '/v1/assets/{id}/sales': { get: { tags: ['assets', 'sales'], summary: 'Observed sales for an asset', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, { name: 'include_flagged', in: 'query', schema: { type: 'boolean' } }, ...paging], responses: { ...ok('#/components/schemas/Sale'), ...errors } } },
52 + '/v1/assets/{id}/listings': { get: { tags: ['assets'], summary: 'Listings for an asset (asks, not transactions)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'availability', in: 'query', schema: { type: 'string', enum: ['available', 'sold', 'ended', 'removed'], default: 'available' } }, ...paging], responses: { ...ok('#/components/schemas/Listing'), ...errors } } },
53 + '/v1/assets/{id}/history': { get: { tags: ['assets'], summary: 'Daily price history (RIV, latest sale, median, volume, listings)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, ...range], responses: { ...ok('#/components/schemas/PricePoint'), ...errors } } },
54 + '/v1/categories': { get: { tags: ['reference'], summary: 'Taxonomy', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Category'), ...errors } } },
55 + '/v1/indices': { get: { tags: ['indices'], summary: 'RARE and subindices with latest values and changes', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Index'), ...errors } } },
56 + '/v1/indices/{ticker}/history': { get: { tags: ['indices'], summary: 'Index daily history', parameters: [{ name: 'ticker', in: 'path', required: true, schema: { type: 'string' }, example: 'RARE-TCG' }, ...range], responses: { ...ok('#/components/schemas/IndexPoint'), ...errors } } },
57 + '/v1/markets': { get: { tags: ['markets'], summary: 'Category markets overview (latest snapshot per family)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Market'), ...errors } } },
58 + '/v1/markets/{slug}': { get: { tags: ['markets'], summary: 'Category market detail: movers, most valuable, most liquid, recent sales, history', parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' }, example: 'pokemon' }], responses: { ...ok('#/components/schemas/MarketDetail', false), ...errors } } },
59 + '/v1/trending': { get: { tags: ['markets'], summary: 'Trending assets', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } },
60 + '/v1/sales/latest': { get: { tags: ['sales'], summary: 'Latest observed sales across the platform', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, { name: 'min_usd', in: 'query', schema: { type: 'number' } }, ...paging], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } },
61 + '/v1/records': { get: { tags: ['sales'], summary: 'Record sale per family (verified transactions only)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } },
62 + '/v1/stats': { get: { tags: ['reference'], summary: 'Platform counts', responses: { ...ok('#/components/schemas/Stats', false) } } },
63 + },
64 + components: {
65 + securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', description: 'API key `ri_live_…`. Requests without a key use the public tier (20 req/min).' } },
66 + schemas: {
67 + Meta: { type: 'object', properties: { count: { type: 'integer' }, cursor: { type: ['string', 'null'] }, as_of: { type: 'string', format: 'date-time' }, attribution: { type: 'string' } }, additionalProperties: true },
68 + Problem: { type: 'object', properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, detail: { type: 'string' }, instance: { type: 'string' }, request_id: { type: 'string' } } },
69 + AssetSummary: {
70 + type: 'object',
71 + properties: {
72 + id: { type: 'string' }, slug: { type: 'string' }, title: { type: 'string' }, name: { type: 'string' }, category_slug: { type: 'string' }, family_slug: { type: 'string' }, brand: { type: ['string', 'null'] }, franchise: { type: ['string', 'null'] }, set_name: { type: ['string', 'null'] }, number: { type: ['string', 'null'] }, year: { type: ['integer', 'null'] }, variant: { type: ['string', 'null'] }, hero_image_url: { type: ['string', 'null'] },
73 + riv_usd: { type: ['number', 'null'], description: 'RareIndex Valuation (estimate)' }, riv_low_usd: { type: ['number', 'null'] }, riv_high_usd: { type: ['number', 'null'] }, riv_confidence: { type: ['number', 'null'] }, riv_sample_size: { type: 'integer' },
74 + latest_sale_usd: { type: ['number', 'null'] }, latest_sale_at: { type: ['string', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, sales_count: { type: 'integer' }, sales_30d: { type: 'integer' }, active_listings: { type: 'integer' }, min_ask_usd: { type: ['number', 'null'] }, liquidity_score: { type: ['number', 'null'] }, rarity_score: { type: ['number', 'null'] }, trending_score: { type: ['number', 'null'] },
75 + },
76 + },
77 + AssetDetail: { allOf: [{ $ref: '#/components/schemas/AssetSummary' }, { type: 'object', properties: { description: { type: ['string', 'null'] }, identifiers: { type: 'object' }, variants: { type: 'array', items: { type: 'object' } }, valuation: { type: ['object', 'null'] }, sources: { type: 'array', items: { type: 'object' } } } }] },
78 + Sale: { type: 'object', properties: { id: { type: 'string' }, source_id: { type: 'string' }, source_url: { type: 'string' }, sale_type: { type: 'string' }, sale_date: { type: 'string' }, price: { type: 'number' }, currency: { type: 'string' }, price_usd: { type: 'number' }, grader: { type: ['string', 'null'] }, grade: { type: ['string', 'null'] }, condition: { type: ['string', 'null'] }, status: { type: 'string' }, confidence: { type: 'number' } } },
79 + SaleWithAsset: { allOf: [{ $ref: '#/components/schemas/Sale' }, { type: 'object', properties: { asset_id: { type: 'string' }, asset_slug: { type: 'string' }, title: { type: 'string' } } }] },
80 + Listing: { type: 'object', properties: { id: { type: 'string' }, source_id: { type: 'string' }, source_url: { type: 'string' }, listing_type: { type: 'string' }, price: { type: ['number', 'null'] }, currency: { type: ['string', 'null'] }, price_usd: { type: ['number', 'null'] }, availability: { type: 'string' }, discount_to_riv: { type: ['number', 'null'] } } },
81 + PricePoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, riv_usd: { type: ['number', 'null'] }, latest_sale_usd: { type: ['number', 'null'] }, median_usd: { type: ['number', 'null'] }, sales_count: { type: 'integer' }, volume_usd: { type: ['number', 'null'] }, listings_count: { type: 'integer' }, min_ask_usd: { type: ['number', 'null'] } } },
82 + Category: { type: 'object', properties: { slug: { type: 'string' }, parent_slug: { type: ['string', 'null'] }, family_slug: { type: 'string' }, name: { type: 'string' }, level: { type: 'integer' }, phase: { type: 'integer' }, index_ticker: { type: ['string', 'null'] }, tracked_assets: { type: 'integer' } } },
83 + Index: { type: 'object', properties: { ticker: { type: 'string' }, name: { type: 'string' }, is_flagship: { type: 'boolean' }, as_of: { type: ['string', 'null'] }, value: { type: ['number', 'null'] }, published: { type: 'boolean' }, change_1d: { type: ['number', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_ytd: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, constituents_count: { type: ['integer', 'null'] }, transactions: { type: ['integer', 'null'] }, market_cap_est_usd: { type: ['number', 'null'] }, market_cap_confidence: { type: ['string', 'null'] } } },
84 + IndexPoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, value: { type: 'number' }, constituents_count: { type: 'integer' }, transactions: { type: 'integer' }, volume_usd: { type: ['number', 'null'] } } },
85 + Market: { type: 'object', properties: { slug: { type: 'string' }, name: { type: 'string' }, as_of: { type: ['string', 'null'] }, index_value: { type: ['number', 'null'] }, tracked_assets: { type: ['integer', 'null'] }, sales: { type: ['integer', 'null'] }, volume_usd: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] } } },
86 + MarketDetail: { type: 'object', properties: { category: { type: 'object' }, snapshot: { type: ['object', 'null'] }, counts: { type: 'object' }, gainers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, losers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_valuable: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_liquid: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, recent_sales: { type: 'array', items: { type: 'object' } }, history: { type: 'array', items: { type: 'object' } } } },
87 + Stats: { type: 'object', properties: { assets: { type: 'integer' }, sales: { type: 'integer' }, listings: { type: 'integer' }, sources: { type: 'integer' }, connectors: { type: 'integer' }, categories: { type: 'integer' } } },
88 + },
89 + },
90 + };
91 +}
added apps/api/src/plugins/auth.ts +69 −0
@@ -0,0 +1,69 @@
1 +import type { FastifyInstance, FastifyRequest } from 'fastify';
2 +import fp from 'fastify-plugin';
3 +import { getDb, apiKeys, apiUsage, eq, sql } from '@rareindex/database';
4 +import { parseBearer, resolveApiKey, TIERS, type ResolvedKey } from '../lib/keys.js';
5 +import { problem } from '../lib/envelope.js';
6 +
7 +declare module 'fastify' {
8 + interface FastifyRequest {
9 + apiKey: ResolvedKey | null;
10 + }
11 +}
12 +
13 +/**
14 + * Bearer API-key authentication. Anonymous requests are allowed on the "public" tier (low limits)
15 + * so the docs playground and casual use work; keyed requests get their tier's limits and usage counters.
16 + */
17 +export const authPlugin = fp(async (app: FastifyInstance) => {
18 + app.decorateRequest('apiKey', null);
19 + const cache = new Map<string, { key: ResolvedKey | null; at: number }>();
20 +
21 + app.addHook('onRequest', async (req, reply) => {
22 + const bearer = parseBearer(req.headers.authorization);
23 + if (!bearer) {
24 + req.apiKey = null;
25 + return;
26 + }
27 + const hit = cache.get(bearer);
28 + let resolved: ResolvedKey | null;
29 + if (hit && Date.now() - hit.at < 60_000) resolved = hit.key;
30 + else {
31 + resolved = await resolveApiKey(bearer);
32 + cache.set(bearer, { key: resolved, at: Date.now() });
33 + if (cache.size > 5000) cache.clear();
34 + }
35 + if (!resolved) return problem(reply, 401, 'Invalid API key', 'The provided API key is unknown or revoked.');
36 + req.apiKey = resolved;
37 + // daily quota
38 + const today = new Date().toISOString().slice(0, 10);
39 + const [row] = await getDb().execute(sql`select coalesce(sum(count),0)::int as used from api_usage where key_id = ${resolved.id} and date = ${today}`);
40 + const used = Number((row as { used?: number } | undefined)?.used ?? 0);
41 + if (used >= resolved.dailyQuota) return problem(reply, 429, 'Daily quota exceeded', `Tier ${resolved.tier} allows ${resolved.dailyQuota} requests per day.`, { quota: resolved.dailyQuota, used });
42 + });
43 +
44 + app.addHook('onResponse', async (req, reply) => {
45 + if (!req.apiKey || !req.routeOptions.url?.startsWith('/v1')) return;
46 + const today = new Date().toISOString().slice(0, 10);
47 + const endpoint = req.routeOptions.url;
48 + const latency = reply.elapsedTime;
49 + try {
50 + await getDb()
51 + .insert(apiUsage)
52 + .values({ keyId: req.apiKey.id, date: today, endpoint, count: 1, latencyMsAvg: latency })
53 + .onConflictDoUpdate({
54 + target: [apiUsage.keyId, apiUsage.date, apiUsage.endpoint],
55 + set: { count: sql`${apiUsage.count} + 1`, latencyMsAvg: sql`(${apiUsage.latencyMsAvg} * ${apiUsage.count} + ${latency}) / (${apiUsage.count} + 1)` },
56 + });
57 + await getDb().update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, req.apiKey.id));
58 + } catch (err) {
59 + req.log.warn({ err }, 'api usage counter failed');
60 + }
61 + });
62 +});
63 +
64 +export function rateLimitFor(req: FastifyRequest): number {
65 + return req.apiKey?.rateLimitPerMinute ?? TIERS.public.rateLimitPerMinute;
66 +}
67 +export function rateKeyFor(req: FastifyRequest): string {
68 + return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`;
69 +}
added apps/api/src/routes/internal.ts +42 −0
@@ -0,0 +1,42 @@
1 +import type { FastifyInstance } from 'fastify';
2 +import { z } from 'zod';
3 +import { createHmac } from 'node:crypto';
4 +import { connectorsForUrl, createRouter, createCrawlContext, listConnectorMeta } from '@rareindex/connectors';
5 +import type { NormalizedRecord } from '@rareindex/shared';
6 +import { problem } from '../lib/envelope.js';
7 +
8 +/**
9 + * Internal (loopback-only) endpoints used by the web app. Connector modules are loaded dynamically
10 + * (`import(path)`), which the Next.js bundler cannot do — so URL lookups run here, where tsx can.
11 + * Auth: `x-internal-token` = HMAC(SESSION_SECRET, "internal") — both processes share SESSION_SECRET.
12 + */
13 +export function internalToken(secret = process.env.SESSION_SECRET ?? 'dev-only'): string {
14 + return createHmac('sha256', secret).update('internal').digest('hex');
15 +}
16 +
17 +export async function internalRoutes(app: FastifyInstance) {
18 + app.addHook('onRequest', async (req, reply) => {
19 + if (!req.url.startsWith('/internal/')) return;
20 + const loopback = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
21 + const ok = loopback && req.headers['x-internal-token'] === internalToken();
22 + if (!ok) return problem(reply, 403, 'Forbidden', 'Internal endpoint');
23 + });
24 +
25 + app.get('/internal/lookup/sources', async () => ({ data: listConnectorMeta({ enabled: true }).filter((m) => m.supportsLookup).map((m) => ({ id: m.id, sourceName: m.sourceName, sourceUrl: m.sourceUrl })) }));
26 +
27 + app.post('/internal/lookup', async (req, reply) => {
28 + const body = z.object({ url: z.string().url().max(2000) }).safeParse(req.body);
29 + if (!body.success) return problem(reply, 400, 'Invalid body', 'url required');
30 + const url = body.data.url;
31 + const connectors = await connectorsForUrl(url);
32 + const connector = connectors[0];
33 + if (!connector?.lookup) return { data: null, meta: { supported: false } };
34 + const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY });
35 + const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: 1 } });
36 + const started = Date.now();
37 + const raws = await connector.lookup(url, ctx);
38 + const records: NormalizedRecord[] = [];
39 + for (const raw of raws) records.push(...(await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() })));
40 + return { data: { connectorId: connector.meta.id, sourceId: connector.meta.sourceId, records }, meta: { supported: true, durationMs: Date.now() - started, engineStats: ctx.engineStats } };
41 + });
42 +}
added apps/api/src/routes/v1.ts +129 −0
@@ -0,0 +1,129 @@
1 +import type { FastifyInstance } from 'fastify';
2 +import { z } from 'zod';
3 +import { decodeCursor, encodeCursor, envelope, problem, sendList } from '../lib/envelope.js';
4 +import * as q from '../lib/queries.js';
5 +import { openapiDocument } from '../openapi.js';
6 +
7 +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() });
8 +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() });
9 +
10 +function 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 +}
14 +
15 +export async function v1Routes(app: FastifyInstance) {
16 + app.get('/v1/openapi.json', async () => openapiDocument());
17 +
18 + app.get('/v1/stats', async () => envelope(await q.platformStats()));
19 +
20 + 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 + });
28 +
29 + 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 + });
35 +
36 + 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 + });
46 +
47 + 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 + });
57 +
58 + app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => {
59 + const p = parse(Range.extend({ 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 rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to });
64 + reply.header('cache-control', 'public, max-age=300');
65 + return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>);
66 + });
67 +
68 + app.get('/v1/categories', async (req, reply) => {
69 + const rows = await q.listCategories();
70 + reply.header('cache-control', 'public, max-age=3600');
71 + return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);
72 + });
73 +
74 + app.get('/v1/indices', async (req, reply) => {
75 + const rows = await q.listIndices();
76 + const shaped = rows.map((r) => {
77 + const v = r.value as number | null;
78 + const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null);
79 + 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 };
80 + });
81 + reply.header('cache-control', 'public, max-age=300');
82 + 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>);
83 + });
84 +
85 + app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => {
86 + const p = parse(Range, req.query);
87 + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
88 + const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data);
89 + reply.header('cache-control', 'public, max-age=300');
90 + return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>);
91 + });
92 +
93 + app.get('/v1/markets', async (req, reply) => {
94 + const rows = await q.listMarkets();
95 + reply.header('cache-control', 'public, max-age=300');
96 + return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>);
97 + });
98 +
99 + app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => {
100 + const m = await q.getMarket(req.params.slug);
101 + if (!m) return problem(reply, 404, 'Market not found');
102 + reply.header('cache-control', 'public, max-age=300');
103 + return envelope(m);
104 + });
105 +
106 + app.get('/v1/trending', async (req, reply) => {
107 + const p = parse(Paging.extend({ category: z.string().optional() }), req.query);
108 + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
109 + const offset = decodeCursor(p.data.cursor);
110 + const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset });
111 + const page = rows.slice(0, p.data.limit);
112 + 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>);
113 + });
114 +
115 + app.get('/v1/sales/latest', async (req, reply) => {
116 + const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query);
117 + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error);
118 + const offset = decodeCursor(p.data.cursor);
119 + const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset });
120 + const page = rows.slice(0, p.data.limit);
121 + 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>);
122 + });
123 +
124 + app.get('/v1/records', async (req, reply) => {
125 + const rows = await q.recordSales();
126 + reply.header('cache-control', 'public, max-age=600');
127 + 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>);
128 + });
129 +}
added apps/api/src/server.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { buildApp } from './app.js';
2 +
3 +const port = Number(process.env.API_PORT ?? 8211);
4 +const host = process.env.API_HOST ?? '127.0.0.1';
5 +
6 +const app = await buildApp();
7 +try {
8 + await app.listen({ port, host });
9 + app.log.info({ port, host }, 'rareindex-api listening');
10 +} catch (err) {
11 + app.log.error(err);
12 + process.exit(1);
13 +}
14 +
15 +for (const sig of ['SIGINT', 'SIGTERM'] as const) {
16 + process.on(sig, async () => {
17 + await app.close();
18 + process.exit(0);
19 + });
20 +}
added apps/api/tsconfig.json +8 −0
@@ -0,0 +1,8 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "rootDir": ".",
5 + "noEmit": true
6 + },
7 + "include": ["src", "scripts"]
8 +}
added apps/api/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { defineConfig } from 'vitest/config';
2 +
3 +export default defineConfig({
4 + test: { include: ['src/**/*.test.ts'], testTimeout: 30_000 },
5 +});
modified apps/web/next.config.ts +7 −2
@@ -4,8 +4,13 @@ const nextConfig: NextConfig = {
4 4 reactStrictMode: true,
5 5 poweredByHeader: false,
6 6 // Workspace packages are consumed as TypeScript sources.
7 transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors'],
8 serverExternalPackages: ['postgres', 'pino', 'cheerio'],
7 + transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors', '@rareindex/ai'],
8 + serverExternalPackages: ['postgres', 'pino', 'cheerio', 'pg-boss', '@anthropic-ai/sdk', 'openai'],
9 + // Public API: /api/v1/* is proxied to the Fastify service (apps/api) so one public host serves web + API.
10 + async rewrites() {
11 + const api = process.env.RI_API_URL ?? `http://127.0.0.1:${process.env.API_PORT ?? 8211}`;
12 + return [{ source: '/api/v1/:path*', destination: `${api}/v1/:path*` }];
13 + },
9 14 images: {
10 15 remotePatterns: [{ protocol: 'https', hostname: '**' }],
11 16 formats: ['image/avif', 'image/webp'],
modified apps/web/package.json +2 −0
@@ -11,6 +11,7 @@
11 11 "test": "vitest run --passWithNoTests"
12 12 },
13 13 "dependencies": {
14 + "@rareindex/ai": "workspace:*",
14 15 "@rareindex/connectors": "workspace:*",
15 16 "@rareindex/database": "workspace:*",
16 17 "@rareindex/shared": "workspace:*",
@@ -18,6 +19,7 @@
18 19 "@tanstack/react-query": "^5.90.0",
19 20 "lucide-react": "^1.0.0",
20 21 "next": "16.3.4",
22 + "pg-boss": "^12.30.0",
21 23 "react": "19.2.8",
22 24 "react-dom": "19.2.8",
23 25 "server-only": "^0.0.1",
added apps/web/src/app/admin/audit/page.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import Link from 'next/link';
2 +import { requireAdmin } from '@/lib/admin/auth';
3 +import { auditLog } from '@/lib/admin/queries';
4 +import { AdminShell, fmtTs } from '@/components/admin/shell';
5 +import { Table, th, td } from '@/components/ui/primitives';
6 +
7 +const TYPES = ['', 'sale', 'listing', 'asset', 'connector', 'normalized_record', 'taxonomy_proposal'];
8 +
9 +export default async function AuditPage({ searchParams }: { searchParams: Promise<{ type?: string }> }) {
10 + await requireAdmin();
11 + const sp = await searchParams;
12 + const rows = await auditLog({ entityType: sp.type || undefined });
13 + return (
14 + <AdminShell current="/admin/audit" title="Audit log" subtitle="Every flag, exclusion, restore, merge, edit and admin decision — the platform never silently deletes data" actions={<div className="flex flex-wrap gap-1 text-xs">{TYPES.map((t) => <Link key={t} href={`/admin/audit${t ? `?type=${t}` : ''}`} className={`rounded-md border px-2 py-1 ${(sp.type ?? '') === t ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>{t || 'all'}</Link>)}</div>}>
15 + <div className="card">
16 + <Table>
17 + <thead><tr><th className={th}>When</th><th className={th}>Entity</th><th className={th}>Action</th><th className={th}>Actor</th><th className={th}>Reason</th><th className={th}>Details</th></tr></thead>
18 + <tbody>
19 + {rows.length === 0 ? <tr><td className={td} colSpan={6}><span className="text-subtle">No audit entries.</span></td></tr> : null}
20 + {rows.map((r) => (
21 + <tr key={String(r.id)} className="align-top">
22 + <td className={td}>{fmtTs(r.created_at)}</td>
23 + <td className={`${td} font-mono text-[11px]`}>{String(r.entity_type)}<br />{String(r.entity_id)}</td>
24 + <td className={td}>{String(r.action)}</td>
25 + <td className={td}>{String(r.actor)}</td>
26 + <td className={`${td} max-w-[320px] whitespace-normal`}>{String(r.reason)}</td>
27 + <td className={`${td} max-w-[320px] whitespace-normal font-mono text-[10px] text-muted`}>{JSON.stringify(r.details)}</td>
28 + </tr>
29 + ))}
30 + </tbody>
31 + </Table>
32 + </div>
33 + </AdminShell>
34 + );
35 +}
added apps/web/src/app/admin/connectors/[id]/page.tsx +133 −0
@@ -0,0 +1,133 @@
1 +import Link from 'next/link';
2 +import { notFound } from 'next/navigation';
3 +import { requireAdmin } from '@/lib/admin/auth';
4 +import { connectorDetail } from '@/lib/admin/queries';
5 +import { connectorAction, updateConnectorConfig } from '@/lib/admin/actions';
6 +import { AdminShell, ActionButton, StatusPill, Kpi, fmtTs, n } from '@/components/admin/shell';
7 +import { Table, th, td, tdNum } from '@/components/ui/primitives';
8 +
9 +export default async function ConnectorInspectPage({ params }: { params: Promise<{ id: string }> }) {
10 + await requireAdmin();
11 + const { id } = await params;
12 + const d = await connectorDetail(id);
13 + if (!d) notFound();
14 + const c = d.connector;
15 + const health = (d.health?.health ?? {}) as Record<string, unknown>;
16 + const outputs = (d.outputs ?? {}) as Record<string, unknown>;
17 + return (
18 + <AdminShell
19 + current="/admin/connectors"
20 + title={String(c.display_name)}
21 + subtitle={<>{String(c.id)} · source <span className="font-mono">{String(c.source_id)}</span> · {(c.engine_priority as string[]).join(' → ')} · categories {(c.categories as string[]).join(', ')} · <Link href="/admin/connectors" className="underline">back</Link></>}
22 + actions={
23 + <div className="flex flex-wrap gap-1">
24 + {(['run', 'probe', 'recrawl', 'retry', c.status === 'paused' ? 'resume' : 'pause'] as const).map((a) => (
25 + <form key={a} action={connectorAction}>
26 + <input type="hidden" name="id" value={String(c.id)} />
27 + <input type="hidden" name="action" value={a} />
28 + <ActionButton label={a} small={false} tone={a === 'pause' ? 'danger' : a === 'resume' ? 'primary' : 'neutral'} />
29 + </form>
30 + ))}
31 + </div>
32 + }
33 + >
34 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
35 + <Kpi label="Status" value={<StatusPill status={String(c.status)} />} sub={<>health <StatusPill status={(d.health?.status as string) ?? 'unknown'} /></>} />
36 + <Kpi label="Success 24h" value={health.success_rate_24h != null ? `${Math.round(Number(health.success_rate_24h) * 100)}%` : '—'} sub={`${n(health.pages_success)}/${n(health.pages_attempted)} pages`} />
37 + <Kpi label="Firecrawl / Scrapfly" value={`${health.firecrawl_success_rate != null ? Math.round(Number(health.firecrawl_success_rate) * 100) + '%' : '—'} / ${health.scrapfly_fallback_rate != null ? Math.round(Number(health.scrapfly_fallback_rate) * 100) + '%' : '—'}`} sub="success / fallback share" />
38 + <Kpi label="Parse failures" value={health.parse_failure_rate != null ? `${(Number(health.parse_failure_rate) * 100).toFixed(1)}%` : '—'} sub={`${n(health.duplicates_24h)} duplicates 24h`} />
39 + <Kpi label="Outputs" value={`${n(outputs.sales)} sales`} sub={`${n(outputs.listings)} listings · ${n(outputs.observations)} observations`} />
40 + <Kpi label="Last success" value={fmtTs(c.last_success_at).slice(0, 16)} sub={`next ${fmtTs(c.next_run_at).slice(0, 16)}`} />
41 + </div>
42 +
43 + {d.anomalies.length ? (
44 + <section className="card mt-6">
45 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold text-alert">Anomalies (recent runs)</h2></header>
46 + <ul className="divide-y divide-border px-4 text-xs">{d.anomalies.map((a, i) => <li key={i} className="py-1.5 font-mono">{a}</li>)}</ul>
47 + </section>
48 + ) : null}
49 +
50 + <section className="card mt-6">
51 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Runs</h2></header>
52 + <Table>
53 + <thead><tr><th className={th}>Started</th><th className={th}>Status</th><th className={th}>Trigger</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Raw</th><th className={`${th} text-right`}>Norm.</th><th className={`${th} text-right`}>Dup.</th><th className={`${th} text-right`}>Rej.</th><th className={`${th} text-right`}>Credits</th><th className={th}>Engines</th><th className={th}>Error</th></tr></thead>
54 + <tbody>
55 + {d.runs.length === 0 ? <tr><td className={td} colSpan={11}><span className="text-subtle">No runs yet.</span></td></tr> : null}
56 + {d.runs.map((r) => (
57 + <tr key={String(r.id)}>
58 + <td className={td}>{fmtTs(r.started_at)}<div className="text-[10px] text-subtle">{r.finished_at ? `${Math.round((new Date(String(r.finished_at)).getTime() - new Date(String(r.started_at)).getTime()) / 1000)} s` : 'running'}</div></td>
59 + <td className={td}><StatusPill status={String(r.status)} /></td>
60 + <td className={td}>{String(r.trigger)}</td>
61 + <td className={tdNum}>{n(r.pages_success)}/{n(r.pages_attempted)}</td>
62 + <td className={tdNum}>{n(r.records_raw)}</td>
63 + <td className={tdNum}>{n(r.records_normalized)}</td>
64 + <td className={tdNum}>{n(r.records_duplicate)}</td>
65 + <td className={tdNum}>{n(r.records_rejected)}</td>
66 + <td className={tdNum}>{n(r.cost_credits, 1)}</td>
67 + <td className={`${td} font-mono text-[10px]`}>{Object.entries((r.engine_stats ?? {}) as Record<string, { attempts: number; success: number }>).map(([k, v]) => `${k} ${v.success}/${v.attempts}`).join(' · ')}</td>
68 + <td className={`${td} max-w-[260px] truncate text-loss`} title={String(r.error ?? '')}>{r.error ? String(r.error) : ''}</td>
69 + </tr>
70 + ))}
71 + </tbody>
72 + </Table>
73 + </section>
74 +
75 + <div className="mt-6 grid gap-6 lg:grid-cols-2">
76 + <section className="card">
77 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Raw records (latest)</h2></header>
78 + <ul className="divide-y divide-border text-xs">
79 + {d.rawSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null}
80 + {d.rawSample.map((r) => (
81 + <li key={String(r.id)} className="px-4 py-2">
82 + <div className="flex items-center justify-between gap-2"><span className="font-mono text-[11px]">{String(r.kind)} · {String(r.engine)} · {String(r.http_status ?? '')}</span><span className="text-subtle">{fmtTs(r.fetched_at)}</span></div>
83 + <a href={String(r.url)} target="_blank" rel="noreferrer noopener" className="block truncate text-muted hover:text-fg">{String(r.url)}</a>
84 + {r.process_error ? <p className="text-loss">{String(r.process_error)}</p> : <p className="text-[10px] text-subtle">{r.processed_at ? `processed ${fmtTs(r.processed_at)}` : 'unprocessed'}</p>}
85 + </li>
86 + ))}
87 + </ul>
88 + </section>
89 + <section className="card">
90 + <header className="flex items-center justify-between border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Normalized records (latest)</h2><span className="text-[11px] text-muted">{d.normalized.map((x) => `${x.status} ${n(x.n)}`).join(' · ')}</span></header>
91 + <ul className="divide-y divide-border text-xs">
92 + {d.normalizedSample.length === 0 ? <li className="px-4 py-3 text-subtle">None yet.</li> : null}
93 + {d.normalizedSample.map((r) => (
94 + <li key={String(r.id)} className="px-4 py-2">
95 + <div className="flex items-center justify-between gap-2"><span className="truncate">{String(r.raw_title ?? '')}</span><StatusPill status={String(r.status)} /></div>
96 + <p className="text-[10px] text-subtle">{String(r.kind)} · {r.price ? `${String(r.price)} ${String(r.currency ?? '')}` : ''} · {r.match_method ? `${String(r.match_method)} ${r.match_confidence ? Math.round(Number(r.match_confidence) * 100) + '%' : ''}` : ''} {r.reject_reason ? `· ${String(r.reject_reason)}` : ''}</p>
97 + </li>
98 + ))}
99 + </ul>
100 + </section>
101 + </div>
102 +
103 + <div className="mt-6 grid gap-6 lg:grid-cols-2">
104 + <section className="card">
105 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Costs (30d)</h2></header>
106 + <Table>
107 + <thead><tr><th className={th}>Day</th><th className={th}>Kind</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD est.</th></tr></thead>
108 + <tbody>
109 + {d.costs.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No cost events.</span></td></tr> : null}
110 + {d.costs.map((r, i) => <tr key={i}><td className={td}>{String(r.day)}</td><td className={td}>{String(r.kind)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td></tr>)}
111 + </tbody>
112 + </Table>
113 + </section>
114 + <section className="card">
115 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Mapping & schedule</h2></header>
116 + <form action={updateConnectorConfig} className="space-y-3 p-4 text-xs">
117 + <input type="hidden" name="id" value={String(c.id)} />
118 + <div className="grid grid-cols-2 gap-3">
119 + <label className="block text-muted">Refresh (minutes)<input name="refresh" type="number" min={5} defaultValue={Number(c.refresh_frequency_minutes)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label>
120 + <label className="block text-muted">Priority
121 + <select name="priority" defaultValue={String(c.priority)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm"><option value="high">high</option><option value="medium">medium</option><option value="low">low</option></select>
122 + </label>
123 + </div>
124 + <label className="block text-muted">Config JSON (seeds, mappings, selectors — connector-specific)
125 + <textarea name="config" rows={10} defaultValue={JSON.stringify(c.config ?? {}, null, 2)} className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 font-mono text-[11px]" />
126 + </label>
127 + <div className="flex items-center justify-between"><span className="text-subtle">schema v{String(c.schema_version)} · connector v{String(c.connector_version)} · source trust {d.source ? Math.round(Number(d.source.trust_score) * 100) + '%' : '—'}</span><ActionButton label="Save mapping" small={false} tone="primary" /></div>
128 + </form>
129 + </section>
130 + </div>
131 + </AdminShell>
132 + );
133 +}
added apps/web/src/app/admin/connectors/page.tsx +86 −0
@@ -0,0 +1,86 @@
1 +import Link from 'next/link';
2 +import { requireAdmin } from '@/lib/admin/auth';
3 +import { connectorsOverview } from '@/lib/admin/queries';
4 +import { connectorAction } from '@/lib/admin/actions';
5 +import { AdminShell, ActionButton, StatusPill, fmtTs, n } from '@/components/admin/shell';
6 +import { Table, th, td, tdNum } from '@/components/ui/primitives';
7 +
8 +function pct(v: unknown): string {
9 + const x = Number(v);
10 + return Number.isFinite(x) ? `${Math.round(x * 100)}%` : '—';
11 +}
12 +
13 +export default async function ConnectorsPage() {
14 + await requireAdmin();
15 + const rows = await connectorsOverview();
16 + return (
17 + <AdminShell current="/admin/connectors" title="Connector control center" subtitle={`${rows.length} connectors registered · actions enqueue jobs on the crawl.run queue consumed by the worker`}>
18 + <div className="card">
19 + <Table>
20 + <thead>
21 + <tr>
22 + <th className={th}>Connector</th>
23 + <th className={th}>Health</th>
24 + <th className={th}>Status</th>
25 + <th className={`${th} text-right`}>Pages/min</th>
26 + <th className={`${th} text-right`}>Errors</th>
27 + <th className={th}>Last crawl</th>
28 + <th className={`${th} text-right`}>Records 24h</th>
29 + <th className={`${th} text-right`}>Dup.</th>
30 + <th className={`${th} text-right`}>FC rate</th>
31 + <th className={`${th} text-right`}>SF rate</th>
32 + <th className={`${th} text-right`}>Parser conf.</th>
33 + <th className={th}>Schema</th>
34 + <th className={th}>Actions</th>
35 + </tr>
36 + </thead>
37 + <tbody>
38 + {rows.length === 0 ? <tr><td className={td} colSpan={13}><span className="text-subtle">No connectors in the registry yet. Add meta.json files under connectors/ and run pnpm db:seed.</span></td></tr> : null}
39 + {rows.map((r) => {
40 + const health = (r.health ?? {}) as Record<string, unknown>;
41 + const anomalies = Array.isArray(r.last_anomalies) ? (r.last_anomalies as string[]) : [];
42 + const drift = anomalies.some((a) => /schema|selector|missing_field|redesign/.test(a));
43 + return (
44 + <tr key={String(r.id)} className="align-top">
45 + <td className={td}>
46 + <Link href={`/admin/connectors/${String(r.id)}`} className="font-medium hover:underline">{String(r.display_name)}</Link>
47 + <div className="text-[11px] text-subtle">{String(r.id)} · {(r.engine_priority as string[]).join('→')} · {String(r.priority)} · every {n(r.refresh_frequency_minutes)} min</div>
48 + </td>
49 + <td className={td}><StatusPill status={(r.health_status as string) ?? 'unknown'} />{r.health_at ? <div className="text-[10px] text-subtle">{fmtTs(r.health_at)}</div> : null}</td>
50 + <td className={td}><StatusPill status={String(r.status)} />{r.last_run_status ? <div className="text-[10px] text-subtle">run: {String(r.last_run_status)}</div> : null}</td>
51 + <td className={tdNum}>{n(r.pages_per_min, 1)}</td>
52 + <td className={`${tdNum} ${r.last_error ? 'text-loss' : ''}`} title={String(r.last_error ?? '')}>{r.last_error ? '1' : '0'}{anomalies.length ? <div className="text-[10px] text-alert">{anomalies.length} anomal.</div> : null}</td>
53 + <td className={td}>{fmtTs(r.last_started_at)}<div className="text-[10px] text-subtle">ok: {fmtTs(r.last_success_at)}</div></td>
54 + <td className={tdNum}>{n(r.records_24h)}<div className="text-[10px] text-subtle">{n(r.normalized_24h)} norm.</div></td>
55 + <td className={tdNum}>{n(r.duplicates_24h)}</td>
56 + <td className={tdNum}>{pct(health.firecrawl_success_rate)}</td>
57 + <td className={tdNum}>{pct(health.scrapfly_fallback_rate)}</td>
58 + <td className={tdNum}>{pct(r.parser_confidence)}</td>
59 + <td className={td}>v{String(r.schema_version)}{drift ? <div className="text-[10px] text-alert">drift?</div> : null}</td>
60 + <td className={td}>
61 + <div className="flex flex-wrap gap-1">
62 + {(['run', 'probe', 'recrawl', 'retry'] as const).map((a) => (
63 + <form key={a} action={connectorAction}>
64 + <input type="hidden" name="id" value={String(r.id)} />
65 + <input type="hidden" name="action" value={a} />
66 + <ActionButton label={a} />
67 + </form>
68 + ))}
69 + <form action={connectorAction}>
70 + <input type="hidden" name="id" value={String(r.id)} />
71 + <input type="hidden" name="action" value={r.status === 'paused' ? 'resume' : 'pause'} />
72 + <ActionButton label={r.status === 'paused' ? 'resume' : 'pause'} tone={r.status === 'paused' ? 'primary' : 'danger'} />
73 + </form>
74 + <Link href={`/admin/connectors/${String(r.id)}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">inspect</Link>
75 + </div>
76 + </td>
77 + </tr>
78 + );
79 + })}
80 + </tbody>
81 + </Table>
82 + </div>
83 + <p className="mt-3 text-[11px] text-subtle">run = incremental crawl · probe = 25-record smoke test · recrawl = backfill mode · retry = re-run from the last cursor. FC = Firecrawl success rate, SF = share of pages that needed Scrapfly (from the 24 h health snapshot).</p>
84 + </AdminShell>
85 + );
86 +}
added apps/web/src/app/admin/costs/page.tsx +62 −0
@@ -0,0 +1,62 @@
1 +import Link from 'next/link';
2 +import { requireAdmin } from '@/lib/admin/auth';
3 +import { costsOverview } from '@/lib/admin/queries';
4 +import { AdminShell, Bars, Kpi, n } from '@/components/admin/shell';
5 +import { Table, th, td, tdNum } from '@/components/ui/primitives';
6 +
7 +export default async function CostsPage({ searchParams }: { searchParams: Promise<{ days?: string }> }) {
8 + await requireAdmin();
9 + const sp = await searchParams;
10 + const days = [7, 30, 90].includes(Number(sp.days)) ? Number(sp.days) : 30;
11 + const c = await costsOverview(days);
12 + const t = (c.totals ?? {}) as Record<string, unknown>;
13 + const raw = Number((c.perRecord as Record<string, unknown> | null)?.raw_records ?? 0);
14 + const dayMap = new Map<string, { usd: number; credits: number }>();
15 + for (const r of c.byDay) {
16 + const k = String(r.day);
17 + const cur = dayMap.get(k) ?? { usd: 0, credits: 0 };
18 + cur.usd += Number(r.usd);
19 + cur.credits += Number(r.credits);
20 + dayMap.set(k, cur);
21 + }
22 + const series = [...dayMap.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([label, v]) => ({ label, value: v.usd }));
23 + const creditSeries = [...dayMap.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([label, v]) => ({ label, value: v.credits }));
24 + return (
25 + <AdminShell
26 + current="/admin/costs"
27 + title="Costs"
28 + subtitle="Crawler credits and AI spend by day, kind, provider, connector, category and endpoint (estimates from list prices)"
29 + actions={<div className="flex gap-1 text-xs">{[7, 30, 90].map((d) => <Link key={d} href={`/admin/costs?days=${d}`} className={`rounded-md border px-2 py-1 ${d === days ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>{d}d</Link>)}</div>}
30 + >
31 + <div className="grid grid-cols-2 gap-3 md:grid-cols-5">
32 + <Kpi label={`Spend ${days}d`} value={`$${n(t.usd, 2)}`} sub={`${n(t.events)} cost events`} />
33 + <Kpi label="AI" value={`$${n(t.ai_usd, 2)}`} />
34 + <Kpi label="Firecrawl credits" value={n(t.firecrawl_credits)} />
35 + <Kpi label="Scrapfly credits" value={n(t.scrapfly_credits)} />
36 + <Kpi label="Per raw record" value={raw ? `$${(Number(t.usd ?? 0) / raw).toFixed(4)}` : '—'} sub={`${n(raw)} raw records`} />
37 + </div>
38 + <div className="mt-6 grid gap-6 lg:grid-cols-2">
39 + <section className="card p-4"><h2 className="mb-2 text-sm font-semibold">USD per day</h2><Bars points={series} format={(v) => `$${v.toFixed(3)}`} /></section>
40 + <section className="card p-4"><h2 className="mb-2 text-sm font-semibold">Crawler credits per day</h2><Bars points={creditSeries} format={(v) => `${Math.round(v)} credits`} /></section>
41 + </div>
42 + <div className="mt-6 grid gap-6 lg:grid-cols-2">
43 + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">By kind / provider</h2></header>
44 + <Table><thead><tr><th className={th}>Kind</th><th className={th}>Provider</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD</th><th className={`${th} text-right`}>Events</th></tr></thead>
45 + <tbody>{c.byKind.length === 0 ? <tr><td className={td} colSpan={5}><span className="text-subtle">No cost events yet.</span></td></tr> : c.byKind.map((r, i) => <tr key={i}><td className={td}>{String(r.kind)}</td><td className={td}>{String(r.provider)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td><td className={tdNum}>{n(r.events)}</td></tr>)}</tbody></Table>
46 + </section>
47 + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">By connector</h2></header>
48 + <Table><thead><tr><th className={th}>Connector</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD</th><th className={`${th} text-right`}>Events</th></tr></thead>
49 + <tbody>{c.byConnector.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">—</span></td></tr> : c.byConnector.map((r, i) => <tr key={i}><td className={td}><Link href={`/admin/connectors/${String(r.connector_id)}`} className="hover:underline">{String(r.connector_id)}</Link></td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td><td className={tdNum}>{n(r.events)}</td></tr>)}</tbody></Table>
50 + </section>
51 + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">By category</h2></header>
52 + <Table><thead><tr><th className={th}>Category</th><th className={`${th} text-right`}>Credits</th><th className={`${th} text-right`}>USD</th></tr></thead>
53 + <tbody>{c.byCategory.length === 0 ? <tr><td className={td} colSpan={3}><span className="text-subtle">—</span></td></tr> : c.byCategory.map((r, i) => <tr key={i}><td className={td}>{String(r.category_slug)}</td><td className={tdNum}>{n(r.credits, 1)}</td><td className={tdNum}>${n(r.usd, 3)}</td></tr>)}</tbody></Table>
54 + </section>
55 + <section className="card"><header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">AI by endpoint / model</h2></header>
56 + <Table><thead><tr><th className={th}>Endpoint</th><th className={th}>Model</th><th className={`${th} text-right`}>Calls</th><th className={`${th} text-right`}>In tok</th><th className={`${th} text-right`}>Out tok</th><th className={`${th} text-right`}>USD</th></tr></thead>
57 + <tbody>{c.byEndpoint.length === 0 ? <tr><td className={td} colSpan={6}><span className="text-subtle">—</span></td></tr> : c.byEndpoint.map((r, i) => <tr key={i}><td className={td}>{String(r.endpoint)}</td><td className={`${td} font-mono text-[11px]`}>{String(r.model)}</td><td className={tdNum}>{n(r.events)}</td><td className={tdNum}>{n(r.input_tokens)}</td><td className={tdNum}>{n(r.output_tokens)}</td><td className={tdNum}>${n(r.usd, 4)}</td></tr>)}</tbody></Table>
58 + </section>
59 + </div>
60 + </AdminShell>
61 + );
62 +}
added apps/web/src/app/admin/data-quality/page.tsx +105 −0
@@ -0,0 +1,105 @@
1 +import Link from 'next/link';
2 +import { requireAdmin } from '@/lib/admin/auth';
3 +import { dataQuality, matchCandidatesForRecord } from '@/lib/admin/queries';
4 +import { manualMatchAction, saleStatusAction } from '@/lib/admin/actions';
5 +import { AdminShell, ActionButton, Kpi, StatusPill, fmtTs, n } from '@/components/admin/shell';
6 +import { Table, th, td, tdNum } from '@/components/ui/primitives';
7 +
8 +export default async function DataQualityPage({ searchParams }: { searchParams: Promise<{ match?: string }> }) {
9 + await requireAdmin();
10 + const sp = await searchParams;
11 + const dq = await dataQuality();
12 + const q = (dq.quality ?? {}) as Record<string, unknown>;
13 + const matching = sp.match ? await matchCandidatesForRecord(sp.match) : null;
14 + return (
15 + <AdminShell current="/admin/data-quality" title="Data quality" subtitle="Flagged/excluded sales with audit reasons, unmatched records with manual matching, cross-listing duplicates. Nothing is deleted; every change is written to the audit log.">
16 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
17 + <Kpi label="Sales data quality (avg)" value={n(q.sales_avg, 1)} sub={`p10 ${n(q.sales_p10, 1)}`} />
18 + <Kpi label="Assets data quality (avg)" value={n(q.assets_avg, 1)} />
19 + <Kpi label="Low-confidence sales" value={n(q.low_confidence_sales)} sub="confidence < 0.6" />
20 + <Kpi label="Flagged / excluded" value={dq.flagged.length} sub="latest 100 shown" />
21 + </div>
22 +
23 + {matching?.record ? (
24 + <section className="card mt-6 border-index/40">
25 + <header className="flex items-center justify-between border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Manual match</h2><Link href="/admin/data-quality" className="text-xs text-muted hover:text-fg">close</Link></header>
26 + <div className="p-4 text-sm">
27 + <p className="font-medium">{String((matching.record.payload as { rawTitle?: string }).rawTitle ?? matching.record.id)}</p>
28 + <ul className="mt-3 divide-y divide-border text-xs">
29 + {matching.candidates.length === 0 ? <li className="py-2 text-subtle">No similar asset titles. Reject or leave for the resolver.</li> : null}
30 + {matching.candidates.map((c) => (
31 + <li key={String(c.id)} className="flex items-center justify-between gap-3 py-1.5">
32 + <span className="truncate"><Link href={`/asset/${String(c.slug)}`} className="hover:underline">{String(c.title)}</Link> <span className="text-subtle">{String(c.category_slug)} · {Math.round(Number(c.score) * 100)}%</span></span>
33 + <form action={manualMatchAction}><input type="hidden" name="recordId" value={String(matching.record!.id)} /><input type="hidden" name="assetId" value={String(c.id)} /><ActionButton label="match" tone="primary" /></form>
34 + </li>
35 + ))}
36 + </ul>
37 + <form action={manualMatchAction} className="mt-3 flex items-center gap-2 text-xs"><input type="hidden" name="recordId" value={String(matching.record.id)} /><input name="assetId" placeholder="or paste an asset id (rare_…)" className="flex-1 rounded-md border border-border bg-sunken px-2 py-1" /><ActionButton label="match id" /></form>
38 + <form action={manualMatchAction} className="mt-2"><input type="hidden" name="recordId" value={String(matching.record.id)} /><input type="hidden" name="assetId" value="" /><ActionButton label="reject record" tone="danger" /></form>
39 + </div>
40 + </section>
41 + ) : null}
42 +
43 + <section className="card mt-6">
44 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Flagged & excluded sales</h2></header>
45 + <Table>
46 + <thead><tr><th className={th}>Asset</th><th className={th}>Status</th><th className={th}>Flags</th><th className={`${th} text-right`}>Price</th><th className={th}>Date</th><th className={th}>Source</th><th className={th}>Last reason</th><th className={th}>Actions</th></tr></thead>
47 + <tbody>
48 + {dq.flagged.length === 0 ? <tr><td className={td} colSpan={8}><span className="text-subtle">No flagged or excluded sales.</span></td></tr> : null}
49 + {dq.flagged.map((s) => (
50 + <tr key={String(s.id)}>
51 + <td className={`${td} max-w-[260px] truncate`}><Link href={`/asset/${String(s.asset_slug)}`} className="hover:underline">{String(s.title)}</Link><div className="truncate text-[10px] text-subtle">{String(s.raw_title)}</div></td>
52 + <td className={td}><StatusPill status={String(s.status)} /></td>
53 + <td className={`${td} font-mono text-[10px]`}>{(s.flags as string[]).join(', ')}</td>
54 + <td className={tdNum}>{n(s.price, 2)} {String(s.currency)}<div className="text-[10px] text-subtle">${n(s.price_usd)}</div></td>
55 + <td className={td}>{fmtTs(s.sale_date).slice(0, 10)}</td>
56 + <td className={td}><a href={String(s.source_url)} target="_blank" rel="noreferrer noopener" className="hover:underline">{String(s.source_id)}</a></td>
57 + <td className={`${td} max-w-[220px] truncate`} title={String(s.last_reason ?? '')}>{String(s.last_reason ?? '')}</td>
58 + <td className={td}>
59 + <div className="flex gap-1">
60 + {(['valid', 'flagged', 'excluded'] as const).filter((x) => x !== s.status).map((x) => (
61 + <form key={x} action={saleStatusAction} className="flex items-center gap-1"><input type="hidden" name="id" value={String(s.id)} /><input type="hidden" name="status" value={x} /><input type="hidden" name="reason" value={`admin review → ${x}`} /><ActionButton label={x === 'valid' ? 'restore' : x} tone={x === 'excluded' ? 'danger' : 'neutral'} /></form>
62 + ))}
63 + </div>
64 + </td>
65 + </tr>
66 + ))}
67 + </tbody>
68 + </Table>
69 + </section>
70 +
71 + <section className="card mt-6">
72 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Unmatched / rejected normalized records</h2></header>
73 + <Table>
74 + <thead><tr><th className={th}>Title</th><th className={th}>Connector</th><th className={th}>Kind</th><th className={th}>Category</th><th className={`${th} text-right`}>Price</th><th className={th}>Reason</th><th className={th}>Created</th><th className={th}></th></tr></thead>
75 + <tbody>
76 + {dq.unmatched.length === 0 ? <tr><td className={td} colSpan={8}><span className="text-subtle">Nothing waiting for a manual decision.</span></td></tr> : null}
77 + {dq.unmatched.map((r) => (
78 + <tr key={String(r.id)}>
79 + <td className={`${td} max-w-[300px] truncate`}><a href={String(r.source_url ?? '#')} target="_blank" rel="noreferrer noopener" className="hover:underline">{String(r.raw_title ?? r.id)}</a></td>
80 + <td className={td}>{String(r.connector_id)}</td>
81 + <td className={td}>{String(r.kind)}</td>
82 + <td className={td}>{String(r.category_slug ?? '—')}</td>
83 + <td className={tdNum}>{r.price ? `${n(r.price, 2)} ${String(r.currency ?? '')}` : '—'}</td>
84 + <td className={`${td} max-w-[200px] truncate`} title={String(r.reject_reason ?? '')}>{String(r.reject_reason ?? '')}</td>
85 + <td className={td}>{fmtTs(r.created_at)}</td>
86 + <td className={td}><Link href={`/admin/data-quality?match=${String(r.id)}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">match</Link></td>
87 + </tr>
88 + ))}
89 + </tbody>
90 + </Table>
91 + </section>
92 +
93 + <section className="card mt-6">
94 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Cross-listing groups</h2></header>
95 + <Table>
96 + <thead><tr><th className={th}>Group</th><th className={`${th} text-right`}>Listings</th><th className={th}>Sources</th><th className={th}>Sample</th></tr></thead>
97 + <tbody>
98 + {dq.duplicates.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No cross-listing groups detected yet.</span></td></tr> : null}
99 + {dq.duplicates.map((d) => <tr key={String(d.cross_listing_group_id)}><td className={`${td} font-mono text-[11px]`}>{String(d.cross_listing_group_id)}</td><td className={tdNum}>{n(d.n)}</td><td className={td}>{(d.sources as string[]).join(', ')}</td><td className={`${td} max-w-[320px] truncate`}>{String(d.sample_title)}</td></tr>)}
100 + </tbody>
101 + </Table>
102 + </section>
103 + </AdminShell>
104 + );
105 +}
added apps/web/src/app/admin/events/page.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import Link from 'next/link';
2 +import { requireAdmin } from '@/lib/admin/auth';
3 +import { eventsLog } from '@/lib/admin/queries';
4 +import { AdminShell, fmtTs, n } from '@/components/admin/shell';
5 +import { Table, th, td } from '@/components/ui/primitives';
6 +
7 +export default async function EventsPage({ searchParams }: { searchParams: Promise<{ type?: string }> }) {
8 + await requireAdmin();
9 + const sp = await searchParams;
10 + const { rows, types } = await eventsLog({ type: sp.type || undefined });
11 + return (
12 + <AdminShell current="/admin/events" title="Domain events" subtitle="page_crawled, listing_created, sale_detected, entity_matched, valuation_updated, index_updated… (last 200)" actions={<div className="flex flex-wrap gap-1 text-xs"><Link href="/admin/events" className={`rounded-md border px-2 py-1 ${!sp.type ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>all</Link>{types.map((t) => <Link key={String(t.type)} href={`/admin/events?type=${String(t.type)}`} className={`rounded-md border px-2 py-1 ${sp.type === t.type ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset'}`}>{String(t.type)} <span className="text-subtle">{n(t.n)}</span></Link>)}</div>}>
13 + <div className="card">
14 + <Table>
15 + <thead><tr><th className={th}>When</th><th className={th}>Type</th><th className={th}>Entity</th><th className={th}>Payload</th></tr></thead>
16 + <tbody>
17 + {rows.length === 0 ? <tr><td className={td} colSpan={4}><span className="text-subtle">No events recorded yet.</span></td></tr> : null}
18 + {rows.map((r) => (
19 + <tr key={String(r.id)} className="align-top">
20 + <td className={td}>{fmtTs(r.created_at)}</td>
21 + <td className={`${td} font-mono text-[11px]`}>{String(r.type)}</td>
22 + <td className={`${td} font-mono text-[11px]`}>{String(r.entity_type ?? '')} {String(r.entity_id ?? '')}</td>
23 + <td className={`${td} max-w-[520px] whitespace-normal font-mono text-[10px] text-muted`}>{JSON.stringify(r.payload)}</td>
24 + </tr>
25 + ))}
26 + </tbody>
27 + </Table>
28 + </div>
29 + </AdminShell>
30 + );
31 +}
added apps/web/src/app/admin/layout.tsx +8 −0
@@ -0,0 +1,8 @@
1 +import type { Metadata } from 'next';
2 +
3 +export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } };
4 +export const dynamic = 'force-dynamic';
5 +
6 +export default function AdminLayout({ children }: { children: React.ReactNode }) {
7 + return <>{children}</>;
8 +}
added apps/web/src/app/admin/login/page.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { adminLogin } from '@/lib/admin/actions';
2 +
3 +export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
4 + const sp = await searchParams;
5 + return (
6 + <div className="mx-auto mt-16 max-w-sm">
7 + <div className="card p-6">
8 + <p className="text-[10px] font-semibold uppercase tracking-wider text-subtle">Admin</p>
9 + <h1 className="mt-1 text-lg font-semibold">Connector control center</h1>
10 + <p className="mt-1 text-xs text-muted">Enter the server admin token. The token is exchanged for a signed, httpOnly cookie valid 12 hours.</p>
11 + <form action={adminLogin} className="mt-4 space-y-3">
12 + <input type="hidden" name="next" value={sp.next ?? '/admin'} />
13 + <input name="token" type="password" autoComplete="current-password" required placeholder="ADMIN_TOKEN" className="w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm focus:border-border-strong focus:outline-none" />
14 + {sp.error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{sp.error}</p> : null}
15 + <button type="submit" className="w-full rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg">Sign in</button>
16 + </form>
17 + </div>
18 + </div>
19 + );
20 +}
added apps/web/src/app/admin/page.tsx +98 −0
@@ -0,0 +1,98 @@
1 +import Link from 'next/link';
2 +import { requireAdmin } from '@/lib/admin/auth';
3 +import { dashboard } from '@/lib/admin/queries';
4 +import { queueDepths } from '@/lib/admin/queue';
5 +import { adminLogout } from '@/lib/admin/actions';
6 +import { AdminShell, Kpi, StatusPill, fmtTs, n } from '@/components/admin/shell';
7 +import { Table, th, td, tdNum } from '@/components/ui/primitives';
8 +import { getRouter } from '@rareindex/ai';
9 +
10 +export default async function AdminHome() {
11 + await requireAdmin();
12 + const [{ counts, recentRuns, recentEvents }, queues] = await Promise.all([dashboard(), queueDepths()]);
13 + const c = (counts ?? {}) as Record<string, unknown>;
14 + const ai = getRouter();
15 + return (
16 + <AdminShell
17 + current="/admin"
18 + title="Pipeline overview"
19 + subtitle="Counts, lag and freshness across ingestion, canonical data and analytics"
20 + actions={
21 + <form action={adminLogout}>
22 + <button type="submit" className="rounded-md border border-border px-2.5 py-1 text-xs hover:bg-inset">Sign out</button>
23 + </form>
24 + }
25 + >
26 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-6">
27 + <Kpi label="Assets" value={n(c.assets)} sub={`${n(c.variants)} variants`} />
28 + <Kpi label="Sales" value={n(c.sales)} sub={`${n(c.sales_flagged)} flagged · ${n(c.sales_excluded)} excluded`} />
29 + <Kpi label="Live listings" value={n(c.listings_live)} />
30 + <Kpi label="Price observations" value={n(c.observations)} />
31 + <Kpi label="Raw records" value={n(c.raw_records)} sub={`${n(c.raw_unprocessed)} unprocessed · ${n(c.raw_errors)} errors`} tone={Number(c.raw_errors) > 0 ? 'alert' : undefined} />
32 + <Kpi label="Normalized pending" value={n(c.normalized_pending)} sub={`${n(c.normalized_unmatched)} unmatched`} tone={Number(c.normalized_pending) > 1000 ? 'alert' : undefined} />
33 + <Kpi label="Connectors" value={`${n(c.connectors_active)} active`} sub={`${n(c.connectors_paused)} paused · ${n(c.connectors_unhealthy)} unhealthy`} tone={Number(c.connectors_unhealthy) > 0 ? 'loss' : undefined} />
34 + <Kpi label="Taxonomy proposals" value={n(c.proposals_pending)} sub="pending review" />
35 + <Kpi label="Latest fetch" value={fmtTs(c.latest_fetch).slice(0, 16)} />
36 + <Kpi label="Latest sale" value={fmtTs(c.latest_sale).slice(0, 10)} sub={`valuation ${fmtTs(c.latest_valuation).slice(0, 16)}`} />
37 + <Kpi label="Latest index date" value={c.latest_index ? String(c.latest_index).slice(0, 10) : '—'} />
38 + <Kpi label="Spend 24h" value={`$${n(c.cost_24h_usd, 2)}`} sub={`FC ${n(c.firecrawl_credits_24h)} · SF ${n(c.scrapfly_credits_24h)} credits`} />
39 + </div>
40 +
41 + <div className="mt-6 grid gap-6 lg:grid-cols-3">
42 + <section className="card lg:col-span-2">
43 + <header className="flex items-center justify-between border-b border-border px-4 py-2.5">
44 + <h2 className="text-sm font-semibold">Recent connector runs</h2>
45 + <Link href="/admin/connectors" className="text-xs text-muted hover:text-fg">Control center →</Link>
46 + </header>
47 + <Table>
48 + <thead><tr><th className={th}>Connector</th><th className={th}>Status</th><th className={th}>Trigger</th><th className={th}>Started</th><th className={`${th} text-right`}>Pages</th><th className={`${th} text-right`}>Raw</th><th className={`${th} text-right`}>Norm.</th><th className={`${th} text-right`}>Dup.</th><th className={th}>Error</th></tr></thead>
49 + <tbody>
50 + {recentRuns.length === 0 ? <tr><td className={td} colSpan={9}><span className="text-subtle">No runs yet. Trigger one from the control center.</span></td></tr> : null}
51 + {recentRuns.map((r) => (
52 + <tr key={String(r.id)}>
53 + <td className={td}><Link href={`/admin/connectors/${String(r.connector_id)}`} className="font-medium hover:underline">{String(r.connector_id)}</Link></td>
54 + <td className={td}><StatusPill status={String(r.status)} /></td>
55 + <td className={td}>{String(r.trigger)}</td>
56 + <td className={td}>{fmtTs(r.started_at)}</td>
57 + <td className={tdNum}>{n(r.pages_success)}/{n(r.pages_attempted)}</td>
58 + <td className={tdNum}>{n(r.records_raw)}</td>
59 + <td className={tdNum}>{n(r.records_normalized)}</td>
60 + <td className={tdNum}>{n(r.records_duplicate)}</td>
61 + <td className={`${td} max-w-[240px] truncate text-loss`} title={String(r.error ?? '')}>{r.error ? String(r.error) : ''}</td>
62 + </tr>
63 + ))}
64 + </tbody>
65 + </Table>
66 + </section>
67 + <div className="space-y-6">
68 + <section className="card">
69 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Queues (pg-boss)</h2></header>
70 + <ul className="divide-y divide-border text-xs">
71 + {queues.length === 0 ? <li className="px-4 py-3 text-subtle">Queue tables not initialised yet (the worker creates them on first start).</li> : null}
72 + {queues.map((q) => (
73 + <li key={q.name} className="flex items-center justify-between px-4 py-2"><span className="font-mono">{q.name}</span><span className="num text-muted">{q.queued} queued · {q.active} active · {q.failed} failed</span></li>
74 + ))}
75 + </ul>
76 + </section>
77 + <section className="card">
78 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Events (24h)</h2></header>
79 + <ul className="divide-y divide-border text-xs">
80 + {recentEvents.length === 0 ? <li className="px-4 py-3 text-subtle">No events yet.</li> : null}
81 + {recentEvents.map((e) => (
82 + <li key={String(e.type)} className="flex items-center justify-between px-4 py-2"><span className="font-mono">{String(e.type)}</span><span className="num text-muted">{n(e.n)}</span></li>
83 + ))}
84 + </ul>
85 + </section>
86 + <section className="card">
87 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">AI model routing</h2></header>
88 + <ul className="divide-y divide-border text-xs">
89 + {ai.table().map((r) => (
90 + <li key={r.role} className="flex items-center justify-between px-4 py-2"><span>{r.role}</span><span className="font-mono text-muted">{r.provider ? `${r.provider} · ${r.model}` : 'not configured'}</span></li>
91 + ))}
92 + </ul>
93 + </section>
94 + </div>
95 + </div>
96 + </AdminShell>
97 + );
98 +}
added apps/web/src/app/admin/taxonomy/page.tsx +61 −0
@@ -0,0 +1,61 @@
1 +import { requireAdmin } from '@/lib/admin/auth';
2 +import { taxonomyQueue } from '@/lib/admin/queries';
3 +import { proposeTaxonomyNode, taxonomyDecision } from '@/lib/admin/actions';
4 +import { AdminShell, ActionButton, StatusPill, fmtTs, n } from '@/components/admin/shell';
5 +
6 +export default async function TaxonomyAdminPage() {
7 + await requireAdmin();
8 + const { pending, decided, families } = await taxonomyQueue();
9 + return (
10 + <AdminShell current="/admin/taxonomy" title="Taxonomy proposals" subtitle="Category discovery queue (§101): approve to insert a node under the chosen parent; the category becomes available to connectors and pages immediately">
11 + <div className="grid gap-6 lg:grid-cols-[1fr_320px]">
12 + <section className="card">
13 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Pending ({pending.length})</h2></header>
14 + <ul className="divide-y divide-border">
15 + {pending.length === 0 ? <li className="px-4 py-6 text-center text-xs text-subtle">Queue empty. The discovery worker files proposals here when it finds recurring product clusters outside the taxonomy.</li> : null}
16 + {pending.map((p) => (
17 + <li key={String(p.id)} className="px-4 py-3 text-sm">
18 + <div className="flex flex-wrap items-start justify-between gap-3">
19 + <div>
20 + <p className="font-medium">{String(p.name)} <span className="font-mono text-xs text-muted">{String(p.proposed_slug)}</span></p>
21 + <p className="text-xs text-muted">parent {String(p.parent_slug ?? '—')} · est. volume {n(p.volume_estimate)} · {fmtTs(p.created_at)}</p>
22 + <pre className="mt-1 max-h-32 overflow-auto rounded-md bg-sunken p-2 text-[10px] text-muted">{JSON.stringify(p.evidence ?? {}, null, 1)}</pre>
23 + </div>
24 + <div className="flex items-center gap-2">
25 + <form action={taxonomyDecision} className="flex items-center gap-1">
26 + <input type="hidden" name="id" value={String(p.id)} />
27 + <input type="hidden" name="decision" value="approved" />
28 + <select name="parent" defaultValue={String(p.parent_slug ?? '')} className="rounded-md border border-border bg-sunken px-1.5 py-0.5 text-xs">
29 + <option value="">(new family)</option>
30 + {families.map((f) => <option key={String(f.slug)} value={String(f.slug)}>{String(f.name)}</option>)}
31 + </select>
32 + <ActionButton label="approve" tone="primary" />
33 + </form>
34 + <form action={taxonomyDecision}>
35 + <input type="hidden" name="id" value={String(p.id)} />
36 + <input type="hidden" name="decision" value="rejected" />
37 + <ActionButton label="reject" tone="danger" />
38 + </form>
39 + </div>
40 + </div>
41 + </li>
42 + ))}
43 + </ul>
44 + <header className="border-y border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Decided</h2></header>
45 + <ul className="divide-y divide-border text-xs">
46 + {decided.map((p) => <li key={String(p.id)} className="flex items-center justify-between px-4 py-2"><span>{String(p.name)} <span className="font-mono text-muted">{String(p.proposed_slug)}</span></span><span className="flex items-center gap-2 text-subtle"><StatusPill status={String(p.status)} />{fmtTs(p.decided_at)}</span></li>)}
47 + </ul>
48 + </section>
49 + <section className="card self-start">
50 + <header className="border-b border-border px-4 py-2.5"><h2 className="text-sm font-semibold">Propose manually</h2></header>
51 + <form action={proposeTaxonomyNode} className="space-y-2 p-4 text-xs">
52 + <label className="block text-muted">Slug<input name="slug" required placeholder="riftbound" className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label>
53 + <label className="block text-muted">Name<input name="name" required placeholder="Riftbound (League of Legends TCG)" className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm" /></label>
54 + <label className="block text-muted">Parent<select name="parent" className="mt-1 w-full rounded-md border border-border bg-sunken px-2 py-1.5 text-sm"><option value="">(new family)</option>{families.map((f) => <option key={String(f.slug)} value={String(f.slug)}>{String(f.name)}</option>)}</select></label>
55 + <ActionButton label="Add to queue" small={false} tone="primary" />
56 + </form>
57 + </section>
58 + </div>
59 + </AdminShell>
60 + );
61 +}
added apps/web/src/app/api/admin/connectors/[id]/[action]/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { NextResponse } from 'next/server';
2 +import { connectorAction } from '@/lib/admin/actions';
3 +
4 +export const runtime = 'nodejs';
5 +
6 +/** JSON twin of the control-center buttons (for scripts/agents): POST /api/admin/connectors/:id/:action with the admin cookie. */
7 +export async function POST(_req: Request, ctx: { params: Promise<{ id: string; action: string }> }) {
8 + const { id, action } = await ctx.params;
9 + const fd = new FormData();
10 + fd.set('id', id);
11 + fd.set('action', action);
12 + try {
13 + await connectorAction(fd);
14 + return NextResponse.json({ ok: true, id, action });
15 + } catch (err) {
16 + return NextResponse.json({ ok: false, error: err instanceof Error ? err.message : String(err) }, { status: 400 });
17 + }
18 +}
added apps/web/src/app/api/admin/status/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +import { NextResponse } from 'next/server';
2 +import { dashboard, connectorsOverview } from '@/lib/admin/queries';
3 +import { queueDepths } from '@/lib/admin/queue';
4 +
5 +export const runtime = 'nodejs';
6 +export const dynamic = 'force-dynamic';
7 +
8 +/** Machine-readable pipeline status for monitors (admin cookie required by the proxy). */
9 +export async function GET() {
10 + const [d, connectors, queues] = await Promise.all([dashboard(), connectorsOverview(), queueDepths()]);
11 + return NextResponse.json({
12 + counts: d.counts,
13 + queues,
14 + connectors: connectors.map((c) => ({ id: c.id, status: c.status, health: c.health_status, lastRun: c.last_started_at, lastRunStatus: c.last_run_status, lastError: c.last_error, records24h: c.records_24h })),
15 + as_of: new Date().toISOString(),
16 + });
17 +}
added apps/web/src/app/api/research/[id]/route.ts +16 −0
@@ -0,0 +1,16 @@
1 +import { NextResponse } from 'next/server';
2 +import { loadSession } from '@/lib/ai/research';
3 +import { currentUserId, getAnonId } from '@/lib/ai/request';
4 +
5 +export const runtime = 'nodejs';
6 +
7 +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
8 + const { id } = await ctx.params;
9 + const [userId, anonId] = await Promise.all([currentUserId(), getAnonId(false)]);
10 + const loaded = await loadSession(id, { anonId, userId });
11 + if (!loaded) return NextResponse.json({ error: 'Not found' }, { status: 404 });
12 + return NextResponse.json({
13 + session: { id: loaded.session.id, title: loaded.session.title, usdEst: loaded.session.usdEst, model: loaded.session.model },
14 + messages: loaded.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, toolCalls: m.toolCalls, createdAt: m.createdAt })),
15 + });
16 +}
added apps/web/src/app/api/research/route.ts +40 −0
@@ -0,0 +1,40 @@
1 +import { z } from 'zod';
2 +import { NextResponse } from 'next/server';
3 +import { researchTurn, listSessions } from '@/lib/ai/research';
4 +import { clientIpHash, consumeQuota, currentUserId, getAnonId } from '@/lib/ai/request';
5 +
6 +export const runtime = 'nodejs';
7 +export const maxDuration = 300;
8 +
9 +const Body = z.object({ sessionId: z.string().regex(/^rs_[a-z0-9]+$/).nullable().optional(), message: z.string().min(1).max(4000) });
10 +
11 +/** POST → Server-Sent Events stream of ResearchEvent JSON lines. */
12 +export async function POST(req: Request) {
13 + const parsed = Body.safeParse(await req.json().catch(() => null));
14 + if (!parsed.success) return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
15 + const [userId, ipHash, anonId] = await Promise.all([currentUserId(), clientIpHash(), getAnonId()]);
16 + const quota = await consumeQuota('research', { userId, ipHash });
17 + if (!quota.ok) return NextResponse.json({ error: `Daily research limit reached (${quota.limit}). Sign in for a higher limit.` }, { status: 429 });
18 +
19 + const encoder = new TextEncoder();
20 + const stream = new ReadableStream<Uint8Array>({
21 + async start(controller) {
22 + const send = (e: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(e)}\n\n`));
23 + try {
24 + for await (const ev of researchTurn({ sessionId: parsed.data.sessionId ?? null, anonId, userId, message: parsed.data.message, signal: req.signal })) send(ev);
25 + } catch (err) {
26 + send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
27 + } finally {
28 + controller.enqueue(encoder.encode('data: [DONE]\n\n'));
29 + controller.close();
30 + }
31 + },
32 + });
33 + return new Response(stream, { headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', 'x-accel-buffering': 'no' } });
34 +}
35 +
36 +export async function GET() {
37 + const [userId, anonId] = await Promise.all([currentUserId(), getAnonId(false)]);
38 + const sessions = await listSessions({ anonId, userId });
39 + return NextResponse.json({ sessions: sessions.map((s) => ({ id: s.id, title: s.title, messageCount: s.messageCount, updatedAt: s.updatedAt })) });
40 +}
added apps/web/src/app/api/scanner/[id]/choose/route.ts +13 −0
@@ -0,0 +1,13 @@
1 +import { NextResponse } from 'next/server';
2 +import { z } from 'zod';
3 +import { chooseCandidate } from '@/lib/ai/scanner';
4 +
5 +export const runtime = 'nodejs';
6 +
7 +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
8 + const { id } = await ctx.params;
9 + const body = z.object({ assetId: z.string().nullable() }).safeParse(await req.json().catch(() => ({})));
10 + if (!body.success || !/^scan_[a-z0-9]+$/.test(id)) return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
11 + await chooseCandidate(id, body.data.assetId);
12 + return NextResponse.json({ ok: true });
13 +}
added apps/web/src/app/api/scanner/route.ts +48 −0
@@ -0,0 +1,48 @@
1 +import { NextResponse } from 'next/server';
2 +import { z } from 'zod';
3 +import { runScan } from '@/lib/ai/scanner';
4 +import { aiErrorMessage, clientIpHash, consumeQuota, currentUserId, getAnonId } from '@/lib/ai/request';
5 +
6 +export const runtime = 'nodejs';
7 +export const maxDuration = 120;
8 +
9 +const Body = z.object({
10 + mode: z.enum(['photo', 'url', 'text']),
11 + images: z.array(z.object({ data: z.string().min(100).max(2_800_000), mediaType: z.enum(['image/jpeg', 'image/png', 'image/webp']) })).max(5).optional(),
12 + thumbnails: z.array(z.string().max(80_000)).max(3).optional(),
13 + url: z.string().url().max(2000).optional(),
14 + text: z.string().max(4000).optional(),
15 +});
16 +
17 +export async function POST(req: Request) {
18 + let body: z.infer<typeof Body>;
19 + try {
20 + body = Body.parse(await req.json());
21 + } catch (err) {
22 + return NextResponse.json({ error: 'Invalid request', detail: err instanceof Error ? err.message : String(err) }, { status: 400 });
23 + }
24 + if (body.mode === 'photo' && !body.images?.length) return NextResponse.json({ error: 'Add at least one photo' }, { status: 400 });
25 + if (body.mode === 'url' && !body.url) return NextResponse.json({ error: 'Paste a listing URL' }, { status: 400 });
26 + if (body.mode === 'text' && !body.text?.trim()) return NextResponse.json({ error: 'Describe the item' }, { status: 400 });
27 +
28 + const [userId, ipHash, anonId] = await Promise.all([currentUserId(), clientIpHash(), getAnonId()]);
29 + const quota = await consumeQuota('scanner', { userId, ipHash });
30 + if (!quota.ok) return NextResponse.json({ error: `Daily scanner limit reached (${quota.limit}). Sign in for a higher limit.` }, { status: 429 });
31 +
32 + try {
33 + const result = await runScan({
34 + mode: body.mode,
35 + images: body.images?.map((i) => ({ data: i.data, mediaType: i.mediaType })),
36 + thumbnails: body.thumbnails,
37 + url: body.url,
38 + text: body.text,
39 + userId,
40 + anonId,
41 + ipHash,
42 + });
43 + return NextResponse.json({ ...result, quota: { used: quota.used, limit: quota.limit } });
44 + } catch (err) {
45 + const e = aiErrorMessage(err);
46 + return NextResponse.json({ error: e.message }, { status: e.status });
47 + }
48 +}
added apps/web/src/app/research/page.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import type { Metadata } from 'next';
2 +import { getRouter } from '@rareindex/ai';
3 +import { ResearchClient } from '@/components/research/research-client';
4 +import { SUGGESTED_PROMPTS } from '@/lib/ai/research';
5 +import { getDb, sql } from '@rareindex/database';
6 +
7 +export const metadata: Metadata = {
8 + title: 'AI Research — ask questions over structured collectibles market data',
9 + description: 'Conversational research over RareIndex data: assets, sales, listings, indices, movers and screens. Every answer is grounded in tool queries you can inspect.',
10 +};
11 +export const dynamic = 'force-dynamic';
12 +
13 +export default async function ResearchPage({ searchParams }: { searchParams: Promise<{ asset?: string; q?: string }> }) {
14 + const sp = await searchParams;
15 + const router = getRouter();
16 + let prefill = sp.q ?? '';
17 + if (sp.asset && !prefill) {
18 + const [a] = (await getDb().execute(sql`select title from assets where slug = ${sp.asset} limit 1`)) as unknown as Array<{ title: string }>;
19 + prefill = a ? `Give me a market brief on ${a.title} (/asset/${sp.asset}): valuation with confidence, recent sales, listings vs RIV, and 30d/1y change.` : '';
20 + }
21 + return (
22 + <div className="mx-auto max-w-5xl">
23 + <header className="mb-4">
24 + <p className="text-[11px] font-semibold uppercase tracking-wider text-subtle">AI Research</p>
25 + <h1 className="mt-1 text-2xl font-semibold tracking-tight">Ask the collectibles market</h1>
26 + <p className="mt-2 max-w-2xl text-sm text-muted">Questions are answered only from RareIndex’s structured data through inspectable queries. When the data is not there, the answer says so.</p>
27 + </header>
28 + <ResearchClient aiReady={router.configured} model={router.configured ? router.modelFor('research') : null} suggestions={SUGGESTED_PROMPTS} prefill={prefill} />
29 + </div>
30 + );
31 +}
added apps/web/src/app/scanner/page.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import type { Metadata } from 'next';
2 +import { ScannerClient } from '@/components/scanner/scanner-client';
3 +import { aiConfigured } from '@/lib/ai/scanner';
4 +
5 +export const metadata: Metadata = {
6 + title: 'Scanner — identify and price a collectible from a photo or URL',
7 + description: 'Upload photos or paste a marketplace URL. RareIndex identifies the item, finds it in the catalog and shows its RareIndex Valuation, latest sales and listings — with confidence, never as authentication.',
8 +};
9 +
10 +export const dynamic = 'force-dynamic';
11 +
12 +export default function ScannerPage() {
13 + return (
14 + <div className="mx-auto max-w-5xl">
15 + <header className="mb-6">
16 + <p className="text-[11px] font-semibold uppercase tracking-wider text-subtle">Scanner</p>
17 + <h1 className="mt-1 text-2xl font-semibold tracking-tight">What is this collectible worth?</h1>
18 + <p className="mt-2 max-w-2xl text-sm text-muted">Take or upload up to five photos, paste a marketplace URL, or describe the item. RareIndex identifies it, matches it to the canonical catalog and returns the RareIndex Valuation with comparable sales and live listings. Identification is an estimate with a confidence score; it is not authentication.</p>
19 + </header>
20 + <ScannerClient aiReady={aiConfigured()} />
21 + </div>
22 + );
23 +}
added apps/web/src/components/admin/shell.tsx +105 −0
@@ -0,0 +1,105 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/format';
4 +
5 +export const ADMIN_NAV = [
6 + { href: '/admin', label: 'Overview' },
7 + { href: '/admin/connectors', label: 'Connectors' },
8 + { href: '/admin/data-quality', label: 'Data quality' },
9 + { href: '/admin/costs', label: 'Costs' },
10 + { href: '/admin/taxonomy', label: 'Taxonomy' },
11 + { href: '/admin/audit', label: 'Audit log' },
12 + { href: '/admin/events', label: 'Events' },
13 +];
14 +
15 +export function AdminShell({ children, title, subtitle, actions, current }: { children: ReactNode; title: string; subtitle?: ReactNode; actions?: ReactNode; current: string }) {
16 + return (
17 + <div className="grid gap-6 lg:grid-cols-[180px_1fr]">
18 + <aside className="lg:sticky lg:top-20 lg:self-start">
19 + <p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-subtle">Admin</p>
20 + <nav className="flex gap-1 overflow-x-auto scrollbar-none lg:flex-col">
21 + {ADMIN_NAV.map((n) => (
22 + <Link key={n.href} href={n.href} className={cn('whitespace-nowrap rounded-md px-2.5 py-1.5 text-[13px]', current === n.href ? 'bg-inset font-medium text-fg' : 'text-muted hover:bg-inset hover:text-fg')}>
23 + {n.label}
24 + </Link>
25 + ))}
26 + </nav>
27 + </aside>
28 + <div className="min-w-0">
29 + <header className="mb-4 flex flex-wrap items-end justify-between gap-3">
30 + <div>
31 + <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
32 + {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
33 + </div>
34 + {actions}
35 + </header>
36 + {children}
37 + </div>
38 + </div>
39 + );
40 +}
41 +
42 +export function StatusPill({ status }: { status: string | null | undefined }) {
43 + const s = status ?? 'unknown';
44 + const tone: Record<string, string> = { healthy: 'bg-gain-bg text-gain', active: 'bg-gain-bg text-gain', success: 'bg-gain-bg text-gain', valid: 'bg-gain-bg text-gain', degraded: 'bg-alert-bg text-alert', partial: 'bg-alert-bg text-alert', paused: 'bg-alert-bg text-alert', flagged: 'bg-alert-bg text-alert', pending: 'bg-alert-bg text-alert', running: 'bg-index-bg text-index', failing: 'bg-loss-bg text-loss', failed: 'bg-loss-bg text-loss', excluded: 'bg-loss-bg text-loss', disabled: 'bg-inset text-muted', unknown: 'bg-inset text-muted' };
45 + return <span className={cn('inline-flex rounded-sm px-1.5 py-0.5 text-[11px] font-medium', tone[s] ?? 'bg-inset text-muted')}>{s}</span>;
46 +}
47 +
48 +export function ActionButton({ label, tone = 'neutral', small = true }: { label: string; tone?: 'neutral' | 'danger' | 'primary'; small?: boolean }) {
49 + return (
50 + <button type="submit" className={cn('rounded-md border font-medium', small ? 'px-2 py-0.5 text-[11px]' : 'px-3 py-1.5 text-xs', tone === 'danger' ? 'border-loss/40 text-loss hover:bg-loss-bg' : tone === 'primary' ? 'border-accent bg-accent text-accent-fg' : 'border-border hover:bg-inset')}>
51 + {label}
52 + </button>
53 + );
54 +}
55 +
56 +export function Kpi({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: 'gain' | 'loss' | 'alert' }) {
57 + return (
58 + <div className="card px-3 py-2.5">
59 + <p className="text-[10px] font-semibold uppercase tracking-wider text-subtle">{label}</p>
60 + <p className={cn('num mt-0.5 text-lg font-semibold', tone === 'gain' && 'text-gain', tone === 'loss' && 'text-loss', tone === 'alert' && 'text-alert')}>{value}</p>
61 + {sub ? <p className="text-[11px] text-muted">{sub}</p> : null}
62 + </div>
63 + );
64 +}
65 +
66 +/** Single-series bar chart (one hue, hover tooltips via <title>, no legend needed). */
67 +export function Bars({ points, format, className }: { points: Array<{ label: string; value: number }>; format?: (v: number) => string; className?: string }) {
68 + if (!points.length) return <p className="text-xs text-subtle">No data in this window.</p>;
69 + const max = Math.max(...points.map((p) => p.value), 0) || 1;
70 + const w = 600;
71 + const h = 120;
72 + const gap = 2;
73 + const bw = Math.max(2, (w - gap * points.length) / points.length);
74 + const fmt = format ?? ((v: number) => String(Math.round(v * 100) / 100));
75 + return (
76 + <svg viewBox={`0 0 ${w} ${h + 16}`} className={cn('h-auto w-full', className)} role="img" aria-label="Daily values">
77 + <line x1={0} x2={w} y1={h} y2={h} stroke="var(--ri-border)" />
78 + {points.map((p, i) => {
79 + const bh = Math.max(p.value > 0 ? 1 : 0, (p.value / max) * (h - 8));
80 + return (
81 + <g key={p.label}>
82 + <rect x={i * (bw + gap)} y={h - bh} width={bw} height={bh} rx={bw > 6 ? 2 : 0} fill="var(--ri-index)" opacity={0.85}>
83 + <title>{`${p.label}: ${fmt(p.value)}`}</title>
84 + </rect>
85 + {points.length <= 31 && (i === 0 || i === points.length - 1) ? (
86 + <text x={i * (bw + gap) + bw / 2} y={h + 12} textAnchor="middle" fontSize={9} fill="var(--ri-fg-subtle)">{p.label.slice(5)}</text>
87 + ) : null}
88 + </g>
89 + );
90 + })}
91 + </svg>
92 + );
93 +}
94 +
95 +export function fmtTs(v: unknown): string {
96 + if (!v) return '—';
97 + const d = v instanceof Date ? v : new Date(String(v));
98 + if (Number.isNaN(d.getTime())) return String(v);
99 + return d.toISOString().replace('T', ' ').slice(0, 16) + 'Z';
100 +}
101 +
102 +export function n(v: unknown, digits = 0): string {
103 + const x = Number(v);
104 + return Number.isFinite(x) ? x.toLocaleString('en-US', { maximumFractionDigits: digits }) : '—';
105 +}
added apps/web/src/components/research/markdown.tsx +71 −0
@@ -0,0 +1,71 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { Fragment, type ReactNode } from 'react';
5 +
6 +/**
7 + * Tiny dependency-free markdown renderer for assistant answers: paragraphs, headings, bullet and
8 + * numbered lists, pipe tables, bold/italic/code and links (internal links use next/link).
9 + * Not a full CommonMark implementation — enough for terminal-style research answers.
10 + */
11 +export function Markdown({ text }: { text: string }) {
12 + const blocks = splitBlocks(text);
13 + return <div className="space-y-2 text-[13px] leading-relaxed">{blocks.map((b, i) => <Fragment key={i}>{renderBlock(b)}</Fragment>)}</div>;
14 +}
15 +
16 +function splitBlocks(text: string): string[] {
17 + return text.replace(/\r\n/g, '\n').split(/\n{2,}/).map((b) => b.trim()).filter(Boolean);
18 +}
19 +
20 +function renderBlock(block: string): ReactNode {
21 + const lines = block.split('\n');
22 + if (lines.length >= 2 && lines.every((l) => l.trim().startsWith('|'))) return renderTable(lines);
23 + if (lines.every((l) => /^\s*[-*•]\s+/.test(l))) return <ul className="list-disc space-y-0.5 pl-5">{lines.map((l, i) => <li key={i}>{inline(l.replace(/^\s*[-*•]\s+/, ''))}</li>)}</ul>;
24 + if (lines.every((l) => /^\s*\d+[.)]\s+/.test(l))) return <ol className="list-decimal space-y-0.5 pl-5">{lines.map((l, i) => <li key={i}>{inline(l.replace(/^\s*\d+[.)]\s+/, ''))}</li>)}</ol>;
25 + const h = block.match(/^(#{1,4})\s+(.*)$/);
26 + if (h && lines.length === 1) {
27 + const level = h[1]!.length;
28 + const cls = level <= 2 ? 'text-sm font-semibold' : 'text-[13px] font-semibold';
29 + return <p className={cls}>{inline(h[2]!)}</p>;
30 + }
31 + if (block.startsWith('```')) return <pre className="overflow-x-auto rounded-md bg-inset p-2 font-mono text-[11px]">{block.replace(/^```\w*\n?/, '').replace(/```$/, '')}</pre>;
32 + return <p>{lines.map((l, i) => <Fragment key={i}>{i > 0 ? <br /> : null}{inline(l)}</Fragment>)}</p>;
33 +}
34 +
35 +function renderTable(lines: string[]): ReactNode {
36 + const rows = lines.filter((l) => !/^\s*\|?\s*:?-{2,}/.test(l)).map((l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()));
37 + const [head, ...body] = rows;
38 + if (!head) return null;
39 + return (
40 + <div className="overflow-x-auto">
41 + <table className="w-full border-collapse text-[12px]">
42 + <thead>
43 + <tr>{head.map((c, i) => <th key={i} className="border-b border-border px-2 py-1 text-left text-[10px] font-semibold uppercase tracking-wider text-subtle">{inline(c)}</th>)}</tr>
44 + </thead>
45 + <tbody>
46 + {body.map((r, i) => (
47 + <tr key={i}>{r.map((c, j) => <td key={j} className={`border-b border-border px-2 py-1 ${/^[\s$€£¥+\-−]?[\d.,]+%?$/.test(c) ? 'num text-right' : ''}`}>{inline(c)}</td>)}</tr>
48 + ))}
49 + </tbody>
50 + </table>
51 + </div>
52 + );
53 +}
54 +
55 +const INLINE = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
56 +
57 +function inline(s: string): ReactNode {
58 + const parts = s.split(INLINE).filter((p) => p !== '');
59 + return parts.map((p, i) => {
60 + if (p.startsWith('**') && p.endsWith('**')) return <strong key={i}>{p.slice(2, -2)}</strong>;
61 + if (p.startsWith('*') && p.endsWith('*') && p.length > 2) return <em key={i}>{p.slice(1, -1)}</em>;
62 + if (p.startsWith('`') && p.endsWith('`')) return <code key={i} className="rounded-sm bg-inset px-1 font-mono text-[11px]">{p.slice(1, -1)}</code>;
63 + const m = p.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
64 + if (m) {
65 + const href = m[2]!;
66 + const cls = 'font-medium underline decoration-border-strong underline-offset-2 hover:decoration-fg';
67 + return href.startsWith('/') ? <Link key={i} href={href} className={cls}>{m[1]}</Link> : <a key={i} href={href} target="_blank" rel="noreferrer noopener" className={cls}>{m[1]}</a>;
68 + }
69 + return <Fragment key={i}>{p}</Fragment>;
70 + });
71 +}
added apps/web/src/components/research/research-client.tsx +212 −0
@@ -0,0 +1,212 @@
1 +'use client';
2 +
3 +import { useEffect, useRef, useState } from 'react';
4 +import { ChevronDown, Loader2, Send, Sparkles } from 'lucide-react';
5 +import { cn } from '@/lib/format';
6 +import { Markdown } from './markdown';
7 +
8 +interface ToolTrace {
9 + id: string;
10 + name: string;
11 + input: unknown;
12 + ok?: boolean;
13 + ms?: number;
14 + summary?: string;
15 + preview?: unknown;
16 +}
17 +interface Turn {
18 + id: string;
19 + role: 'user' | 'assistant';
20 + content: string;
21 + tools: ToolTrace[];
22 + streaming?: boolean;
23 + error?: string;
24 + usdEst?: number;
25 +}
26 +
27 +export function ResearchClient({ aiReady, model, suggestions, prefill }: { aiReady: boolean; model: string | null; suggestions: string[]; prefill: string }) {
28 + const [sessionId, setSessionId] = useState<string | null>(null);
29 + const [turns, setTurns] = useState<Turn[]>([]);
30 + const [input, setInput] = useState(prefill);
31 + const [busy, setBusy] = useState(false);
32 + const [sessions, setSessions] = useState<Array<{ id: string; title: string | null; updatedAt: string }>>([]);
33 + const bottom = useRef<HTMLDivElement>(null);
34 + const abort = useRef<AbortController | null>(null);
35 +
36 + useEffect(() => {
37 + fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {});
38 + }, []);
39 + useEffect(() => {
40 + bottom.current?.scrollIntoView({ block: 'end' });
41 + }, [turns]);
42 +
43 + async function openSession(id: string) {
44 + const r = await fetch(`/api/research/${id}`);
45 + if (!r.ok) return;
46 + const j = (await r.json()) as { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; toolCalls: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> }> };
47 + setSessionId(id);
48 + setTurns(j.messages.map((m) => ({ id: m.id, role: m.role, content: m.content, tools: (m.toolCalls ?? []).map((t, i) => ({ id: `${m.id}-${i}`, ...t })) })));
49 + }
50 +
51 + async function send(text: string) {
52 + const message = text.trim();
53 + if (!message || busy) return;
54 + setInput('');
55 + setBusy(true);
56 + const userTurn: Turn = { id: `u-${Date.now()}`, role: 'user', content: message, tools: [] };
57 + const asst: Turn = { id: `a-${Date.now()}`, role: 'assistant', content: '', tools: [], streaming: true };
58 + setTurns((t) => [...t, userTurn, asst]);
59 + const update = (fn: (a: Turn) => Turn) => setTurns((t) => t.map((x) => (x.id === asst.id ? fn(x) : x)));
60 + abort.current = new AbortController();
61 + try {
62 + const res = await fetch('/api/research', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, message }), signal: abort.current.signal });
63 + if (!res.ok || !res.body) {
64 + const j = await res.json().catch(() => ({}));
65 + throw new Error((j as { error?: string }).error ?? `HTTP ${res.status}`);
66 + }
67 + const reader = res.body.getReader();
68 + const dec = new TextDecoder();
69 + let buf = '';
70 + for (;;) {
71 + const { value, done } = await reader.read();
72 + if (done) break;
73 + buf += dec.decode(value, { stream: true });
74 + const parts = buf.split('\n\n');
75 + buf = parts.pop() ?? '';
76 + for (const p of parts) {
77 + const line = p.replace(/^data: /, '');
78 + if (line === '[DONE]') continue;
79 + let ev: Record<string, unknown>;
80 + try {
81 + ev = JSON.parse(line);
82 + } catch {
83 + continue;
84 + }
85 + switch (ev.type) {
86 + case 'session':
87 + setSessionId(ev.sessionId as string);
88 + break;
89 + case 'text':
90 + update((a) => ({ ...a, content: a.content + (ev.text as string) }));
91 + break;
92 + case 'tool_call':
93 + update((a) => ({ ...a, tools: [...a.tools, { id: ev.id as string, name: ev.name as string, input: ev.input }] }));
94 + break;
95 + case 'tool_result':
96 + update((a) => ({ ...a, tools: a.tools.map((t) => (t.id === ev.id ? { ...t, ok: ev.ok as boolean, ms: ev.ms as number, summary: ev.summary as string, preview: ev.preview } : t)) }));
97 + break;
98 + case 'error':
99 + update((a) => ({ ...a, error: ev.message as string }));
100 + break;
101 + case 'done':
102 + update((a) => ({ ...a, streaming: false, usdEst: ev.usdEst as number }));
103 + break;
104 + }
105 + }
106 + }
107 + } catch (err) {
108 + update((a) => ({ ...a, streaming: false, error: err instanceof Error ? err.message : String(err) }));
109 + } finally {
110 + setBusy(false);
111 + update((a) => ({ ...a, streaming: false }));
112 + fetch('/api/research').then((r) => r.json()).then((j) => setSessions(j.sessions ?? [])).catch(() => {});
113 + }
114 + }
115 +
116 + return (
117 + <div className="grid gap-4 lg:grid-cols-[220px_1fr]">
118 + <aside className="hidden lg:block">
119 + <button type="button" onClick={() => { setSessionId(null); setTurns([]); }} className="mb-2 w-full rounded-md border border-border px-3 py-1.5 text-left text-xs font-medium hover:bg-inset">+ New thread</button>
120 + <ul className="space-y-0.5">
121 + {sessions.map((s) => (
122 + <li key={s.id}>
123 + <button type="button" onClick={() => void openSession(s.id)} className={cn('w-full truncate rounded-md px-2 py-1.5 text-left text-xs', s.id === sessionId ? 'bg-inset text-fg' : 'text-muted hover:bg-inset hover:text-fg')} title={s.title ?? ''}>
124 + {s.title ?? 'Untitled'}
125 + </button>
126 + </li>
127 + ))}
128 + </ul>
129 + </aside>
130 + <section className="card flex min-h-[70dvh] flex-col">
131 + <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs text-muted">
132 + <span className="inline-flex items-center gap-1.5"><Sparkles className="h-3.5 w-3.5" /> {aiReady ? `Grounded research · ${model}` : 'AI provider not configured'}</span>
133 + <span>Tool calls are shown inline</span>
134 + </div>
135 + <div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
136 + {turns.length === 0 ? (
137 + <div>
138 + <p className="mb-3 text-sm text-muted">Try one of these:</p>
139 + <div className="grid gap-2 sm:grid-cols-2">
140 + {suggestions.map((s) => (
141 + <button key={s} type="button" disabled={!aiReady || busy} onClick={() => void send(s)} className="rounded-md border border-border bg-sunken px-3 py-2 text-left text-[13px] hover:bg-inset disabled:opacity-50">
142 + {s}
143 + </button>
144 + ))}
145 + </div>
146 + </div>
147 + ) : null}
148 + {turns.map((t) => (
149 + <div key={t.id} className={cn('flex', t.role === 'user' ? 'justify-end' : 'justify-start')}>
150 + <div className={cn('max-w-[92%] rounded-lg px-3.5 py-2.5 text-sm', t.role === 'user' ? 'bg-accent text-accent-fg' : 'bg-sunken')}>
151 + {t.tools.length ? (
152 + <ul className="mb-2 space-y-1">
153 + {t.tools.map((tool) => (
154 + <ToolRow key={tool.id} tool={tool} />
155 + ))}
156 + </ul>
157 + ) : null}
158 + {t.role === 'assistant' ? <Markdown text={t.content} /> : <p className="whitespace-pre-wrap">{t.content}</p>}
159 + {t.streaming && !t.content ? <Loader2 className="mt-1 h-4 w-4 animate-spin text-muted" /> : null}
160 + {t.error ? <p className="mt-2 rounded-md bg-loss-bg px-2 py-1 text-xs text-loss">{t.error}</p> : null}
161 + {t.role === 'assistant' && !t.streaming && t.usdEst !== undefined ? <p className="mt-2 text-[10px] text-subtle">Grounded in {t.tools.length} quer{t.tools.length === 1 ? 'y' : 'ies'} · est. cost ${t.usdEst.toFixed(4)}</p> : null}
162 + </div>
163 + </div>
164 + ))}
165 + <div ref={bottom} />
166 + </div>
167 + <form
168 + className="flex items-end gap-2 border-t border-border p-3"
169 + onSubmit={(e) => {
170 + e.preventDefault();
171 + void send(input);
172 + }}
173 + >
174 + <textarea
175 + value={input}
176 + onChange={(e) => setInput(e.target.value)}
177 + onKeyDown={(e) => {
178 + if (e.key === 'Enter' && !e.shiftKey) {
179 + e.preventDefault();
180 + void send(input);
181 + }
182 + }}
183 + rows={2}
184 + placeholder={aiReady ? 'Ask about an asset, a category, an index, movers or a screen…' : 'AI provider not configured'}
185 + disabled={!aiReady}
186 + className="min-h-[44px] flex-1 resize-none rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none"
187 + />
188 + <button type="submit" disabled={!aiReady || busy || !input.trim()} aria-label="Send" className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-accent text-accent-fg disabled:opacity-50">
189 + {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
190 + </button>
191 + </form>
192 + <p className="border-t border-border px-4 py-2 text-[11px] text-subtle">Analytical data, not investment advice. Valuations are estimates with confidence; listing prices are not confirmed transactions.</p>
193 + </section>
194 + </div>
195 + );
196 +}
197 +
198 +function ToolRow({ tool }: { tool: ToolTrace }) {
199 + const [open, setOpen] = useState(false);
200 + const args = tool.input && typeof tool.input === 'object' ? Object.entries(tool.input as Record<string, unknown>).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ') : '';
201 + return (
202 + <li className="rounded-md border border-border bg-elevated text-[11px]">
203 + <button type="button" onClick={() => setOpen(!open)} className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono">
204 + <span className={cn('h-1.5 w-1.5 rounded-full', tool.ok === undefined ? 'animate-pulse bg-subtle' : tool.ok ? 'bg-gain' : 'bg-loss')} />
205 + <span className="truncate">Queried: {tool.name}({args})</span>
206 + <span className="ml-auto shrink-0 text-subtle">{tool.summary ?? (tool.ok === undefined ? '…' : '')}{tool.ms !== undefined ? ` · ${tool.ms} ms` : ''}</span>
207 + <ChevronDown className={cn('h-3 w-3 shrink-0 text-subtle transition-transform', open && 'rotate-180')} />
208 + </button>
209 + {open && tool.preview !== undefined ? <pre className="max-h-48 overflow-auto border-t border-border px-2 py-1 font-mono text-[10px] text-muted">{JSON.stringify(tool.preview, null, 1)}</pre> : null}
210 + </li>
211 + );
212 +}
added apps/web/src/components/scanner/scanner-client.tsx +350 −0
@@ -0,0 +1,350 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useCallback, useRef, useState } from 'react';
5 +import { Camera, ImagePlus, Link2, Loader2, Type, X } from 'lucide-react';
6 +import { cn, fmtMoney, fmtPct, fmtDate, confidenceLabel } from '@/lib/format';
7 +import { Badge, Card, CardHeader, EmptyState, Stat } from '@/components/ui/primitives';
8 +
9 +type Mode = 'photo' | 'url' | 'text';
10 +
11 +interface Candidate {
12 + assetId: string;
13 + slug: string;
14 + title: string;
15 + categorySlug: string;
16 + heroImageUrl: string | null;
17 + year: number | null;
18 + setName: string | null;
19 + number: string | null;
20 + score: number;
21 + rivUsd: number | null;
22 + rivLowUsd: number | null;
23 + rivHighUsd: number | null;
24 + rivConfidence: number | null;
25 + rivSampleSize: number;
26 + latestSaleUsd: number | null;
27 + latestSaleAt: string | null;
28 + salesCount: number;
29 + activeListings: number;
30 + minAskUsd: number | null;
31 +}
32 +interface ScanResponse {
33 + sessionId: string;
34 + mode: Mode;
35 + guess: Record<string, unknown> & { confidence?: number; rationale?: string; warnings?: string[]; likelyGradeRange?: string | null; conditionNotes?: string | null } | null;
36 + guessConfidence: number | null;
37 + candidates: Candidate[];
38 + best: (Candidate & { context: { sales: Array<Record<string, unknown>>; listings: Array<Record<string, unknown>>; variants: Array<Record<string, unknown>> } }) | null;
39 + listing: { sourceId: string; sourceUrl: string; rawTitle: string; price: number | null; currency: string | null; grader: string | null; grade: string | null; imageUrls: string[]; discountToRiv: number | null; verdict: string } | null;
40 + model: string | null;
41 + usdEst: number;
42 + durationMs: number;
43 + notes: string[];
44 + quota: { used: number; limit: number };
45 + error?: string;
46 +}
47 +
48 +async function downscale(file: File, max = 1600, quality = 0.86): Promise<{ data: string; mediaType: 'image/jpeg'; thumb: string }> {
49 + const bitmap = await createImageBitmap(file);
50 + const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height));
51 + const w = Math.round(bitmap.width * scale);
52 + const h = Math.round(bitmap.height * scale);
53 + const canvas = document.createElement('canvas');
54 + canvas.width = w;
55 + canvas.height = h;
56 + const ctx = canvas.getContext('2d')!;
57 + ctx.drawImage(bitmap, 0, 0, w, h);
58 + const dataUrl = canvas.toDataURL('image/jpeg', quality);
59 + const tscale = Math.min(1, 240 / Math.max(w, h));
60 + const tc = document.createElement('canvas');
61 + tc.width = Math.round(w * tscale);
62 + tc.height = Math.round(h * tscale);
63 + tc.getContext('2d')!.drawImage(canvas, 0, 0, tc.width, tc.height);
64 + return { data: dataUrl.split(',')[1]!, mediaType: 'image/jpeg', thumb: tc.toDataURL('image/jpeg', 0.7) };
65 +}
66 +
67 +export function ScannerClient({ aiReady }: { aiReady: boolean }) {
68 + const [mode, setMode] = useState<Mode>('photo');
69 + const [images, setImages] = useState<Array<{ data: string; mediaType: 'image/jpeg'; thumb: string; name: string }>>([]);
70 + const [url, setUrl] = useState('');
71 + const [text, setText] = useState('');
72 + const [busy, setBusy] = useState(false);
73 + const [error, setError] = useState<string | null>(null);
74 + const [result, setResult] = useState<ScanResponse | null>(null);
75 + const [chosen, setChosen] = useState<string | null>(null);
76 + const fileRef = useRef<HTMLInputElement>(null);
77 + const camRef = useRef<HTMLInputElement>(null);
78 +
79 + const addFiles = useCallback(async (files: FileList | null) => {
80 + if (!files) return;
81 + const next = [...images];
82 + for (const f of Array.from(files).slice(0, 5 - next.length)) {
83 + if (!f.type.startsWith('image/')) continue;
84 + const d = await downscale(f);
85 + next.push({ ...d, name: f.name });
86 + }
87 + setImages(next.slice(0, 5));
88 + }, [images]);
89 +
90 + async function submit() {
91 + setBusy(true);
92 + setError(null);
93 + setResult(null);
94 + setChosen(null);
95 + try {
96 + const res = await fetch('/api/scanner', {
97 + method: 'POST',
98 + headers: { 'content-type': 'application/json' },
99 + body: JSON.stringify({
100 + mode,
101 + images: mode === 'photo' ? images.map((i) => ({ data: i.data, mediaType: i.mediaType })) : undefined,
102 + thumbnails: mode === 'photo' ? images.slice(0, 3).map((i) => i.thumb) : undefined,
103 + url: mode === 'url' ? url.trim() : undefined,
104 + text: mode === 'text' ? text.trim() : undefined,
105 + }),
106 + });
107 + const json = (await res.json()) as ScanResponse;
108 + if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
109 + setResult(json);
110 + } catch (err) {
111 + setError(err instanceof Error ? err.message : String(err));
112 + } finally {
113 + setBusy(false);
114 + }
115 + }
116 +
117 + async function choose(assetId: string | null) {
118 + if (!result) return;
119 + setChosen(assetId);
120 + await fetch(`/api/scanner/${result.sessionId}/choose`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }) }).catch(() => {});
121 + }
122 +
123 + const canSubmit = !busy && aiReady !== false && ((mode === 'photo' && images.length > 0) || (mode === 'url' && /^https?:\/\//.test(url.trim())) || (mode === 'text' && text.trim().length > 3));
124 +
125 + return (
126 + <div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_1fr]">
127 + <Card className="self-start">
128 + <div className="flex border-b border-border">
129 + {(
130 + [
131 + ['photo', 'Photos', Camera],
132 + ['url', 'URL', Link2],
133 + ['text', 'Describe', Type],
134 + ] as const
135 + ).map(([m, label, Icon]) => (
136 + <button key={m} type="button" onClick={() => setMode(m)} className={cn('flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[13px] font-medium', mode === m ? 'border-b-2 border-accent text-fg' : 'text-muted hover:text-fg')}>
137 + <Icon className="h-3.5 w-3.5" /> {label}
138 + </button>
139 + ))}
140 + </div>
141 + <div className="space-y-3 p-4">
142 + {!aiReady ? <p className="rounded-md bg-alert-bg px-3 py-2 text-xs text-alert">AI provider not configured on this server. URL parsing still works; identification is disabled.</p> : null}
143 + {mode === 'photo' ? (
144 + <>
145 + <div
146 + className="flex min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border-strong bg-sunken p-4 text-center"
147 + onDragOver={(e) => e.preventDefault()}
148 + onDrop={(e) => {
149 + e.preventDefault();
150 + void addFiles(e.dataTransfer.files);
151 + }}
152 + >
153 + <p className="text-xs text-muted">Drop up to 5 photos: front, back, label, seal, serial.</p>
154 + <div className="flex gap-2">
155 + <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset" onClick={() => fileRef.current?.click()}>
156 + <ImagePlus className="h-3.5 w-3.5" /> Upload
157 + </button>
158 + <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border bg-elevated px-3 py-1.5 text-xs font-medium hover:bg-inset md:hidden" onClick={() => camRef.current?.click()}>
159 + <Camera className="h-3.5 w-3.5" /> Take photo
160 + </button>
161 + </div>
162 + <input ref={fileRef} type="file" accept="image/*" multiple hidden onChange={(e) => void addFiles(e.target.files)} />
163 + <input ref={camRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => void addFiles(e.target.files)} />
164 + </div>
165 + {images.length ? (
166 + <ul className="grid grid-cols-5 gap-2">
167 + {images.map((img, i) => (
168 + <li key={i} className="relative aspect-square overflow-hidden rounded-sm border border-border bg-inset">
169 + {/* eslint-disable-next-line @next/next/no-img-element */}
170 + <img src={img.thumb} alt={img.name} className="h-full w-full object-cover" />
171 + <button type="button" aria-label="Remove" className="absolute right-0.5 top-0.5 rounded-sm bg-black/60 p-0.5 text-white" onClick={() => setImages(images.filter((_, j) => j !== i))}>
172 + <X className="h-3 w-3" />
173 + </button>
174 + </li>
175 + ))}
176 + </ul>
177 + ) : null}
178 + </>
179 + ) : mode === 'url' ? (
180 + <label className="block text-xs text-muted">
181 + Marketplace or auction URL
182 + <input type="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" />
183 + <span className="mt-1 block text-[11px] text-subtle">Supported sources are those with a lookup-capable connector; others fall back to text identification.</span>
184 + </label>
185 + ) : (
186 + <label className="block text-xs text-muted">
187 + Describe the item
188 + <textarea value={text} onChange={(e) => setText(e.target.value)} rows={5} placeholder="e.g. 1999 Pokémon Base Set Charizard holo, 1st edition stamp, PSA 9 slab #12345678" className="mt-1 w-full rounded-md border border-border bg-sunken px-3 py-2 text-sm text-fg placeholder:text-subtle focus:border-border-strong focus:outline-none" />
189 + </label>
190 + )}
191 + <button type="button" disabled={!canSubmit} onClick={() => void submit()} className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-fg disabled:opacity-50">
192 + {busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {busy ? 'Identifying…' : 'Identify & price'}
193 + </button>
194 + {error ? <p className="rounded-md bg-loss-bg px-3 py-2 text-xs text-loss">{error}</p> : null}
195 + <p className="text-[11px] leading-relaxed text-subtle">Photos are downscaled in your browser before upload and are not stored; small thumbnails are kept for quality review. Valuations are estimates, not offers, appraisals or authentication.</p>
196 + </div>
197 + </Card>
198 +
199 + <div className="min-w-0 space-y-4">
200 + {!result && !busy ? <EmptyState title="Results appear here" description="Identification, the closest catalog matches, RareIndex Valuation, comparable sales and live listings." /> : null}
201 + {busy ? (
202 + <Card className="p-6">
203 + <div className="flex items-center gap-3 text-sm text-muted">
204 + <Loader2 className="h-4 w-4 animate-spin" /> Analysing{mode === 'photo' ? ' photos' : mode === 'url' ? ' the listing' : ' the description'} and searching the catalog…
205 + </div>
206 + </Card>
207 + ) : null}
208 + {result ? <ScanResults r={result} chosen={chosen} onChoose={choose} /> : null}
209 + </div>
210 + </div>
211 + );
212 +}
213 +
214 +function ScanResults({ r, chosen, onChoose }: { r: ScanResponse; chosen: string | null; onChoose: (id: string | null) => void }) {
215 + const g = r.guess;
216 + const best = r.best;
217 + return (
218 + <>
219 + {g ? (
220 + <Card>
221 + <CardHeader
222 + title="Identification"
223 + subtitle={r.model ? `Model ${r.model} · ${(r.durationMs / 1000).toFixed(1)} s` : undefined}
224 + action={<Badge tone={(g.confidence ?? 0) >= 0.75 ? 'gain' : (g.confidence ?? 0) >= 0.5 ? 'alert' : 'loss'}>Confidence {confidenceLabel(g.confidence)} · {Math.round((g.confidence ?? 0) * 100)}%</Badge>}
225 + />
226 + <div className="grid gap-x-6 gap-y-2 p-4 text-sm sm:grid-cols-2">
227 + {(
228 + [
229 + ['Category', g.categorySlug],
230 + ['Name', g.name],
231 + ['Brand / franchise', [g.brand, g.franchise].filter(Boolean).join(' · ')],
232 + ['Set', g.set],
233 + ['Number / reference', g.number],
234 + ['Year', g.year],
235 + ['Variant', g.variant],
236 + ['Language', g.language],
237 + ['Grade', g.grader ? `${String(g.grader).toUpperCase()} ${g.grade ?? ''}`.trim() : null],
238 + ['Certification #', g.certificationNumber],
239 + ['Likely grade range', g.likelyGradeRange],
240 + ['Condition notes', g.conditionNotes],
241 + ] as Array<[string, unknown]>
242 + ).map(([k, v]) => (
243 + <div key={k} className="flex justify-between gap-3 border-b border-border py-1 last:border-0">
244 + <span className="text-xs text-subtle">{k}</span>
245 + <span className="text-right">{v === null || v === undefined || v === '' ? <span className="text-subtle">—</span> : String(v)}</span>
246 + </div>
247 + ))}
248 + </div>
249 + {g.rationale ? <p className="border-t border-border px-4 py-3 text-xs text-muted">{g.rationale}</p> : null}
250 + {g.warnings?.length ? (
251 + <ul className="border-t border-border px-4 py-3 text-xs text-alert">
252 + {g.warnings.map((w) => (
253 + <li key={w}>⚠ {w}</li>
254 + ))}
255 + </ul>
256 + ) : null}
257 + </Card>
258 + ) : null}
259 +
260 + {r.listing ? (
261 + <Card>
262 + <CardHeader title="Parsed listing" subtitle={r.listing.sourceId} action={<a href={r.listing.sourceUrl} target="_blank" rel="noreferrer noopener" className="text-muted hover:text-fg">Open source ↗</a>} />
263 + <div className="grid gap-4 p-4 sm:grid-cols-3">
264 + <Stat label="Asking price" value={r.listing.price !== null ? fmtMoney(r.listing.price, r.listing.currency ?? 'USD') : '—'} sub={r.listing.rawTitle} />
265 + <Stat label="vs RareIndex Valuation" value={r.listing.discountToRiv !== null ? <span className={r.listing.discountToRiv < 0 ? 'text-gain' : 'text-loss'}>{fmtPct(r.listing.discountToRiv, 1)}</span> : '—'} sub={{ below_fair_value: 'Below fair value range', in_range: 'Within fair value range', above_fair_value: 'Above fair value range', unknown: 'No valuation to compare' }[r.listing.verdict]} />
266 + <Stat label="Grade on listing" value={r.listing.grader ? `${r.listing.grader.toUpperCase()} ${r.listing.grade ?? ''}` : 'Raw / unknown'} />
267 + </div>
268 + </Card>
269 + ) : null}
270 +
271 + {best ? (
272 + <Card>
273 + <CardHeader title="Best match" subtitle={`Match score ${Math.round(best.score * 100)}%`} action={<Link href={`/asset/${best.slug}`} className="font-medium text-fg hover:underline">Open asset →</Link>} />
274 + <div className="flex gap-4 p-4">
275 + {best.heroImageUrl ? (
276 + // eslint-disable-next-line @next/next/no-img-element
277 + <img src={best.heroImageUrl} alt="" className="h-28 w-20 shrink-0 rounded-sm object-cover" />
278 + ) : null}
279 + <div className="min-w-0 flex-1">
280 + <p className="text-sm font-semibold">{best.title}</p>
281 + <p className="text-xs text-muted">{[best.setName, best.number, best.year].filter(Boolean).join(' · ')}</p>
282 + <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
283 + <Stat label="RareIndex Valuation" value={best.rivUsd ? fmtMoney(best.rivUsd) : '—'} sub={best.rivUsd ? `${fmtMoney(best.rivLowUsd)} – ${fmtMoney(best.rivHighUsd)} · ${confidenceLabel(best.rivConfidence)} · ${best.rivSampleSize} sales` : 'Insufficient evidence'} />
284 + <Stat label="Latest sale" value={best.latestSaleUsd ? fmtMoney(best.latestSaleUsd) : '—'} sub={best.latestSaleAt ? fmtDate(best.latestSaleAt) : undefined} />
285 + <Stat label="Sales tracked" value={best.salesCount} />
286 + <Stat label="Active listings" value={best.activeListings} sub={best.minAskUsd ? `from ${fmtMoney(best.minAskUsd)}` : undefined} />
287 + </div>
288 + </div>
289 + </div>
290 + {best.context.sales.length ? (
291 + <div className="border-t border-border">
292 + <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Recent comparable sales</p>
293 + <ul className="divide-y divide-border px-4 pb-2 text-xs">
294 + {best.context.sales.map((s) => (
295 + <li key={String(s.id)} className="flex items-center justify-between gap-3 py-1.5">
296 + <span className="truncate text-muted">{fmtDate(String(s.saleDate))} · {String(s.sourceId)} {s.grader ? `· ${String(s.grader).toUpperCase()} ${String(s.grade ?? '')}` : ''}</span>
297 + <a href={String(s.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(s.priceUsd))}</a>
298 + </li>
299 + ))}
300 + </ul>
301 + </div>
302 + ) : null}
303 + {best.context.listings.length ? (
304 + <div className="border-t border-border">
305 + <p className="px-4 pt-3 text-[11px] font-semibold uppercase tracking-wider text-subtle">Live listings (asks, not sales)</p>
306 + <ul className="divide-y divide-border px-4 pb-2 text-xs">
307 + {best.context.listings.map((l) => (
308 + <li key={String(l.id)} className="flex items-center justify-between gap-3 py-1.5">
309 + <span className="truncate text-muted">{String(l.sourceId)} · {String(l.rawTitle)}</span>
310 + <a href={String(l.sourceUrl)} target="_blank" rel="noreferrer noopener" className="num font-medium hover:underline">{fmtMoney(Number(l.priceUsd))}</a>
311 + </li>
312 + ))}
313 + </ul>
314 + </div>
315 + ) : null}
316 + <div className="flex items-center justify-between border-t border-border px-4 py-2 text-xs">
317 + <span className="text-subtle">Is this the right item?</span>
318 + <div className="flex gap-2">
319 + <button type="button" onClick={() => onChoose(best.assetId)} className={cn('rounded-md border px-2 py-1', chosen === best.assetId ? 'border-gain bg-gain-bg text-gain' : 'border-border hover:bg-inset')}>Yes</button>
320 + <button type="button" onClick={() => onChoose(null)} className={cn('rounded-md border px-2 py-1', chosen === null && chosen !== undefined && r.candidates.length ? 'border-border hover:bg-inset' : 'border-border hover:bg-inset')}>Not this</button>
321 + </div>
322 + </div>
323 + </Card>
324 + ) : null}
325 +
326 + {r.candidates.length > (best ? 1 : 0) ? (
327 + <Card>
328 + <CardHeader title="Other candidates" subtitle="Pick the correct one to improve future identifications" />
329 + <ul className="divide-y divide-border">
330 + {r.candidates.filter((c) => c.assetId !== best?.assetId).map((c) => (
331 + <li key={c.assetId} className="flex items-center gap-3 px-4 py-2 text-sm">
332 + <Link href={`/asset/${c.slug}`} className="min-w-0 flex-1 truncate hover:underline">{c.title}</Link>
333 + <span className="num text-xs text-muted">{c.rivUsd ? fmtMoney(c.rivUsd) : '—'}</span>
334 + <span className="num text-xs text-subtle">{Math.round(c.score * 100)}%</span>
335 + <button type="button" onClick={() => onChoose(c.assetId)} className={cn('rounded-md border px-2 py-0.5 text-xs', chosen === c.assetId ? 'border-gain bg-gain-bg text-gain' : 'border-border hover:bg-inset')}>This one</button>
336 + </li>
337 + ))}
338 + </ul>
339 + </Card>
340 + ) : null}
341 +
342 + <ul className="space-y-1 text-[11px] leading-relaxed text-subtle">
343 + {r.notes.map((n) => (
344 + <li key={n}>· {n}</li>
345 + ))}
346 + <li>· Scans today: {r.quota.used}/{r.quota.limit}.</li>
347 + </ul>
348 + </>
349 + );
350 +}
added apps/web/src/lib/admin/actions.ts +156 −0
@@ -0,0 +1,156 @@
1 +'use server';
2 +
3 +import { cookies } from 'next/headers';
4 +import { redirect } from 'next/navigation';
5 +import { revalidatePath } from 'next/cache';
6 +import { z } from 'zod';
7 +import { getDb, connectors, connectorRuns, sales, normalizedRecords, taxonomyProposals, categories, auditLog, events, eq, sql } from '@rareindex/database';
8 +import { newId } from '@rareindex/shared';
9 +import { ADMIN_COOKIE, adminCookieValue, isAdmin, tokenMatches } from './auth';
10 +import { enqueue, QUEUES } from './queue';
11 +
12 +async function guard(): Promise<void> {
13 + if (!(await isAdmin())) throw new Error('Admin authentication required');
14 +}
15 +
16 +async function audit(entityType: string, entityId: string, action: string, reason: string, details: Record<string, unknown> = {}) {
17 + await getDb().insert(auditLog).values({ id: newId('event'), entityType, entityId, action, reason, actor: 'admin', details });
18 +}
19 +
20 +export async function adminLogin(formData: FormData): Promise<void> {
21 + const token = String(formData.get('token') ?? '');
22 + const next = String(formData.get('next') ?? '/admin');
23 + if (!process.env.ADMIN_TOKEN) redirect('/admin/login?error=ADMIN_TOKEN+is+not+set+on+the+server');
24 + if (!tokenMatches(token)) redirect('/admin/login?error=Invalid+token');
25 + const jar = await cookies();
26 + jar.set(ADMIN_COOKIE, adminCookieValue()!, { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 12 });
27 + redirect(next.startsWith('/admin') ? next : '/admin');
28 +}
29 +
30 +export async function adminLogout(): Promise<void> {
31 + const jar = await cookies();
32 + jar.delete(ADMIN_COOKIE);
33 + redirect('/admin/login');
34 +}
35 +
36 +const ConnectorAction = z.enum(['run', 'probe', 'recrawl', 'retry', 'pause', 'resume']);
37 +
38 +/** Connector control-center actions (§144): run/recrawl/probe enqueue `crawl.run`; pause/resume flip status. */
39 +export async function connectorAction(formData: FormData): Promise<void> {
40 + await guard();
41 + const id = String(formData.get('id') ?? '');
42 + const action = ConnectorAction.parse(formData.get('action'));
43 + const db = getDb();
44 + const [c] = await db.select().from(connectors).where(eq(connectors.id, id)).limit(1);
45 + if (!c) throw new Error(`unknown connector ${id}`);
46 + if (action === 'pause' || action === 'resume') {
47 + await db.update(connectors).set({ status: action === 'pause' ? 'paused' : 'active', updatedAt: new Date() }).where(eq(connectors.id, id));
48 + await audit('connector', id, action, `admin ${action}`);
49 + } else {
50 + const mode = action === 'recrawl' ? 'backfill' : action === 'probe' ? 'probe' : 'incremental';
51 + const payload: Record<string, unknown> = { connectorId: id, mode, trigger: action === 'retry' ? 'retry' : 'manual', requestedAt: new Date().toISOString() };
52 + if (action === 'probe') payload.limit = 25;
53 + if (action === 'retry') {
54 + const [last] = await db.select().from(connectorRuns).where(eq(connectorRuns.connectorId, id)).orderBy(sql`started_at desc`).limit(1);
55 + if (last) payload.cursor = last.cursor;
56 + }
57 + const jobId = await enqueue(QUEUES.crawlRun, payload, { singletonKey: `${id}:${mode}`, priority: 10 });
58 + await db.insert(events).values({ id: newId('event'), type: 'crawl_requested', entityType: 'connector', entityId: id, payload: { ...payload, jobId } });
59 + await audit('connector', id, action, `admin enqueued crawl.run (${mode})`, { jobId });
60 + }
61 + revalidatePath('/admin/connectors');
62 + revalidatePath(`/admin/connectors/${id}`);
63 +}
64 +
65 +export async function updateConnectorConfig(formData: FormData): Promise<void> {
66 + await guard();
67 + const id = String(formData.get('id') ?? '');
68 + const raw = String(formData.get('config') ?? '{}');
69 + let config: Record<string, unknown>;
70 + try {
71 + config = z.record(z.string(), z.unknown()).parse(JSON.parse(raw));
72 + } catch (err) {
73 + throw new Error(`config must be a JSON object: ${err instanceof Error ? err.message : String(err)}`);
74 + }
75 + const refresh = Number(formData.get('refresh') ?? NaN);
76 + const priority = String(formData.get('priority') ?? 'medium');
77 + await getDb()
78 + .update(connectors)
79 + .set({ config, ...(Number.isFinite(refresh) && refresh > 0 ? { refreshFrequencyMinutes: Math.round(refresh) } : {}), ...(['high', 'medium', 'low'].includes(priority) ? { priority } : {}), updatedAt: new Date() })
80 + .where(eq(connectors.id, id));
81 + await audit('connector', id, 'edited', 'admin updated config/schedule', { config, refresh, priority });
82 + revalidatePath(`/admin/connectors/${id}`);
83 +}
84 +
85 +export async function saleStatusAction(formData: FormData): Promise<void> {
86 + await guard();
87 + const id = String(formData.get('id') ?? '');
88 + const status = z.enum(['valid', 'flagged', 'excluded']).parse(formData.get('status'));
89 + const reason = String(formData.get('reason') ?? '').trim() || `admin set status ${status}`;
90 + const db = getDb();
91 + const [s] = await db.select({ status: sales.status, flags: sales.flags }).from(sales).where(eq(sales.id, id)).limit(1);
92 + if (!s) throw new Error('sale not found');
93 + const flags = status === 'valid' ? s.flags.filter((f) => !f.startsWith('admin:')) : [...new Set([...s.flags, `admin:${status}`])];
94 + await db.update(sales).set({ status, flags }).where(eq(sales.id, id));
95 + await audit('sale', id, status === 'valid' ? 'restored' : status, reason, { from: s.status, to: status });
96 + revalidatePath('/admin/data-quality');
97 +}
98 +
99 +export async function manualMatchAction(formData: FormData): Promise<void> {
100 + await guard();
101 + const recordId = String(formData.get('recordId') ?? '');
102 + const assetId = String(formData.get('assetId') ?? '');
103 + const db = getDb();
104 + if (!assetId) {
105 + await db.update(normalizedRecords).set({ status: 'rejected', rejectReason: 'admin: rejected', processedAt: new Date() }).where(eq(normalizedRecords.id, recordId));
106 + await audit('normalized_record', recordId, 'rejected', 'admin rejected unmatched record');
107 + } else {
108 + await db.update(normalizedRecords).set({ status: 'matched', assetId, matchMethod: 'manual', matchConfidence: 1, rejectReason: null, processedAt: null }).where(eq(normalizedRecords.id, recordId));
109 + await audit('normalized_record', recordId, 'merged', 'admin manual match', { assetId });
110 + await enqueue(QUEUES.normalize, { normalizedRecordId: recordId, reason: 'manual_match' }).catch(() => null);
111 + }
112 + revalidatePath('/admin/data-quality');
113 +}
114 +
115 +export async function taxonomyDecision(formData: FormData): Promise<void> {
116 + await guard();
117 + const id = String(formData.get('id') ?? '');
118 + const decision = z.enum(['approved', 'rejected']).parse(formData.get('decision'));
119 + const db = getDb();
120 + const [p] = await db.select().from(taxonomyProposals).where(eq(taxonomyProposals.id, id)).limit(1);
121 + if (!p) throw new Error('proposal not found');
122 + if (decision === 'approved') {
123 + const parent = String(formData.get('parent') ?? p.parentSlug ?? '') || null;
124 + const [parentRow] = parent ? await db.select().from(categories).where(eq(categories.slug, parent)).limit(1) : [];
125 + const [{ n }] = (await db.execute(sql`select count(*)::int as n from categories`)) as unknown as [{ n: number }];
126 + await db
127 + .insert(categories)
128 + .values({
129 + slug: p.proposedSlug,
130 + parentSlug: parentRow?.slug ?? null,
131 + familySlug: parentRow?.familySlug ?? p.proposedSlug,
132 + name: p.name,
133 + level: parentRow ? parentRow.level + 1 : 0,
134 + phase: 3,
135 + conditionScale: parentRow?.conditionScale ?? 'general',
136 + graders: parentRow?.graders ?? [],
137 + indexTicker: parentRow?.indexTicker ?? null,
138 + sortOrder: n + 1,
139 + description: `Added from taxonomy proposal ${p.id}`,
140 + })
141 + .onConflictDoNothing();
142 + }
143 + await db.update(taxonomyProposals).set({ status: decision, decidedBy: 'admin', decidedAt: new Date().toISOString() }).where(eq(taxonomyProposals.id, id));
144 + await audit('taxonomy_proposal', id, decision, `admin ${decision} ${p.proposedSlug}`);
145 + revalidatePath('/admin/taxonomy');
146 +}
147 +
148 +export async function proposeTaxonomyNode(formData: FormData): Promise<void> {
149 + await guard();
150 + const slug = String(formData.get('slug') ?? '').trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_');
151 + const name = String(formData.get('name') ?? '').trim();
152 + const parent = String(formData.get('parent') ?? '').trim() || null;
153 + if (!slug || !name) throw new Error('slug and name required');
154 + await getDb().insert(taxonomyProposals).values({ id: newId('event').replace('evt_', 'tp_'), proposedSlug: slug, name, parentSlug: parent, evidence: { source: 'admin' } });
155 + revalidatePath('/admin/taxonomy');
156 +}
added apps/web/src/lib/admin/auth.ts +35 −0
@@ -0,0 +1,35 @@
1 +import 'server-only';
2 +import { createHmac, timingSafeEqual } from 'node:crypto';
3 +import { cookies } from 'next/headers';
4 +import { redirect } from 'next/navigation';
5 +
6 +export const ADMIN_COOKIE = 'ri_admin';
7 +
8 +/** Cookie value = HMAC(SESSION_SECRET, ADMIN_TOKEN): the token itself never travels after login. */
9 +export function adminCookieValue(): string | null {
10 + const token = process.env.ADMIN_TOKEN;
11 + if (!token) return null;
12 + return createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update(`admin:${token}`).digest('hex');
13 +}
14 +
15 +export function isValidAdminCookie(value: string | undefined | null): boolean {
16 + const expected = adminCookieValue();
17 + if (!expected || !value || value.length !== expected.length) return false;
18 + return timingSafeEqual(Buffer.from(value), Buffer.from(expected));
19 +}
20 +
21 +export function tokenMatches(token: string): boolean {
22 + const expected = process.env.ADMIN_TOKEN;
23 + if (!expected || token.length !== expected.length) return false;
24 + return timingSafeEqual(Buffer.from(token), Buffer.from(expected));
25 +}
26 +
27 +export async function isAdmin(): Promise<boolean> {
28 + const jar = await cookies();
29 + return isValidAdminCookie(jar.get(ADMIN_COOKIE)?.value);
30 +}
31 +
32 +/** Server-component guard (the proxy already blocks unauthenticated requests; this is defence in depth). */
33 +export async function requireAdmin(): Promise<void> {
34 + if (!(await isAdmin())) redirect('/admin/login');
35 +}
added apps/web/src/lib/admin/queries.ts +121 −0
@@ -0,0 +1,121 @@
1 +import 'server-only';
2 +import { getDb, sql } from '@rareindex/database';
3 +
4 +type Row = Record<string, unknown>;
5 +const run = async <T = Row>(q: ReturnType<typeof sql>): Promise<T[]> => (await getDb().execute(q)) as unknown as T[];
6 +const one = async <T = Row>(q: ReturnType<typeof sql>): Promise<T | null> => (await run<T>(q))[0] ?? null;
7 +
8 +export async function dashboard() {
9 + const counts = await one(sql`select
10 + (select count(*)::int from assets) as assets,
11 + (select count(*)::int from asset_variants) as variants,
12 + (select count(*)::int from sales) as sales,
13 + (select count(*)::int from sales where status = 'flagged') as sales_flagged,
14 + (select count(*)::int from sales where status = 'excluded') as sales_excluded,
15 + (select count(*)::int from listings where availability = 'available') as listings_live,
16 + (select count(*)::int from price_observations) as observations,
17 + (select count(*)::int from raw_records) as raw_records,
18 + (select count(*)::int from raw_records where processed_at is null) as raw_unprocessed,
19 + (select count(*)::int from raw_records where process_error is not null) as raw_errors,
20 + (select count(*)::int from normalized_records where status = 'pending') as normalized_pending,
21 + (select count(*)::int from normalized_records where status = 'unmatched') as normalized_unmatched,
22 + (select count(*)::int from connectors where status = 'active') as connectors_active,
23 + (select count(*)::int from connectors where status = 'paused') as connectors_paused,
24 + (select count(*)::int from connector_health where status in ('failing','degraded')) as connectors_unhealthy,
25 + (select count(*)::int from taxonomy_proposals where status = 'pending') as proposals_pending,
26 + (select max(sale_date) from sales) as latest_sale,
27 + (select max(fetched_at) from raw_records) as latest_fetch,
28 + (select max(computed_at) from valuations) as latest_valuation,
29 + (select max(date) from index_values) as latest_index,
30 + (select coalesce(sum(usd_est),0) from costs where occurred_at >= now() - interval '24 hours') as cost_24h_usd,
31 + (select coalesce(sum(credits),0) from costs where occurred_at >= now() - interval '24 hours' and kind = 'firecrawl') as firecrawl_credits_24h,
32 + (select coalesce(sum(credits),0) from costs where occurred_at >= now() - interval '24 hours' and kind = 'scrapfly') as scrapfly_credits_24h`);
33 + const recentRuns = await run(sql`select r.id, r.connector_id, r.status, r.trigger, r.started_at, r.finished_at, r.pages_attempted, r.pages_success, r.records_raw, r.records_normalized, r.records_duplicate, r.error from connector_runs r order by r.started_at desc limit 12`);
34 + const recentEvents = await run(sql`select type, count(*)::int as n from events where created_at >= now() - interval '24 hours' group by type order by n desc`);
35 + return { counts, recentRuns, recentEvents };
36 +}
37 +
38 +export async function connectorsOverview() {
39 + return run(sql`with last_run as (
40 + select distinct on (connector_id) * from connector_runs order by connector_id, started_at desc),
41 + day as (
42 + select connector_id, sum(pages_attempted)::int as pages, sum(records_raw)::int as records, sum(records_duplicate)::int as duplicates, sum(cost_credits)::float as credits,
43 + greatest(1, extract(epoch from (max(coalesce(finished_at, now())) - min(started_at))) / 60.0) as minutes
44 + from connector_runs where started_at >= now() - interval '24 hours' group by connector_id),
45 + conf as (
46 + select connector_id, avg((payload->>'confidence')::float) as parser_confidence, count(*)::int as normalized_24h from normalized_records where created_at >= now() - interval '24 hours' group by connector_id)
47 + select c.id, c.display_name, c.source_id, c.status, c.priority, c.engine_priority, c.categories, c.refresh_frequency_minutes, c.schema_version, c.connector_version, c.last_run_at, c.last_success_at, c.next_run_at,
48 + h.status as health_status, h.computed_at as health_at, h.health,
49 + lr.id as last_run_id, lr.status as last_run_status, lr.error as last_error, lr.started_at as last_started_at, lr.finished_at as last_finished_at, lr.pages_attempted as last_pages, lr.records_raw as last_records, lr.anomalies as last_anomalies,
50 + d.pages as pages_24h, d.records as records_24h, d.duplicates as duplicates_24h, d.credits as credits_24h, (coalesce(d.pages,0) / coalesce(d.minutes,1)) as pages_per_min,
51 + cf.parser_confidence, cf.normalized_24h
52 + from connectors c
53 + left join connector_health h on h.connector_id = c.id
54 + left join last_run lr on lr.connector_id = c.id
55 + left join day d on d.connector_id = c.id
56 + left join conf cf on cf.connector_id = c.id
57 + order by c.priority = 'high' desc, c.id`);
58 +}
59 +
60 +export async function connectorDetail(id: string) {
61 + const connector = await one(sql`select * from connectors where id = ${id}`);
62 + if (!connector) return null;
63 + const source = await one(sql`select * from sources where id = ${connector.source_id as string}`);
64 + const health = await one(sql`select * from connector_health where connector_id = ${id}`);
65 + const runs = await run(sql`select * from connector_runs where connector_id = ${id} order by started_at desc limit 25`);
66 + const rawSample = await run(sql`select id, kind, url, external_id, engine, fetched_at, http_status, processed_at, process_error, parser_version from raw_records where connector_id = ${id} order by fetched_at desc limit 10`);
67 + const normalized = await run(sql`select status, count(*)::int as n from normalized_records where connector_id = ${id} group by status`);
68 + const normalizedSample = await run(sql`select id, kind, status, match_method, match_confidence, asset_id, reject_reason, created_at, payload->>'rawTitle' as raw_title, payload->>'price' as price, payload->>'currency' as currency from normalized_records where connector_id = ${id} order by created_at desc limit 10`);
69 + const costs = await run(sql`select date_trunc('day', occurred_at)::date as day, kind, sum(credits)::float as credits, sum(usd_est)::float as usd from costs where connector_id = ${id} and occurred_at >= now() - interval '30 days' group by 1, 2 order by 1 desc`);
70 + const outputs = await one(sql`select (select count(*)::int from sales where connector_id = ${id}) as sales, (select count(*)::int from listings where connector_id = ${id}) as listings, (select count(*)::int from price_observations where connector_id = ${id}) as observations`);
71 + const anomalies = runs.flatMap((r) => (Array.isArray(r.anomalies) ? (r.anomalies as string[]) : [])).slice(0, 30);
72 + return { connector, source, health, runs, rawSample, normalized, normalizedSample, costs, outputs, anomalies };
73 +}
74 +
75 +export async function costsOverview(days = 30) {
76 + const byDay = await run(sql`select date_trunc('day', occurred_at)::date as day, kind, sum(credits)::float as credits, sum(usd_est)::float as usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1, 2 order by 1`);
77 + const byKind = await run(sql`select kind, coalesce(provider,'') as provider, sum(credits)::float as credits, sum(usd_est)::float as usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1, 2 order by usd desc`);
78 + const byConnector = await run(sql`select coalesce(connector_id,'—') as connector_id, sum(credits)::float as credits, sum(usd_est)::float as usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1 order by usd desc, credits desc limit 30`);
79 + const byCategory = await run(sql`select coalesce(category_slug,'—') as category_slug, sum(usd_est)::float as usd, sum(credits)::float as credits from costs where occurred_at >= now() - (${days}::int || ' days')::interval group by 1 order by usd desc limit 30`);
80 + const byEndpoint = await run(sql`select coalesce(endpoint,'—') as endpoint, coalesce(metadata->>'model','') as model, sum(usd_est)::float as usd, count(*)::int as events, sum((metadata->'usage'->>'inputTokens')::float) as input_tokens, sum((metadata->'usage'->>'outputTokens')::float) as output_tokens from costs where kind = 'ai' and occurred_at >= now() - (${days}::int || ' days')::interval group by 1, 2 order by usd desc limit 30`);
81 + const totals = await one(sql`select sum(usd_est)::float as usd, sum(credits) filter (where kind='firecrawl')::float as firecrawl_credits, sum(credits) filter (where kind='scrapfly')::float as scrapfly_credits, sum(usd_est) filter (where kind='ai')::float as ai_usd, count(*)::int as events from costs where occurred_at >= now() - (${days}::int || ' days')::interval`);
82 + const perRecord = await one(sql`select (select count(*)::int from raw_records where fetched_at >= now() - (${days}::int || ' days')::interval) as raw_records`);
83 + return { byDay, byKind, byConnector, byCategory, byEndpoint, totals, perRecord };
84 +}
85 +
86 +export async function taxonomyQueue() {
87 + const pending = await run(sql`select * from taxonomy_proposals where status = 'pending' order by volume_estimate desc nulls last, created_at`);
88 + const decided = await run(sql`select * from taxonomy_proposals where status <> 'pending' order by decided_at desc nulls last limit 30`);
89 + const families = await run(sql`select slug, name from categories where level = 0 and active order by sort_order`);
90 + return { pending, decided, families };
91 +}
92 +
93 +export async function dataQuality() {
94 + const flagged = await run(sql`select s.id, s.asset_id, a.slug as asset_slug, a.title, s.status, s.flags, s.price, s.currency, s.price_usd, s.sale_date, s.source_id, s.source_url, s.raw_title, s.confidence, s.data_quality,
95 + (select reason from audit_log l where l.entity_type = 'sale' and l.entity_id = s.id order by l.created_at desc limit 1) as last_reason
96 + from sales s join assets a on a.id = s.asset_id where s.status in ('flagged','excluded') order by s.created_at desc limit 100`);
97 + const unmatched = await run(sql`select n.id, n.connector_id, n.kind, n.reject_reason, n.match_confidence, n.created_at, n.payload->>'rawTitle' as raw_title, n.payload->>'price' as price, n.payload->>'currency' as currency, n.payload->>'sourceUrl' as source_url, n.payload->'attributes'->>'categorySlug' as category_slug
98 + from normalized_records n where n.status in ('unmatched','rejected') order by n.created_at desc limit 100`);
99 + const duplicates = await run(sql`select cross_listing_group_id, count(*)::int as n, min(raw_title) as sample_title, array_agg(source_id) as sources from listings where cross_listing_group_id is not null group by cross_listing_group_id having count(*) > 1 order by n desc limit 30`);
100 + const quality = await one(sql`select avg(data_quality)::float as sales_avg, percentile_cont(0.1) within group (order by data_quality) as sales_p10, (select avg(data_quality)::float from assets) as assets_avg, (select count(*)::int from sales where confidence < 0.6) as low_confidence_sales from sales`);
101 + return { flagged, unmatched, duplicates, quality };
102 +}
103 +
104 +export async function matchCandidatesForRecord(recordId: string) {
105 + const rec = await one(sql`select id, payload from normalized_records where id = ${recordId}`);
106 + if (!rec) return { record: null, candidates: [] };
107 + const payload = rec.payload as { rawTitle?: string; attributes?: { categorySlug?: string } };
108 + const title = payload.rawTitle ?? '';
109 + const candidates = await run(sql`select id, slug, title, category_slug, similarity(title, ${title}) as score from assets where title % ${title} ${payload.attributes?.categorySlug ? sql`and category_slug = ${payload.attributes.categorySlug}` : sql``} order by score desc limit 10`);
110 + return { record: rec, candidates };
111 +}
112 +
113 +export async function auditLog(opts: { entityType?: string; limit?: number } = {}) {
114 + return run(sql`select * from audit_log where true ${opts.entityType ? sql`and entity_type = ${opts.entityType}` : sql``} order by created_at desc limit ${opts.limit ?? 200}`);
115 +}
116 +
117 +export async function eventsLog(opts: { type?: string; limit?: number } = {}) {
118 + const rows = await run(sql`select * from events where true ${opts.type ? sql`and type = ${opts.type}` : sql``} order by created_at desc limit ${opts.limit ?? 200}`);
119 + const types = await run(sql`select type, count(*)::int as n from events where created_at >= now() - interval '7 days' group by type order by n desc`);
120 + return { rows, types };
121 +}
added apps/web/src/lib/admin/queue.ts +57 −0
@@ -0,0 +1,57 @@
1 +import 'server-only';
2 +import { PgBoss } from 'pg-boss';
3 +import { logger } from '@rareindex/shared';
4 +
5 +/**
6 + * Thin pg-boss producer used by the admin console to enqueue pipeline jobs. Consumers live in
7 + * workers/ (agent D). Queue names and payloads are the contract:
8 + * crawl.run { connectorId, mode: 'incremental'|'backfill'|'probe', trigger: 'manual'|'retry'|'schedule', limit? }
9 + * pipeline.normalize { connectorId? }
10 + * valuation.run { assetId? }
11 + */
12 +export const QUEUES = {
13 + crawlRun: 'crawl.run',
14 + normalize: 'pipeline.normalize',
15 + valuation: 'valuation.run',
16 + indices: 'indices.run',
17 +} as const;
18 +
19 +let boss: PgBoss | null = null;
20 +let starting: Promise<PgBoss> | null = null;
21 +
22 +export async function getBoss(): Promise<PgBoss> {
23 + if (boss) return boss;
24 + if (!starting) {
25 + starting = (async () => {
26 + const b = new PgBoss({ connectionString: process.env.DATABASE_URL ?? 'postgres://localhost:5432/rareindex', application_name: 'rareindex-web-admin', max: 2, supervise: false, schedule: false });
27 + b.on('error', (err: Error) => logger.warn({ err: err.message }, 'pg-boss error'));
28 + await b.start();
29 + boss = b;
30 + return b;
31 + })();
32 + }
33 + return starting;
34 +}
35 +
36 +export async function enqueue(queue: string, data: Record<string, unknown>, opts: { singletonKey?: string; priority?: number } = {}): Promise<string | null> {
37 + const b = await getBoss();
38 + await b.createQueue(queue).catch(() => {});
39 + return b.send(queue, data, { ...(opts.singletonKey ? { singletonKey: opts.singletonKey } : {}), ...(opts.priority !== undefined ? { priority: opts.priority } : {}), retryLimit: 2, expireInSeconds: 3600 });
40 +}
41 +
42 +export async function queueDepths(): Promise<Array<{ name: string; queued: number; active: number; failed: number }>> {
43 + try {
44 + const b = await getBoss();
45 + const out: Array<{ name: string; queued: number; active: number; failed: number }> = [];
46 + for (const name of Object.values(QUEUES)) {
47 + const q = await b.getQueue(name).catch(() => null);
48 + if (!q) continue;
49 + const stats = (await (b as unknown as { getQueueStats?: (n: string) => Promise<{ queuedCount?: number; activeCount?: number; failedCount?: number }> }).getQueueStats?.(name).catch(() => null)) ?? null;
50 + out.push({ name, queued: stats?.queuedCount ?? 0, active: stats?.activeCount ?? 0, failed: stats?.failedCount ?? 0 });
51 + }
52 + return out;
53 + } catch (err) {
54 + logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'queue depth unavailable');
55 + return [];
56 + }
57 +}
added apps/web/src/lib/ai/candidates.ts +80 −0
@@ -0,0 +1,80 @@
1 +import 'server-only';
2 +import { getDb, sql } from '@rareindex/database';
3 +import { descendants } from '@rareindex/taxonomy';
4 +import type { Identification } from '@rareindex/ai';
5 +
6 +export interface Candidate {
7 + assetId: string;
8 + slug: string;
9 + title: string;
10 + categorySlug: string;
11 + heroImageUrl: string | null;
12 + year: number | null;
13 + setName: string | null;
14 + number: string | null;
15 + variant: string | null;
16 + score: number;
17 + rivUsd: number | null;
18 + rivLowUsd: number | null;
19 + rivHighUsd: number | null;
20 + rivConfidence: number | null;
21 + rivSampleSize: number;
22 + latestSaleUsd: number | null;
23 + latestSaleAt: string | null;
24 + salesCount: number;
25 + activeListings: number;
26 + minAskUsd: number | null;
27 +}
28 +
29 +/**
30 + * Candidate assets for an identification guess (§114): identifier hits first, then trigram/FTS
31 + * over titles restricted to the guessed category subtree. Pure DB, no AI.
32 + */
33 +export async function findCandidates(guess: Pick<Identification, 'categorySlug' | 'name' | 'set' | 'number' | 'year' | 'variant' | 'brand' | 'searchQueries' | 'certificationNumber'>, identifiers: Record<string, string> = {}, limit = 8): Promise<Candidate[]> {
34 + const db = getDb();
35 + const scope = guess.categorySlug ? [guess.categorySlug, ...descendants(guess.categorySlug)] : null;
36 + const scopeSql = scope ? sql`and a.category_slug in (${sql.join(scope.map((s) => sql`${s}`), sql`, `)})` : sql``;
37 + const found = new Map<string, Candidate>();
38 +
39 + const cols = sql`a.id as "assetId", a.slug, a.title, a.category_slug as "categorySlug", a.hero_image_url as "heroImageUrl", a.year, a.set_name as "setName", a.number, a.variant,
40 + s.riv_usd as "rivUsd", s.riv_low_usd as "rivLowUsd", s.riv_high_usd as "rivHighUsd", s.riv_confidence as "rivConfidence", coalesce(s.riv_sample_size,0) as "rivSampleSize", s.latest_sale_usd as "latestSaleUsd", s.latest_sale_at as "latestSaleAt", coalesce(s.sales_count,0) as "salesCount", coalesce(s.active_listings,0) as "activeListings", s.min_ask_usd as "minAskUsd"`;
41 +
42 + // 1) deterministic identifiers
43 + const idEntries = Object.entries(identifiers).filter(([, v]) => v);
44 + for (const [k, v] of idEntries) {
45 + const rows = (await db.execute(sql`select ${cols}, 1.0 as score from assets a left join asset_stats s on s.asset_id = a.id where a.identifiers ->> ${k} = ${v} limit 3`)) as unknown as Candidate[];
46 + for (const r of rows) found.set(r.assetId, { ...r, score: 1 });
47 + }
48 +
49 + // 2) text queries: model-proposed search strings + a composed one
50 + const composed = [guess.year, guess.brand, guess.set, guess.name, guess.number, guess.variant].filter(Boolean).join(' ');
51 + const queries = [...new Set([composed, ...(guess.searchQueries ?? [])].map((q) => q.trim()).filter((q) => q.length >= 3))].slice(0, 4);
52 + for (const q of queries) {
53 + const tsq = q
54 + .split(/\s+/)
55 + .map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, ''))
56 + .filter(Boolean)
57 + .map((t) => `${t}:*`)
58 + .join(' | ');
59 + const rows = (await db.execute(sql`select ${cols},
60 + (similarity(a.title, ${q}) * 0.6 + coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})), 0) * 0.4
61 + + case when ${guess.number ?? null}::text is not null and a.number = ${guess.number ?? null}::text then 0.25 else 0 end
62 + + case when ${guess.year ?? null}::int is not null and a.year = ${guess.year ?? null}::int then 0.1 else 0 end) as score
63 + from assets a left join asset_stats s on s.asset_id = a.id
64 + where (a.title % ${q} or a.search @@ to_tsquery('simple', ${tsq})) ${scopeSql}
65 + order by score desc limit ${limit}`)) as unknown as Candidate[];
66 + for (const r of rows) {
67 + const prev = found.get(r.assetId);
68 + if (!prev || prev.score < Number(r.score)) found.set(r.assetId, { ...r, score: Number(r.score) });
69 + }
70 + }
71 + return [...found.values()].sort((a, b) => b.score - a.score).slice(0, limit);
72 +}
73 +
74 +export async function assetContext(assetId: string) {
75 + const db = getDb();
76 + const sales = (await db.execute(sql`select id, sale_date as "saleDate", price, currency, price_usd as "priceUsd", grader, grade, source_id as "sourceId", source_url as "sourceUrl", raw_title as "rawTitle" from sales where asset_id = ${assetId} and status = 'valid' order by sale_date desc limit 8`)) as unknown as Array<Record<string, unknown>>;
77 + const listings = (await db.execute(sql`select id, price, currency, price_usd as "priceUsd", source_id as "sourceId", source_url as "sourceUrl", grader, grade, condition, raw_title as "rawTitle", discount_to_riv as "discountToRiv" from listings where asset_id = ${assetId} and availability = 'available' order by price_usd asc nulls last limit 8`)) as unknown as Array<Record<string, unknown>>;
78 + const variants = (await db.execute(sql`select v.id, v.label, v.grader, v.grade, vs.riv_usd as "rivUsd", vs.riv_confidence as "rivConfidence", vs.riv_sample_size as "rivSampleSize", vs.sales_count as "salesCount" from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${assetId} order by vs.sales_count desc nulls last limit 12`)) as unknown as Array<Record<string, unknown>>;
79 + return { sales, listings, variants };
80 +}
added apps/web/src/lib/ai/lookup.ts +46 −0
@@ -0,0 +1,46 @@
1 +import 'server-only';
2 +import { createHmac } from 'node:crypto';
3 +import type { NormalizedRecord } from '@rareindex/shared';
4 +import { NormalizedRecordSchema } from '@rareindex/shared';
5 +
6 +/**
7 + * URL lookup is delegated to the API service (apps/api `/internal/lookup`): connector modules are
8 + * loaded dynamically from disk, which the Next.js bundler cannot do. Shared secret = SESSION_SECRET.
9 + */
10 +export function apiBaseUrl(): string {
11 + return process.env.RI_API_URL ?? `http://127.0.0.1:${process.env.API_PORT ?? 8211}`;
12 +}
13 +
14 +export function internalToken(): string {
15 + return createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update('internal').digest('hex');
16 +}
17 +
18 +export interface LookupResult {
19 + connectorId: string;
20 + sourceId: string;
21 + records: NormalizedRecord[];
22 + durationMs: number;
23 +}
24 +
25 +export async function lookupViaApi(url: string, timeoutMs = 90_000): Promise<LookupResult | null | 'unavailable'> {
26 + const ctrl = new AbortController();
27 + const t = setTimeout(() => ctrl.abort(), timeoutMs);
28 + try {
29 + const res = await fetch(`${apiBaseUrl()}/internal/lookup`, {
30 + method: 'POST',
31 + headers: { 'content-type': 'application/json', 'x-internal-token': internalToken() },
32 + body: JSON.stringify({ url }),
33 + signal: ctrl.signal,
34 + cache: 'no-store',
35 + });
36 + if (!res.ok) return 'unavailable';
37 + const json = (await res.json()) as { data: { connectorId: string; sourceId: string; records: unknown[] } | null; meta: { supported: boolean; durationMs?: number } };
38 + if (!json.data) return null;
39 + const records = json.data.records.map((r) => NormalizedRecordSchema.parse(r));
40 + return { connectorId: json.data.connectorId, sourceId: json.data.sourceId, records, durationMs: json.meta.durationMs ?? 0 };
41 + } catch {
42 + return 'unavailable';
43 + } finally {
44 + clearTimeout(t);
45 + }
46 +}
added apps/web/src/lib/ai/request.ts +75 −0
@@ -0,0 +1,75 @@
1 +import 'server-only';
2 +import { createHmac, randomUUID } from 'node:crypto';
3 +import { cookies, headers } from 'next/headers';
4 +import { getDb, aiQuotas, sql } from '@rareindex/database';
5 +
6 +const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only';
7 +
8 +/** Stable, non-reversible identifier for the caller's IP (quota keys, abuse review). */
9 +export async function clientIpHash(): Promise<string> {
10 + const h = await headers();
11 + const ip = (h.get('x-forwarded-for')?.split(',')[0] ?? h.get('x-real-ip') ?? '0.0.0.0').trim();
12 + return createHmac('sha256', SECRET()).update(ip).digest('hex').slice(0, 32);
13 +}
14 +
15 +export const ANON_COOKIE = 'ri_anon';
16 +
17 +/** Anonymous visitor id (httpOnly cookie) used to thread research sessions and scanner history. */
18 +export async function getAnonId(create = true): Promise<string | null> {
19 + const jar = await cookies();
20 + const existing = jar.get(ANON_COOKIE)?.value;
21 + if (existing && /^[a-f0-9-]{36}$/.test(existing)) return existing;
22 + if (!create) return null;
23 + const id = randomUUID();
24 + try {
25 + jar.set(ANON_COOKIE, id, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 365 });
26 + } catch {
27 + /* cannot set cookies during render; caller falls back to null */
28 + return null;
29 + }
30 + return id;
31 +}
32 +
33 +/**
34 + * Signed-in user id, if any. Integration point for the account module: it reads the session
35 + * cookie (`ri_session`, falling back to `session`) and validates it against the `sessions` table.
36 + * If the auth module uses another cookie name or token format, adapt this one function only.
37 + */
38 +export async function currentUserId(): Promise<string | null> {
39 + try {
40 + const jar = await cookies();
41 + const token = jar.get('ri_session')?.value ?? jar.get('session')?.value;
42 + if (!token || token.length > 200) return null;
43 + const [row] = await getDb().execute(sql`select user_id from sessions where id = ${token} and expires_at > now() limit 1`);
44 + return ((row as { user_id?: string } | undefined)?.user_id) ?? null;
45 + } catch {
46 + return null;
47 + }
48 +}
49 +
50 +export const QUOTAS: Record<string, { anonymous: number; user: number }> = {
51 + scanner: { anonymous: 20, user: 100 },
52 + research: { anonymous: 40, user: 300 },
53 +};
54 +
55 +/** Consume one unit of a daily quota. Returns remaining (−1 when exceeded). */
56 +export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId: string | null; ipHash: string }): Promise<{ ok: boolean; used: number; limit: number }> {
57 + const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous;
58 + const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`;
59 + const date = new Date().toISOString().slice(0, 10);
60 + const db = getDb();
61 + const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`);
62 + const used = Number((row as { count?: number } | undefined)?.count ?? 0);
63 + if (used >= limit) return { ok: false, used, limit };
64 + if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`);
65 + else await db.insert(aiQuotas).values({ key, feature, date, count: 1 });
66 + return { ok: true, used: used + 1, limit };
67 +}
68 +
69 +export function aiErrorMessage(err: unknown): { status: number; message: string } {
70 + const e = err as { code?: string; message?: string; status?: number };
71 + if (e?.code === 'ai_not_configured') return { status: 503, message: 'AI provider not configured. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY) on the server.' };
72 + if (e?.code === 'ai_refusal') return { status: 422, message: `The model declined this request${e.message ? `: ${e.message}` : ''}.` };
73 + if (typeof e?.status === 'number' && e.status === 429) return { status: 429, message: 'The AI provider is rate limiting requests. Try again shortly.' };
74 + return { status: 500, message: e?.message ?? 'Unexpected error' };
75 +}
added apps/web/src/lib/ai/research-tools.ts +259 −0
@@ -0,0 +1,259 @@
1 +import 'server-only';
2 +import { z } from 'zod';
3 +import { getDb, sql } from '@rareindex/database';
4 +import { CATEGORIES, descendants } from '@rareindex/taxonomy';
5 +import type { ToolDefinition } from '@rareindex/ai';
6 +
7 +/**
8 + * Structured tools for AI Research (§128). The model can only call these; each one is a fixed,
9 + * parameterised query — never free-form SQL. Results are compact and always carry provenance
10 + * columns (slug/url/date) so the assistant can cite them.
11 + */
12 +type Row = Record<string, unknown>;
13 +const run = async (q: ReturnType<typeof sql>): Promise<Row[]> => (await getDb().execute(q)) as unknown as Row[];
14 +
15 +const WINDOWS = { '7d': 'change_7d', '30d': 'change_30d', '90d': 'change_90d', '1y': 'change_1y' } as const;
16 +type Window = keyof typeof WINDOWS;
17 +
18 +function scope(category?: string | null) {
19 + if (!category) return sql``;
20 + const slugs = [category, ...descendants(category)];
21 + return sql`and a.category_slug in (${sql.join(slugs.map((s) => sql`${s}`), sql`, `)})`;
22 +}
23 +
24 +const ASSET_COLS = sql`a.slug, a.title, a.category_slug, a.year, a.hero_image_url, s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, s.riv_sample_size, s.latest_sale_usd, s.latest_sale_at, s.change_7d, s.change_30d, s.change_90d, s.change_1y, s.sales_count, s.sales_30d, s.active_listings, s.min_ask_usd, s.liquidity_score, s.rarity_score, s.trending_score, s.value_opportunity`;
25 +
26 +export const TOOL_SCHEMAS = {
27 + search_assets: z.object({ query: z.string().min(1).max(200), category: z.string().nullable().optional(), limit: z.number().int().min(1).max(25).default(10) }),
28 + get_asset: z.object({ slug: z.string() }),
29 + get_asset_sales: z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }),
30 + get_asset_listings: z.object({ slug: z.string(), limit: z.number().int().min(1).max(50).default(20) }),
31 + get_price_history: z.object({ slug: z.string(), days: z.number().int().min(7).max(3650).default(365) }),
32 + list_categories: z.object({ family: z.string().nullable().optional() }),
33 + get_category_market: z.object({ category: z.string() }),
34 + get_index: z.object({ ticker: z.string() }),
35 + get_index_history: z.object({ ticker: z.string(), days: z.number().int().min(7).max(3650).default(365) }),
36 + top_movers: z.object({ category: z.string().nullable().optional(), window: z.enum(['7d', '30d', '90d', '1y']).default('30d'), direction: z.enum(['gainers', 'losers']).default('gainers'), min_sales: z.number().int().min(0).default(3), limit: z.number().int().min(1).max(25).default(10) }),
37 + compare: z.object({ items: z.array(z.string()).min(2).max(6).describe('asset slugs, category slugs or index tickers'), window: z.enum(['7d', '30d', '90d', '1y']).default('1y') }),
38 + screen: z.object({
39 + category: z.string().nullable().optional(),
40 + min_price_usd: z.number().nullable().optional(),
41 + max_price_usd: z.number().nullable().optional(),
42 + min_change: z.number().nullable().optional().describe('minimum return over the window as a fraction, e.g. 0.3'),
43 + max_change: z.number().nullable().optional(),
44 + window: z.enum(['7d', '30d', '90d', '1y']).default('1y'),
45 + min_sales: z.number().int().min(0).default(3),
46 + max_sales_1y: z.number().int().nullable().optional(),
47 + min_liquidity: z.number().min(0).max(100).nullable().optional(),
48 + sort: z.enum(['change', 'value', 'liquidity', 'sales', 'trending', 'opportunity']).default('change'),
49 + limit: z.number().int().min(1).max(50).default(20),
50 + }),
51 + record_sales: z.object({ category: z.string().nullable().optional(), limit: z.number().int().min(1).max(25).default(10) }),
52 + radar: z.object({ kind: z.string().nullable().optional(), limit: z.number().int().min(1).max(25).default(10) }),
53 + platform_coverage: z.object({}),
54 +} as const;
55 +
56 +export type ToolName = keyof typeof TOOL_SCHEMAS;
57 +
58 +const DESCRIPTIONS: Record<ToolName, string> = {
59 + search_assets: 'Full-text search over canonical assets (cards, watches, sneakers, LEGO, games…). Returns slugs, valuations and activity. Use before any asset-specific tool when you only have a name.',
60 + get_asset: 'Asset detail: attributes, RareIndex Valuation with low/high/confidence/sample size, changes, sales/listings counts, variants (grades) with their own valuations.',
61 + get_asset_sales: 'Recent observed sales for an asset (date, price, currency, USD, grade, source URL).',
62 + get_asset_listings: 'Current listings (asks, not sales) for an asset with discount vs RIV.',
63 + get_price_history: 'Daily RIV / latest-sale / median / volume series for an asset over N days.',
64 + list_categories: 'Taxonomy with tracked asset counts; optionally children of a family.',
65 + get_category_market: 'Category market snapshot: index value, tracked assets, sales, volume, median sale, listings, changes; plus top gainers/losers and most valuable.',
66 + get_index: 'RareIndex index (RARE or RARE-XXX) latest value, changes, constituents, transactions, market cap estimate with confidence.',
67 + get_index_history: 'Index daily values over N days.',
68 + top_movers: 'Best or worst performing assets over a window, optionally within a category, requiring a minimum number of sales.',
69 + compare: 'Compare returns over a window across asset slugs, category slugs and/or index tickers (mixed allowed). Returns start/end values and return.',
70 + screen: 'Screen assets by category, price range, return over window, sales counts, liquidity; sort by change/value/liquidity/sales/trending/opportunity.',
71 + record_sales: 'Highest verified sales (optionally within a category).',
72 + radar: 'Rare Radar findings: first listing in years, ultra-low population, unusual price discrepancy, record sale…',
73 + platform_coverage: 'Counts of assets, sales, listings, sources and the latest data timestamps — use to explain coverage limits.',
74 +};
75 +
76 +export function toolDefinitions(): ToolDefinition[] {
77 + return (Object.keys(TOOL_SCHEMAS) as ToolName[]).map((name) => ({
78 + name,
79 + description: DESCRIPTIONS[name],
80 + inputSchema: z.toJSONSchema(TOOL_SCHEMAS[name], { target: 'draft-2020-12', io: 'input' }) as Record<string, unknown>,
81 + }));
82 +}
83 +
84 +async function assetBySlug(slug: string) {
85 + const [a] = await run(sql`select a.id, ${ASSET_COLS}, a.brand, a.franchise, a.set_name, a.number, a.variant, a.edition, a.language, a.identifiers from assets a left join asset_stats s on s.asset_id = a.id where a.slug = ${slug} or a.id = ${slug} limit 1`);
86 + return a ?? null;
87 +}
88 +
89 +export async function executeTool(name: string, rawInput: unknown): Promise<unknown> {
90 + if (!(name in TOOL_SCHEMAS)) throw new Error(`unknown tool ${name}`);
91 + const schema = TOOL_SCHEMAS[name as ToolName];
92 + const input = schema.parse(rawInput ?? {}) as z.infer<typeof schema>;
93 + switch (name as ToolName) {
94 + case 'search_assets': {
95 + const i = input as z.infer<typeof TOOL_SCHEMAS.search_assets>;
96 + const tsq = i.query.split(/\s+/).map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, '')).filter(Boolean).map((t) => `${t}:*`).join(' & ');
97 + const rows = await run(sql`select ${ASSET_COLS}, (coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})),0)*2 + similarity(a.title, ${i.query})) as score
98 + from assets a left join asset_stats s on s.asset_id = a.id where (a.search @@ to_tsquery('simple', ${tsq}) or a.title % ${i.query}) ${scope(i.category)} order by score desc, s.sales_count desc nulls last limit ${i.limit}`);
99 + return { count: rows.length, results: rows };
100 + }
101 + case 'get_asset': {
102 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_asset>;
103 + const a = await assetBySlug(i.slug);
104 + if (!a) return { error: 'asset not found', hint: 'use search_assets first' };
105 + const variants = await run(sql`select v.label, v.grader, v.grade, vs.riv_usd, vs.riv_confidence, vs.riv_sample_size, vs.sales_count, vs.latest_sale_usd, vs.change_30d, vs.change_1y from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${a.id as string} order by vs.sales_count desc nulls last limit 15`);
106 + const rest = { ...a };
107 + delete rest.id;
108 + return { ...rest, url: `/asset/${a.slug as string}`, variants };
109 + }
110 + case 'get_asset_sales': {
111 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_asset_sales>;
112 + const a = await assetBySlug(i.slug);
113 + if (!a) return { error: 'asset not found' };
114 + const sales = await run(sql`select sale_date, price, currency, price_usd, grader, grade, condition, sale_type, source_id, source_url, auction_house from sales where asset_id = ${a.id as string} and status = 'valid' order by sale_date desc limit ${i.limit}`);
115 + return { asset: a.title, count: sales.length, sales };
116 + }
117 + case 'get_asset_listings': {
118 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_asset_listings>;
119 + const a = await assetBySlug(i.slug);
120 + if (!a) return { error: 'asset not found' };
121 + const listings = await run(sql`select price, currency, price_usd, grader, grade, condition, seller, source_id, source_url, ends_at, discount_to_riv, listing_type from listings where asset_id = ${a.id as string} and availability = 'available' order by price_usd asc nulls last limit ${i.limit}`);
122 + return { asset: a.title, note: 'asking prices, not transactions', count: listings.length, listings };
123 + }
124 + case 'get_price_history': {
125 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_price_history>;
126 + const a = await assetBySlug(i.slug);
127 + if (!a) return { error: 'asset not found' };
128 + const rows = await run(sql`select date, riv_usd, latest_sale_usd, median_usd, sales_count, volume_usd, listings_count from price_snapshots where asset_id = ${a.id as string} and variant_id = '' and date >= current_date - ${i.days}::int order by date`);
129 + return { asset: a.title, points: thin(rows, 120) };
130 + }
131 + case 'list_categories': {
132 + const i = input as z.infer<typeof TOOL_SCHEMAS.list_categories>;
133 + const rows = await run(sql`select c.slug, c.name, c.parent_slug, c.family_slug, c.level, c.phase, c.index_ticker, (select count(*)::int from assets a where a.category_slug = c.slug) as tracked_assets from categories c where c.active ${i.family ? sql`and (c.family_slug = ${i.family})` : sql`and c.level = 0`} order by c.sort_order`);
134 + return { count: rows.length, categories: rows };
135 + }
136 + case 'get_category_market': {
137 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_category_market>;
138 + const cat = CATEGORIES.find((c) => c.slug === i.category);
139 + if (!cat) return { error: 'unknown category', hint: 'call list_categories' };
140 + const [snap] = await run(sql`select * from category_snapshots where category_slug = ${i.category} order by date desc limit 1`);
141 + const [counts] = await run(sql`select count(*)::int as tracked_assets, count(s.riv_usd)::int as valued_assets, coalesce(sum(s.sales_count),0)::int as sales_total, coalesce(sum(s.active_listings),0)::int as active_listings, percentile_cont(0.5) within group (order by s.riv_usd) as median_riv_usd from assets a left join asset_stats s on s.asset_id = a.id where true ${scope(i.category)}`);
142 + const gainers = await run(sql`select a.slug, a.title, s.riv_usd, s.change_30d, s.sales_count from assets a join asset_stats s on s.asset_id = a.id where s.change_30d is not null and s.riv_sample_size >= 3 ${scope(i.category)} order by s.change_30d desc limit 5`);
143 + const losers = await run(sql`select a.slug, a.title, s.riv_usd, s.change_30d, s.sales_count from assets a join asset_stats s on s.asset_id = a.id where s.change_30d is not null and s.riv_sample_size >= 3 ${scope(i.category)} order by s.change_30d asc limit 5`);
144 + const valuable = await run(sql`select a.slug, a.title, s.riv_usd, s.riv_confidence, s.sales_count from assets a join asset_stats s on s.asset_id = a.id where s.riv_usd is not null ${scope(i.category)} order by s.riv_usd desc limit 5`);
145 + return { category: { slug: cat.slug, name: cat.name, index: cat.index }, snapshot: snap ?? null, counts, gainers, losers, most_valuable: valuable, url: `/markets/${cat.slug}` };
146 + }
147 + case 'get_index': {
148 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_index>;
149 + const t = i.ticker.toUpperCase();
150 + const [idx] = await run(sql`select i.ticker, i.name, i.description, i.methodology, i.weighting, i.base_date, i.base_value, i.min_constituents from indices i where i.ticker = ${t}`);
151 + if (!idx) return { error: 'unknown index' };
152 + const [latest] = await run(sql`select v.* from index_values v join indices i on i.id = v.index_id where i.ticker = ${t} order by v.date desc limit 1`);
153 + if (!latest) return { ...idx, published: false, note: 'Index not yet published: not enough priced constituents.' };
154 + const change = async (days: number) => {
155 + const [p] = await run(sql`select value from index_values v join indices i on i.id = v.index_id where i.ticker = ${t} and v.date <= ${latest.date as string}::date - ${days}::int order by v.date desc limit 1`);
156 + return p && Number(p.value) > 0 ? (Number(latest.value) - Number(p.value)) / Number(p.value) : null;
157 + };
158 + return { ...idx, published: true, as_of: latest.date, value: latest.value, constituents: latest.constituents_count, transactions: latest.transactions, volume_usd: latest.volume_usd, median_sale_usd: latest.median_sale_usd, market_cap_est_usd: latest.market_cap_est_usd, market_cap_confidence: latest.market_cap_confidence, liquidity_score: latest.liquidity_score, momentum: latest.momentum, change_1d: await change(1), change_7d: await change(7), change_30d: await change(30), change_1y: await change(365), url: `/rareindex/${t}` };
159 + }
160 + case 'get_index_history': {
161 + const i = input as z.infer<typeof TOOL_SCHEMAS.get_index_history>;
162 + const rows = await run(sql`select v.date, v.value, v.transactions, v.volume_usd from index_values v join indices i on i.id = v.index_id where i.ticker = ${i.ticker.toUpperCase()} and v.date >= current_date - ${i.days}::int order by v.date`);
163 + return { ticker: i.ticker.toUpperCase(), points: thin(rows, 120) };
164 + }
165 + case 'top_movers': {
166 + const i = input as z.infer<typeof TOOL_SCHEMAS.top_movers>;
167 + const col = sql.raw(`s.${WINDOWS[i.window as Window]}`);
168 + const order = i.direction === 'gainers' ? sql`desc` : sql`asc`;
169 + const rows = await run(sql`select ${ASSET_COLS} from assets a join asset_stats s on s.asset_id = a.id where ${col} is not null and s.riv_sample_size >= ${i.min_sales} ${scope(i.category)} order by ${col} ${order} limit ${i.limit}`);
170 + return { window: i.window, direction: i.direction, count: rows.length, results: rows };
171 + }
172 + case 'compare': {
173 + const i = input as z.infer<typeof TOOL_SCHEMAS.compare>;
174 + const days = { '7d': 7, '30d': 30, '90d': 90, '1y': 365 }[i.window as Window];
175 + const out: Row[] = [];
176 + for (const item of i.items) {
177 + const up = item.toUpperCase();
178 + if (up === 'RARE' || up.startsWith('RARE-')) {
179 + const [end] = await run(sql`select v.date, v.value from index_values v join indices x on x.id = v.index_id where x.ticker = ${up} order by v.date desc limit 1`);
180 + const [start] = end ? await run(sql`select v.date, v.value from index_values v join indices x on x.id = v.index_id where x.ticker = ${up} and v.date <= ${end.date as string}::date - ${days}::int order by v.date desc limit 1`) : [];
181 + out.push({ item: up, type: 'index', start_date: start?.date ?? null, start_value: start?.value ?? null, end_date: end?.date ?? null, end_value: end?.value ?? null, return: ret(start?.value, end?.value) });
182 + continue;
183 + }
184 + const cat = CATEGORIES.find((c) => c.slug === item);
185 + if (cat) {
186 + const [end] = await run(sql`select date, index_value, median_sale_usd from category_snapshots where category_slug = ${item} and index_value is not null order by date desc limit 1`);
187 + const [start] = end ? await run(sql`select date, index_value from category_snapshots where category_slug = ${item} and index_value is not null and date <= ${end.date as string}::date - ${days}::int order by date desc limit 1`) : [];
188 + out.push({ item, type: 'category', start_date: start?.date ?? null, start_value: start?.index_value ?? null, end_date: end?.date ?? null, end_value: end?.index_value ?? null, return: ret(start?.index_value, end?.index_value) });
189 + continue;
190 + }
191 + const a = await assetBySlug(item);
192 + if (!a) {
193 + out.push({ item, type: 'unknown', error: 'not found' });
194 + continue;
195 + }
196 + const [end] = await run(sql`select date, riv_usd from price_snapshots where asset_id = ${a.id as string} and variant_id = '' and riv_usd is not null order by date desc limit 1`);
197 + const [start] = end ? await run(sql`select date, riv_usd from price_snapshots where asset_id = ${a.id as string} and variant_id = '' and riv_usd is not null and date <= ${end.date as string}::date - ${days}::int order by date desc limit 1`) : [];
198 + out.push({ item, type: 'asset', title: a.title, start_date: start?.date ?? null, start_value: start?.riv_usd ?? null, end_date: end?.date ?? null, end_value: end?.riv_usd ?? null, return: ret(start?.riv_usd, end?.riv_usd), riv_usd: a.riv_usd, sales_count: a.sales_count });
199 + }
200 + return { window: i.window, items: out, note: 'return = null means insufficient history for the window' };
201 + }
202 + case 'screen': {
203 + const i = input as z.infer<typeof TOOL_SCHEMAS.screen>;
204 + const col = sql.raw(`s.${WINDOWS[i.window as Window]}`);
205 + const sortCol = { change: col, value: sql`s.riv_usd`, liquidity: sql`s.liquidity_score`, sales: sql`s.sales_count`, trending: sql`s.trending_score`, opportunity: sql`s.value_opportunity` }[i.sort as string] ?? col;
206 + const rows = await run(sql`select ${ASSET_COLS} from assets a join asset_stats s on s.asset_id = a.id where s.riv_sample_size >= ${i.min_sales} ${scope(i.category)}
207 + ${i.min_price_usd != null ? sql`and s.riv_usd >= ${i.min_price_usd}` : sql``} ${i.max_price_usd != null ? sql`and s.riv_usd <= ${i.max_price_usd}` : sql``}
208 + ${i.min_change != null ? sql`and ${col} >= ${i.min_change}` : sql``} ${i.max_change != null ? sql`and ${col} <= ${i.max_change}` : sql``}
209 + ${i.max_sales_1y != null ? sql`and s.sales_1y <= ${i.max_sales_1y}` : sql``} ${i.min_liquidity != null ? sql`and s.liquidity_score >= ${i.min_liquidity}` : sql``}
210 + order by ${sortCol} desc nulls last limit ${i.limit}`);
211 + return { filters: i, count: rows.length, results: rows };
212 + }
213 + case 'record_sales': {
214 + const i = input as z.infer<typeof TOOL_SCHEMAS.record_sales>;
215 + const rows = await run(sql`select a.slug, a.title, a.category_slug, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.source_id, sa.source_url, sa.auction_house from sales sa join assets a on a.id = sa.asset_id where sa.status = 'valid' and sa.confidence >= 0.8 and sa.is_bundle = false ${scope(i.category)} order by sa.price_usd desc limit ${i.limit}`);
216 + return { count: rows.length, sales: rows };
217 + }
218 + case 'radar': {
219 + const i = input as z.infer<typeof TOOL_SCHEMAS.radar>;
220 + const rows = await run(sql`select r.kind, r.score, r.evidence, r.detected_at, a.slug, a.title from radar_findings r join assets a on a.id = r.asset_id where (r.expires_at is null or r.expires_at > now()) ${i.kind ? sql`and r.kind = ${i.kind}` : sql``} order by r.detected_at desc limit ${i.limit}`);
221 + return { count: rows.length, findings: rows };
222 + }
223 + case 'platform_coverage': {
224 + const [r] = await run(sql`select (select count(*)::int from assets) as assets, (select count(*)::int from assets a join asset_stats s on s.asset_id=a.id where s.riv_usd is not null) as valued_assets, (select count(*)::int from sales where status='valid') as sales, (select max(sale_date) from sales) as latest_sale, (select count(*)::int from listings where availability='available') as listings, (select count(*)::int from sources where active) as sources, (select max(date) from index_values) as latest_index_date`);
225 + const families = await run(sql`select a.family_slug, count(*)::int as assets, coalesce(sum(s.sales_count),0)::int as sales from assets a left join asset_stats s on s.asset_id = a.id group by a.family_slug order by assets desc`);
226 + return { ...r, families };
227 + }
228 + }
229 + return { error: 'unhandled' };
230 +}
231 +
232 +function ret(start: unknown, end: unknown): number | null {
233 + const s = Number(start);
234 + const e = Number(end);
235 + return Number.isFinite(s) && Number.isFinite(e) && s > 0 ? (e - s) / s : null;
236 +}
237 +
238 +/** Keep tool payloads small: at most n evenly spaced points. */
239 +function thin<T>(rows: T[], n: number): T[] {
240 + if (rows.length <= n) return rows;
241 + const step = rows.length / n;
242 + const out: T[] = [];
243 + for (let i = 0; i < n; i++) out.push(rows[Math.floor(i * step)]!);
244 + if (out[out.length - 1] !== rows[rows.length - 1]) out.push(rows[rows.length - 1]!);
245 + return out;
246 +}
247 +
248 +/** One-line summary of a tool result for the transparent trace shown to users. */
249 +export function summarizeToolResult(name: string, output: unknown): string {
250 + if (!output || typeof output !== 'object') return '';
251 + const o = output as Record<string, unknown>;
252 + if (o.error) return `error: ${String(o.error)}`;
253 + if (typeof o.count === 'number') return `${o.count} rows`;
254 + if (Array.isArray(o.points)) return `${o.points.length} points`;
255 + if (Array.isArray(o.items)) return `${o.items.length} items`;
256 + if (name === 'get_asset' && o.title) return String(o.title);
257 + if (name === 'get_index') return o.published ? `value ${o.value}` : 'not published';
258 + return 'ok';
259 +}
added apps/web/src/lib/ai/research.ts +135 −0
@@ -0,0 +1,135 @@
1 +import 'server-only';
2 +import { getDb, researchSessions, researchMessages, eq, desc, and, sql } from '@rareindex/database';
3 +import { newId } from '@rareindex/shared';
4 +import { getRouter, type ChatMessage, type StreamDelta } from '@rareindex/ai';
5 +import { executeTool, summarizeToolResult, toolDefinitions } from './research-tools';
6 +
7 +export const RESEARCH_SYSTEM = `You are RareIndex Research, the analyst interface of RareIndex.io — a market-data terminal for collectibles (trading cards, sports cards, comics, video games, sneakers, watches, LEGO, toys, coins, art…).
8 +
9 +Rules:
10 +1. Answer ONLY from tool results. Never state a price, count, return, population or date that did not come from a tool in this conversation. If tools return nothing, say "Data unavailable" and explain what RareIndex does or does not track yet (use platform_coverage).
11 +2. Start by calling the right tools; chain them (search_assets → get_asset → get_asset_sales…). Prefer one well-parameterised call over many.
12 +3. Every figure you cite must carry its context: sample size, confidence label, as-of date, and the currency (USD unless stated). Listing prices are asks, not sales. RIV is an estimate.
13 +4. Cite assets as markdown links: [Title](/asset/slug); categories as [Name](/markets/slug); indices as [TICKER](/rareindex/TICKER).
14 +5. Use compact markdown tables for comparisons (≤ 8 rows). Keep prose short and analytical. No investment advice; describe data, not recommendations.
15 +6. Windows: 7d/30d/90d/1y. If the user asks for a horizon RareIndex cannot support, say so.
16 +7. You may explain methodology briefly (RIV = ensemble of latest/median/volume-weighted/trimmed/exponentially-weighted prices with outlier flags; indices are chain-linked from constituent valuations and published only above a minimum breadth).`;
17 +
18 +export const SUGGESTED_PROMPTS = [
19 + 'Find Pokémon cards under $5,000 that have increased more than 30% in the last year.',
20 + 'Compare RARE-WATCH versus RARE-TCG over the last 90 days.',
21 + 'Find sealed Nintendo games with fewer than 20 public sales in the last year.',
22 + 'Which collectible category has the strongest momentum this month?',
23 + 'What are the highest verified sales tracked by RareIndex?',
24 + 'How much data does RareIndex currently cover?',
25 +];
26 +
27 +export interface ResearchTurnInput {
28 + sessionId: string | null;
29 + anonId: string | null;
30 + userId: string | null;
31 + message: string;
32 + signal?: AbortSignal;
33 +}
34 +
35 +export type ResearchEvent =
36 + | { type: 'session'; sessionId: string }
37 + | { type: 'text'; text: string }
38 + | { type: 'thinking'; text: string }
39 + | { type: 'tool_call'; id: string; name: string; input: unknown }
40 + | { type: 'tool_result'; id: string; name: string; ok: boolean; ms: number; summary: string; preview: unknown }
41 + | { type: 'done'; usdEst: number; model: string | null }
42 + | { type: 'error'; message: string };
43 +
44 +const MAX_HISTORY = 16;
45 +
46 +export async function loadSession(sessionId: string, owner: { anonId: string | null; userId: string | null }) {
47 + const db = getDb();
48 + const [s] = await db.select().from(researchSessions).where(eq(researchSessions.id, sessionId)).limit(1);
49 + if (!s) return null;
50 + if (s.userId && s.userId !== owner.userId) return null;
51 + if (!s.userId && s.anonId && s.anonId !== owner.anonId) return null;
52 + const messages = await db.select().from(researchMessages).where(eq(researchMessages.sessionId, sessionId)).orderBy(researchMessages.createdAt);
53 + return { session: s, messages };
54 +}
55 +
56 +export async function listSessions(owner: { anonId: string | null; userId: string | null }, limit = 20) {
57 + const db = getDb();
58 + const where = owner.userId ? eq(researchSessions.userId, owner.userId) : owner.anonId ? and(eq(researchSessions.anonId, owner.anonId), sql`${researchSessions.userId} is null`) : null;
59 + if (!where) return [];
60 + return db.select().from(researchSessions).where(and(where, eq(researchSessions.archived, false))).orderBy(desc(researchSessions.updatedAt)).limit(limit);
61 +}
62 +
63 +/** Run one research turn: persists the user message, streams the agent, persists the answer + tool trace. */
64 +export async function* researchTurn(input: ResearchTurnInput): AsyncGenerator<ResearchEvent> {
65 + const db = getDb();
66 + const router = getRouter();
67 + let sessionId = input.sessionId;
68 + let history: ChatMessage[] = [];
69 + if (sessionId) {
70 + const loaded = await loadSession(sessionId, { anonId: input.anonId, userId: input.userId });
71 + if (!loaded) sessionId = null;
72 + else history = loaded.messages.slice(-MAX_HISTORY).map((m) => ({ role: m.role as 'user' | 'assistant', content: m.content }));
73 + }
74 + if (!sessionId) {
75 + sessionId = newId('event').replace('evt_', 'rs_');
76 + await db.insert(researchSessions).values({ id: sessionId, userId: input.userId, anonId: input.anonId, title: input.message.slice(0, 80), model: router.configured ? router.modelFor('research') : null });
77 + }
78 + yield { type: 'session', sessionId };
79 + await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'user', content: input.message });
80 +
81 + let answer = '';
82 + let usdEst = 0;
83 + let model: string | null = null;
84 + const trace: Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }> = [];
85 + const pending = new Map<string, { name: string; input: unknown }>();
86 + const usage: Record<string, number> = {};
87 + try {
88 + const stream = router.runTools('research', {
89 + system: RESEARCH_SYSTEM,
90 + messages: [...history, { role: 'user', content: input.message }],
91 + tools: toolDefinitions(),
92 + execute: executeTool,
93 + maxIterations: 10,
94 + maxTokens: 6000,
95 + effort: 'medium',
96 + signal: input.signal,
97 + cost: { endpoint: 'research', userId: input.userId, metadata: { sessionId } },
98 + });
99 + for await (const d of stream as AsyncIterable<StreamDelta>) {
100 + if (d.type === 'text') {
101 + answer += d.text;
102 + yield d;
103 + } else if (d.type === 'thinking') {
104 + yield d;
105 + } else if (d.type === 'tool_call') {
106 + pending.set(d.id, { name: d.name, input: d.input });
107 + yield d;
108 + } else if (d.type === 'tool_result') {
109 + const summary = d.isError ? `error: ${JSON.stringify(d.output).slice(0, 120)}` : summarizeToolResult(d.name, d.output);
110 + trace.push({ name: d.name, input: pending.get(d.id)?.input, ok: !d.isError, ms: d.durationMs, summary });
111 + yield { type: 'tool_result', id: d.id, name: d.name, ok: !d.isError, ms: d.durationMs, summary, preview: preview(d.output) };
112 + } else if (d.type === 'usage') {
113 + usdEst += d.usdEst;
114 + model = d.model;
115 + for (const [k, v] of Object.entries(d.usage)) usage[k] = (usage[k] ?? 0) + v;
116 + }
117 + }
118 + } catch (err) {
119 + const e = err as { code?: string; message?: string };
120 + const message = e?.code === 'ai_not_configured' ? 'AI provider not configured on this server.' : e?.code === 'ai_refusal' ? `The model declined this request${e.message ? `: ${e.message}` : '.'}` : (e?.message ?? 'Unexpected error');
121 + yield { type: 'error', message };
122 + answer = answer || `_${message}_`;
123 + }
124 + await db.insert(researchMessages).values({ id: newId('event').replace('evt_', 'rm_'), sessionId, role: 'assistant', content: answer, toolCalls: trace, usage, usdEst, model });
125 + await db.update(researchSessions).set({ messageCount: sql`${researchSessions.messageCount} + 2`, usdEst: sql`${researchSessions.usdEst} + ${usdEst}`, updatedAt: new Date(), model }).where(eq(researchSessions.id, sessionId));
126 + yield { type: 'done', usdEst, model };
127 +}
128 +
129 +function preview(output: unknown): unknown {
130 + if (!output || typeof output !== 'object') return output;
131 + const o = output as Record<string, unknown>;
132 + const arrKey = ['results', 'sales', 'listings', 'items', 'points', 'categories', 'findings', 'variants', 'gainers'].find((k) => Array.isArray(o[k]));
133 + if (arrKey) return { [arrKey]: (o[arrKey] as unknown[]).slice(0, 5), truncated: (o[arrKey] as unknown[]).length > 5 };
134 + return Object.fromEntries(Object.entries(o).slice(0, 12));
135 +}
added apps/web/src/lib/ai/scanner.ts +169 −0
@@ -0,0 +1,169 @@
1 +import 'server-only';
2 +import { getDb, scannerSessions, eq } from '@rareindex/database';
3 +import { newId, logger, type NormalizedRecord } from '@rareindex/shared';
4 +import { getGrader, parseGradeFromTitle } from '@rareindex/taxonomy';
5 +import { identifyCollectible, getRouter, type ImageInput, type Identification } from '@rareindex/ai';
6 +import { lookupViaApi } from './lookup';
7 +import { findCandidates, assetContext, type Candidate } from './candidates';
8 +
9 +export interface ScanResult {
10 + sessionId: string;
11 + mode: 'photo' | 'url' | 'text';
12 + guess: Identification | null;
13 + guessConfidence: number | null;
14 + candidates: Candidate[];
15 + best: (Candidate & { context: Awaited<ReturnType<typeof assetContext>> }) | null;
16 + listing: ListingSummary | null;
17 + model: string | null;
18 + usdEst: number;
19 + durationMs: number;
20 + notes: string[];
21 +}
22 +
23 +export interface ListingSummary {
24 + sourceId: string;
25 + sourceUrl: string;
26 + rawTitle: string;
27 + price: number | null;
28 + currency: string | null;
29 + kind: NormalizedRecord['kind'];
30 + grader: string | null;
31 + grade: string | null;
32 + imageUrls: string[];
33 + identifiers: Record<string, string>;
34 + /** price vs RIV when both known: negative = below fair value */
35 + discountToRiv: number | null;
36 + verdict: 'below_fair_value' | 'in_range' | 'above_fair_value' | 'unknown';
37 +}
38 +
39 +interface ScanInput {
40 + mode: 'photo' | 'url' | 'text';
41 + images?: ImageInput[];
42 + thumbnails?: string[];
43 + url?: string;
44 + text?: string;
45 + userId: string | null;
46 + anonId: string | null;
47 + ipHash: string;
48 +}
49 +
50 +/** Full scanner flow (§114). Everything shown is evidence-backed; AI output carries confidence. */
51 +export async function runScan(input: ScanInput): Promise<ScanResult> {
52 + const started = Date.now();
53 + const id = newId('event').replace('evt_', 'scan_');
54 + const notes: string[] = ['AI identification is an estimate, not authentication. Confirm with the source, the certification number and a professional grader when value matters.'];
55 + let guess: Identification | null = null;
56 + let model: string | null = null;
57 + let usdEst = 0;
58 + let listing: ListingSummary | null = null;
59 + let identifiers: Record<string, string> = {};
60 + let textForModel = input.text ?? '';
61 +
62 + if (input.mode === 'url' && input.url) {
63 + const looked = await lookupUrl(input.url);
64 + if (looked === 'unavailable') {
65 + notes.push('Listing lookup service unavailable; identification falls back to the URL text only.');
66 + textForModel = input.url;
67 + } else if (looked) {
68 + listing = looked.summary;
69 + identifiers = looked.summary.identifiers;
70 + textForModel = [looked.summary.rawTitle, looked.description ?? ''].filter(Boolean).join('\n');
71 + notes.push(`Listing parsed from ${looked.summary.sourceId} via connector lookup.`);
72 + } else {
73 + notes.push('No connector understands this URL yet; identification falls back to the URL text only.');
74 + textForModel = input.url;
75 + }
76 + }
77 +
78 + try {
79 + const res = await identifyCollectible({
80 + images: input.images,
81 + text: textForModel || undefined,
82 + cost: { endpoint: 'scanner', userId: input.userId },
83 + });
84 + guess = res.data;
85 + model = res.model;
86 + usdEst += res.usdEst;
87 + } catch (err) {
88 + logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'scanner identification failed');
89 + if (!listing) throw err;
90 + notes.push('AI identification unavailable; showing the parsed listing only.');
91 + }
92 +
93 + // Fill grade from the raw title when the model missed it (deterministic beats generative)
94 + if (guess && !guess.grader && listing?.rawTitle) {
95 + const g = parseGradeFromTitle(listing.rawTitle);
96 + if (g.grader) {
97 + guess.grader = g.grader;
98 + guess.grade = g.grade;
99 + }
100 + }
101 +
102 + const candidates = guess ? await findCandidates(guess, identifiers) : await findCandidates({ categorySlug: null, name: listing?.rawTitle ?? '', set: null, number: null, year: null, variant: null, brand: null, searchQueries: listing ? [listing.rawTitle] : [], certificationNumber: null }, identifiers);
103 + const top = candidates[0];
104 + const best = top && top.score >= 0.25 ? { ...top, context: await assetContext(top.assetId) } : null;
105 + if (!best) notes.push(candidates.length ? 'Low-confidence match: review the candidates below.' : 'No matching asset in the RareIndex catalog yet. The pipeline adds assets as connectors ingest sources.');
106 +
107 + if (listing && best?.rivUsd && listing.price !== null && listing.currency === 'USD') {
108 + listing.discountToRiv = (listing.price - best.rivUsd) / best.rivUsd;
109 + listing.verdict = listing.discountToRiv <= -0.15 ? 'below_fair_value' : listing.discountToRiv >= 0.2 ? 'above_fair_value' : 'in_range';
110 + }
111 +
112 + const durationMs = Date.now() - started;
113 + await getDb()
114 + .insert(scannerSessions)
115 + .values({
116 + id,
117 + userId: input.userId,
118 + anonId: input.anonId,
119 + ipHash: input.ipHash,
120 + mode: input.mode,
121 + inputUrl: input.url ?? null,
122 + inputText: input.text ?? null,
123 + imageCount: input.images?.length ?? 0,
124 + thumbnails: input.thumbnails ?? [],
125 + guess: (guess ?? {}) as Record<string, unknown>,
126 + guessConfidence: guess?.confidence ?? null,
127 + candidates: candidates.map((c) => ({ assetId: c.assetId, slug: c.slug, title: c.title, score: c.score })),
128 + listing: (listing ?? {}) as unknown as Record<string, unknown>,
129 + model,
130 + usdEst,
131 + durationMs,
132 + });
133 +
134 + return { sessionId: id, mode: input.mode, guess, guessConfidence: guess?.confidence ?? null, candidates, best, listing, model, usdEst, durationMs, notes };
135 +}
136 +
137 +export async function chooseCandidate(sessionId: string, assetId: string | null): Promise<void> {
138 + await getDb().update(scannerSessions).set({ chosenAssetId: assetId, chosenAt: new Date() }).where(eq(scannerSessions.id, sessionId));
139 +}
140 +
141 +/** Resolve a marketplace URL through the connector framework (lookup → normalize), via the API service. */
142 +export async function lookupUrl(url: string): Promise<{ summary: ListingSummary; description: string | null; records: NormalizedRecord[] } | null | 'unavailable'> {
143 + const looked = await lookupViaApi(url);
144 + if (looked === 'unavailable' || looked === null) return looked;
145 + const records = looked.records;
146 + const rec = records.find((r) => r.kind === 'listing' || r.kind === 'sale' || r.kind === 'catalog_item' || r.kind === 'price_observation');
147 + if (!rec || !('rawTitle' in rec)) return null;
148 + const price = 'price' in rec ? (rec.price as number | null) : null;
149 + const currency = 'currency' in rec ? (rec.currency as string | null) : null;
150 + const summary: ListingSummary = {
151 + sourceId: rec.sourceId,
152 + sourceUrl: rec.sourceUrl,
153 + rawTitle: rec.rawTitle,
154 + price,
155 + currency,
156 + kind: rec.kind,
157 + grader: rec.grade.grader ? (getGrader(rec.grade.grader)?.slug ?? rec.grade.grader) : null,
158 + grade: rec.grade.grade,
159 + imageUrls: rec.imageUrls,
160 + identifiers: rec.attributes.identifiers,
161 + discountToRiv: null,
162 + verdict: 'unknown',
163 + };
164 + return { summary, description: rec.description, records };
165 +}
166 +
167 +export function aiConfigured(): boolean {
168 + return getRouter().configured;
169 +}
added apps/web/src/proxy.ts +41 −0
@@ -0,0 +1,41 @@
1 +import { NextResponse, type NextRequest } from 'next/server';
2 +
3 +/**
4 + * Request guard for the admin surface (§144). Runs before rendering: `/admin/*` and `/api/admin/*`
5 + * require the `ri_admin` cookie whose value is HMAC-SHA256(SESSION_SECRET, "admin:" + ADMIN_TOKEN).
6 + * Web Crypto is used because this runs on the edge-compatible runtime.
7 + */
8 +const ADMIN_COOKIE = 'ri_admin';
9 +
10 +async function expectedCookie(): Promise<string | null> {
11 + const token = process.env.ADMIN_TOKEN;
12 + if (!token) return null;
13 + const enc = new TextEncoder();
14 + const key = await crypto.subtle.importKey('raw', enc.encode(process.env.SESSION_SECRET ?? 'dev-only'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
15 + const sig = await crypto.subtle.sign('HMAC', key, enc.encode(`admin:${token}`));
16 + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
17 +}
18 +
19 +export async function proxy(req: NextRequest) {
20 + const { pathname } = req.nextUrl;
21 + if (pathname === '/admin/login' || pathname === '/api/admin/login') return NextResponse.next();
22 + const expected = await expectedCookie();
23 + const got = req.cookies.get(ADMIN_COOKIE)?.value;
24 + const ok = Boolean(expected && got && got.length === expected.length && constantTimeEqual(got, expected));
25 + if (ok) return NextResponse.next();
26 + if (pathname.startsWith('/api/')) return NextResponse.json({ error: 'Admin authentication required' }, { status: 401 });
27 + const url = req.nextUrl.clone();
28 + url.pathname = '/admin/login';
29 + url.searchParams.set('next', pathname);
30 + return NextResponse.redirect(url);
31 +}
32 +
33 +function constantTimeEqual(a: string, b: string): boolean {
34 + let out = 0;
35 + for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i);
36 + return out === 0;
37 +}
38 +
39 +export const config = {
40 + matcher: ['/admin/:path*', '/api/admin/:path*'],
41 +};
modified apps/web/tsconfig.json +2 −2
@@ -8,8 +8,8 @@
8 8 "noUncheckedIndexedAccess": true,
9 9 "noEmit": true,
10 10 "esModuleInterop": true,
11 "module": "esnext",
12 "moduleResolution": "bundler",
11 + "module": "nodenext",
12 + "moduleResolution": "nodenext",
13 13 "resolveJsonModule": true,
14 14 "isolatedModules": true,
15 15 "jsx": "react-jsx",
added docs/API.md +78 −0
@@ -0,0 +1,78 @@
1 +# RareIndex Public API (v1)
2 +
3 +Base URL (production): `https://www.rareindex.io/api/v1` — the web app proxies `/api/v1/*` to the
4 +Fastify service (`apps/api`, port `API_PORT`, default 8211). Direct access in development:
5 +`http://localhost:8211/v1`. Machine-readable contract: `GET /v1/openapi.json` (also `docs/openapi.json`).
6 +
7 +## Authentication & tiers
8 +
9 +`Authorization: Bearer ri_live_<prefix>_<secret>`. Requests without a key use the **public** tier.
10 +Only the SHA-256 hash of a key is stored (`api_keys.key_hash`); mint keys with
11 +
12 +```
13 +pnpm --filter @rareindex/api create-key -- --name "Research desk" --tier professional [--user usr_…]
14 +```
15 +
16 +| Tier | req/min | req/day |
17 +|---|---|---|
18 +| public (no key) | 20 | 500 |
19 +| free | 60 | 1 000 |
20 +| hobby | 120 | 10 000 |
21 +| professional | 600 | 100 000 |
22 +| research | 600 | 250 000 |
23 +| enterprise | 3 000 | 5 000 000 |
24 +
25 +Rate-limit headers: `x-ratelimit-limit`, `x-ratelimit-remaining`, `x-ratelimit-reset`; every response
26 +carries `x-request-id`. Usage is counted per key/day/endpoint in `api_usage`.
27 +
28 +## Conventions
29 +
30 +- Envelope: `{ "data": …, "meta": { "count", "cursor", "as_of", "attribution", … } }`.
31 +- Pagination: `limit` (≤ 200) + opaque `cursor` (from `meta.cursor`, `null` when exhausted).
32 +- Errors: RFC 9457 `application/problem+json` (`title`, `status`, `detail`, `instance`, `request_id`).
33 +- Export: add `?format=csv` on list endpoints for a flat CSV download.
34 +- Caching: read endpoints send `cache-control` and weak ETags; gzip/brotli negotiated.
35 +- Money: `price` + `currency` are the native transaction values; `price_usd` uses the FX rate of the
36 + transaction date. Valuations (`riv_usd`, `riv_low_usd`, `riv_high_usd`) always come with
37 + `riv_confidence` and `riv_sample_size`. Listing prices are asks, never transactions.
38 +- Attribution is required (see `meta.attribution`).
39 +
40 +## Endpoints
41 +
42 +| Method & path | Purpose |
43 +|---|---|
44 +| `GET /v1/assets/search?q&category&limit&cursor` | Search canonical assets (FTS + trigram + identifiers) |
45 +| `GET /v1/assets/{idOrSlug}` | Asset detail: attributes, stats, variants with their own valuations, latest valuation, sources |
46 +| `GET /v1/assets/{id}/sales?variant&include_flagged` | Observed sales (valid only by default) |
47 +| `GET /v1/assets/{id}/listings?availability` | Listings (asks) with `discount_to_riv` |
48 +| `GET /v1/assets/{id}/history?variant&from&to` | Daily series: RIV, latest sale, median, sales count, volume, listings, min ask |
49 +| `GET /v1/categories` | Taxonomy with tracked asset counts |
50 +| `GET /v1/indices` | RARE + subindices: latest value, 1d/7d/30d/YTD/1y changes, breadth, market cap estimate + confidence; `published=false` until breadth threshold |
51 +| `GET /v1/indices/{ticker}/history?from&to` | Index history |
52 +| `GET /v1/markets` | Latest category snapshot per family |
53 +| `GET /v1/markets/{slug}` | Market detail: counts, gainers/losers (≥ 3 sales), most valuable, most liquid, recent sales, history |
54 +| `GET /v1/trending?category` | Assets ranked by trending score |
55 +| `GET /v1/sales/latest?category&min_usd` | Latest observed sales across the platform |
56 +| `GET /v1/records` | Highest verified sale per family (bundles and low-confidence excluded) |
57 +| `GET /v1/stats` | Platform counts |
58 +| `GET /healthz` | Liveness (DB ping) |
59 +
60 +## Internal endpoints (loopback only)
61 +
62 +`POST /internal/lookup { url }` and `GET /internal/lookup/sources` are used by the web Scanner to
63 +resolve a marketplace URL through the connector framework (connector modules are loaded
64 +dynamically, which the Next.js bundler cannot do). Auth: header `x-internal-token` =
65 +HMAC-SHA256(`SESSION_SECRET`, `"internal"`), and the caller must be 127.0.0.1.
66 +
67 +## Running
68 +
69 +```
70 +pnpm --filter @rareindex/api dev # tsx watch, port 8211
71 +pnpm --filter @rareindex/api start
72 +pnpm --filter @rareindex/api test # fastify.inject integration tests against DATABASE_URL
73 +pnpm --filter @rareindex/api exec tsx scripts/gen-openapi.ts # refresh docs/openapi.json
74 +```
75 +
76 +Environment: `DATABASE_URL`, `API_PORT`, `API_HOST` (default 127.0.0.1 — put it behind the web proxy),
77 +`SESSION_SECRET` (shared with the web app for internal calls), `FIRECRAWL_API_KEY`/`SCRAPFLY_API_KEY`
78 +(URL lookups), `LOG_LEVEL`.
added docs/PENDING-SCHEMA-ai.md +14 −0
@@ -0,0 +1,14 @@
1 +# Pending schema changes — agent F2 (ai / scanner / research)
2 +
3 +New file `packages/database/src/schema/ai.ts`, exported from `schema/index.ts`. Applied locally with
4 +`drizzle-kit push` on `rareindex_ai`; **no migration generated** (to be folded into the next
5 +`pnpm db:generate` at integration).
6 +
7 +| Table | Purpose |
8 +|---|---|
9 +| `scanner_sessions` | Scanner submissions (photo/url/text), model guess + confidence, candidates shown, user pick (feedback loop), thumbnails, cost |
10 +| `research_sessions` | AI Research threads keyed by anonymous cookie (`anon_id`) or `user_id` |
11 +| `research_messages` | Messages with transparent tool-call trace, usage and cost |
12 +| `ai_quotas` | Per-day counters for anonymous AI features (key = ip hash or user id) |
13 +
14 +No changes to existing tables. AI spend is written to the existing `costs` table (`kind='ai'`).
added docs/openapi.json +2075 −0
@@ -0,0 +1,2075 @@
1 +{
2 + "openapi": "3.1.0",
3 + "info": {
4 + "title": "RareIndex Public API",
5 + "version": "1.0.0",
6 + "description": "Structured market data for collectible assets: canonical assets, observed sales, live listings, price history, category markets and the RareIndex indices. Valuations are estimates with confidence and sample size; listing prices are not confirmed transactions. Attribution to RareIndex and the original sources is required.",
7 + "contact": {
8 + "name": "RareIndex",
9 + "url": "https://www.rareindex.io/api-docs",
10 + "email": "api@rareindex.io"
11 + },
12 + "termsOfService": "https://www.rareindex.io/about#terms"
13 + },
14 + "servers": [
15 + {
16 + "url": "https://www.rareindex.io/api",
17 + "description": "Production (proxied by the web app)"
18 + },
19 + {
20 + "url": "http://localhost:8211",
21 + "description": "Local API service"
22 + }
23 + ],
24 + "security": [
25 + {
26 + "bearerAuth": []
27 + },
28 + {}
29 + ],
30 + "tags": [
31 + {
32 + "name": "assets"
33 + },
34 + {
35 + "name": "markets"
36 + },
37 + {
38 + "name": "indices"
39 + },
40 + {
41 + "name": "sales"
42 + },
43 + {
44 + "name": "reference"
45 + }
46 + ],
47 + "paths": {
48 + "/v1/assets/search": {
49 + "get": {
50 + "tags": [
51 + "assets"
52 + ],
53 + "summary": "Search canonical assets",
54 + "parameters": [
55 + {
56 + "name": "q",
57 + "in": "query",
58 + "schema": {
59 + "type": "string"
60 + },
61 + "description": "Natural language query, e.g. \"1999 Charizard PSA 10\""
62 + },
63 + {
64 + "name": "category",
65 + "in": "query",
66 + "schema": {
67 + "type": "string"
68 + },
69 + "description": "Category or family slug"
70 + },
71 + {
72 + "name": "limit",
73 + "in": "query",
74 + "schema": {
75 + "type": "integer",
76 + "minimum": 1,
77 + "maximum": 200,
78 + "default": 50
79 + }
80 + },
81 + {
82 + "name": "cursor",
83 + "in": "query",
84 + "schema": {
85 + "type": "string"
86 + },
87 + "description": "Opaque cursor from meta.cursor"
88 + },
89 + {
90 + "name": "format",
91 + "in": "query",
92 + "schema": {
93 + "type": "string",
94 + "enum": [
95 + "json",
96 + "csv"
97 + ]
98 + },
99 + "description": "csv returns a flat export of the rows"
100 + }
101 + ],
102 + "responses": {
103 + "200": {
104 + "description": "OK",
105 + "content": {
106 + "application/json": {
107 + "schema": {
108 + "type": "object",
109 + "properties": {
110 + "data": {
111 + "type": "array",
112 + "items": {
113 + "$ref": "#/components/schemas/AssetSummary"
114 + }
115 + },
116 + "meta": {
117 + "$ref": "#/components/schemas/Meta"
118 + }
119 + }
120 + }
121 + }
122 + }
123 + },
124 + "400": {
125 + "description": "Invalid query",
126 + "content": {
127 + "application/problem+json": {
128 + "schema": {
129 + "$ref": "#/components/schemas/Problem"
130 + }
131 + }
132 + }
133 + },
134 + "401": {
135 + "description": "Invalid API key",
136 + "content": {
137 + "application/problem+json": {
138 + "schema": {
139 + "$ref": "#/components/schemas/Problem"
140 + }
141 + }
142 + }
143 + },
144 + "404": {
145 + "description": "Not found",
146 + "content": {
147 + "application/problem+json": {
148 + "schema": {
149 + "$ref": "#/components/schemas/Problem"
150 + }
151 + }
152 + }
153 + },
154 + "429": {
155 + "description": "Rate limit or quota exceeded",
156 + "content": {
157 + "application/problem+json": {
158 + "schema": {
159 + "$ref": "#/components/schemas/Problem"
160 + }
161 + }
162 + }
163 + }
164 + }
165 + }
166 + },
167 + "/v1/assets/{id}": {
168 + "get": {
169 + "tags": [
170 + "assets"
171 + ],
172 + "summary": "Asset detail with variants, latest valuation and sources",
173 + "parameters": [
174 + {
175 + "name": "id",
176 + "in": "path",
177 + "required": true,
178 + "schema": {
179 + "type": "string"
180 + },
181 + "description": "Asset id (rare_…) or slug"
182 + }
183 + ],
184 + "responses": {
185 + "200": {
186 + "description": "OK",
187 + "content": {
188 + "application/json": {
189 + "schema": {
190 + "type": "object",
191 + "properties": {
192 + "data": {
193 + "$ref": "#/components/schemas/AssetDetail"
194 + },
195 + "meta": {
196 + "$ref": "#/components/schemas/Meta"
197 + }
198 + }
199 + }
200 + }
201 + }
202 + },
203 + "400": {
204 + "description": "Invalid query",
205 + "content": {
206 + "application/problem+json": {
207 + "schema": {
208 + "$ref": "#/components/schemas/Problem"
209 + }
210 + }
211 + }
212 + },
213 + "401": {
214 + "description": "Invalid API key",
215 + "content": {
216 + "application/problem+json": {
217 + "schema": {
218 + "$ref": "#/components/schemas/Problem"
219 + }
220 + }
221 + }
222 + },
223 + "404": {
224 + "description": "Not found",
225 + "content": {
226 + "application/problem+json": {
227 + "schema": {
228 + "$ref": "#/components/schemas/Problem"
229 + }
230 + }
231 + }
232 + },
233 + "429": {
234 + "description": "Rate limit or quota exceeded",
235 + "content": {
236 + "application/problem+json": {
237 + "schema": {
238 + "$ref": "#/components/schemas/Problem"
239 + }
240 + }
241 + }
242 + }
243 + }
244 + }
245 + },
246 + "/v1/assets/{id}/sales": {
247 + "get": {
248 + "tags": [
249 + "assets",
250 + "sales"
251 + ],
252 + "summary": "Observed sales for an asset",
253 + "parameters": [
254 + {
255 + "name": "id",
256 + "in": "path",
257 + "required": true,
258 + "schema": {
259 + "type": "string"
260 + }
261 + },
262 + {
263 + "name": "variant",
264 + "in": "query",
265 + "schema": {
266 + "type": "string"
267 + }
268 + },
269 + {
270 + "name": "include_flagged",
271 + "in": "query",
272 + "schema": {
273 + "type": "boolean"
274 + }
275 + },
276 + {
277 + "name": "limit",
278 + "in": "query",
279 + "schema": {
280 + "type": "integer",
281 + "minimum": 1,
282 + "maximum": 200,
283 + "default": 50
284 + }
285 + },
286 + {
287 + "name": "cursor",
288 + "in": "query",
289 + "schema": {
290 + "type": "string"
291 + },
292 + "description": "Opaque cursor from meta.cursor"
293 + },
294 + {
295 + "name": "format",
296 + "in": "query",
297 + "schema": {
298 + "type": "string",
299 + "enum": [
300 + "json",
301 + "csv"
302 + ]
303 + },
304 + "description": "csv returns a flat export of the rows"
305 + }
306 + ],
307 + "responses": {
308 + "200": {
309 + "description": "OK",
310 + "content": {
311 + "application/json": {
312 + "schema": {
313 + "type": "object",
314 + "properties": {
315 + "data": {
316 + "type": "array",
317 + "items": {
318 + "$ref": "#/components/schemas/Sale"
319 + }
320 + },
321 + "meta": {
322 + "$ref": "#/components/schemas/Meta"
323 + }
324 + }
325 + }
326 + }
327 + }
328 + },
329 + "400": {
330 + "description": "Invalid query",
331 + "content": {
332 + "application/problem+json": {
333 + "schema": {
334 + "$ref": "#/components/schemas/Problem"
335 + }
336 + }
337 + }
338 + },
339 + "401": {
340 + "description": "Invalid API key",
341 + "content": {
342 + "application/problem+json": {
343 + "schema": {
344 + "$ref": "#/components/schemas/Problem"
345 + }
346 + }
347 + }
348 + },
349 + "404": {
350 + "description": "Not found",
351 + "content": {
352 + "application/problem+json": {
353 + "schema": {
354 + "$ref": "#/components/schemas/Problem"
355 + }
356 + }
357 + }
358 + },
359 + "429": {
360 + "description": "Rate limit or quota exceeded",
361 + "content": {
362 + "application/problem+json": {
363 + "schema": {
364 + "$ref": "#/components/schemas/Problem"
365 + }
366 + }
367 + }
368 + }
369 + }
370 + }
371 + },
372 + "/v1/assets/{id}/listings": {
373 + "get": {
374 + "tags": [
375 + "assets"
376 + ],
377 + "summary": "Listings for an asset (asks, not transactions)",
378 + "parameters": [
379 + {
380 + "name": "id",
381 + "in": "path",
382 + "required": true,
383 + "schema": {
384 + "type": "string"
385 + }
386 + },
387 + {
388 + "name": "availability",
389 + "in": "query",
390 + "schema": {
391 + "type": "string",
392 + "enum": [
393 + "available",
394 + "sold",
395 + "ended",
396 + "removed"
397 + ],
398 + "default": "available"
399 + }
400 + },
401 + {
402 + "name": "limit",
403 + "in": "query",
404 + "schema": {
405 + "type": "integer",
406 + "minimum": 1,
407 + "maximum": 200,
408 + "default": 50
409 + }
410 + },
411 + {
412 + "name": "cursor",
413 + "in": "query",
414 + "schema": {
415 + "type": "string"
416 + },
417 + "description": "Opaque cursor from meta.cursor"
418 + },
419 + {
420 + "name": "format",
421 + "in": "query",
422 + "schema": {
423 + "type": "string",
424 + "enum": [
425 + "json",
426 + "csv"
427 + ]
428 + },
429 + "description": "csv returns a flat export of the rows"
430 + }
431 + ],
432 + "responses": {
433 + "200": {
434 + "description": "OK",
435 + "content": {
436 + "application/json": {
437 + "schema": {
438 + "type": "object",
439 + "properties": {
440 + "data": {
441 + "type": "array",
442 + "items": {
443 + "$ref": "#/components/schemas/Listing"
444 + }
445 + },
446 + "meta": {
447 + "$ref": "#/components/schemas/Meta"
448 + }
449 + }
450 + }
451 + }
452 + }
453 + },
454 + "400": {
455 + "description": "Invalid query",
456 + "content": {
457 + "application/problem+json": {
458 + "schema": {
459 + "$ref": "#/components/schemas/Problem"
460 + }
461 + }
462 + }
463 + },
464 + "401": {
465 + "description": "Invalid API key",
466 + "content": {
467 + "application/problem+json": {
468 + "schema": {
469 + "$ref": "#/components/schemas/Problem"
470 + }
471 + }
472 + }
473 + },
474 + "404": {
475 + "description": "Not found",
476 + "content": {
477 + "application/problem+json": {
478 + "schema": {
479 + "$ref": "#/components/schemas/Problem"
480 + }
481 + }
482 + }
483 + },
484 + "429": {
485 + "description": "Rate limit or quota exceeded",
486 + "content": {
487 + "application/problem+json": {
488 + "schema": {
489 + "$ref": "#/components/schemas/Problem"
490 + }
491 + }
492 + }
493 + }
494 + }
495 + }
496 + },
497 + "/v1/assets/{id}/history": {
498 + "get": {
499 + "tags": [
500 + "assets"
501 + ],
502 + "summary": "Daily price history (RIV, latest sale, median, volume, listings)",
503 + "parameters": [
504 + {
505 + "name": "id",
506 + "in": "path",
507 + "required": true,
508 + "schema": {
509 + "type": "string"
510 + }
511 + },
512 + {
513 + "name": "variant",
514 + "in": "query",
515 + "schema": {
516 + "type": "string"
517 + }
518 + },
519 + {
520 + "name": "from",
521 + "in": "query",
522 + "schema": {
523 + "type": "string",
524 + "format": "date"
525 + }
526 + },
527 + {
528 + "name": "to",
529 + "in": "query",
530 + "schema": {
531 + "type": "string",
532 + "format": "date"
533 + }
534 + },
535 + {
536 + "name": "format",
537 + "in": "query",
538 + "schema": {
539 + "type": "string",
540 + "enum": [
541 + "json",
542 + "csv"
543 + ]
544 + }
545 + }
546 + ],
547 + "responses": {
548 + "200": {
549 + "description": "OK",
550 + "content": {
551 + "application/json": {
552 + "schema": {
553 + "type": "object",
554 + "properties": {
555 + "data": {
556 + "type": "array",
557 + "items": {
558 + "$ref": "#/components/schemas/PricePoint"
559 + }
560 + },
561 + "meta": {
562 + "$ref": "#/components/schemas/Meta"
563 + }
564 + }
565 + }
566 + }
567 + }
568 + },
569 + "400": {
570 + "description": "Invalid query",
571 + "content": {
572 + "application/problem+json": {
573 + "schema": {
574 + "$ref": "#/components/schemas/Problem"
575 + }
576 + }
577 + }
578 + },
579 + "401": {
580 + "description": "Invalid API key",
581 + "content": {
582 + "application/problem+json": {
583 + "schema": {
584 + "$ref": "#/components/schemas/Problem"
585 + }
586 + }
587 + }
588 + },
589 + "404": {
590 + "description": "Not found",
591 + "content": {
592 + "application/problem+json": {
593 + "schema": {
594 + "$ref": "#/components/schemas/Problem"
595 + }
596 + }
597 + }
598 + },
599 + "429": {
600 + "description": "Rate limit or quota exceeded",
601 + "content": {
602 + "application/problem+json": {
603 + "schema": {
604 + "$ref": "#/components/schemas/Problem"
605 + }
606 + }
607 + }
608 + }
609 + }
610 + }
611 + },
612 + "/v1/categories": {
613 + "get": {
614 + "tags": [
615 + "reference"
616 + ],
617 + "summary": "Taxonomy",
618 + "parameters": [
619 + {
620 + "name": "format",
621 + "in": "query",
622 + "schema": {
623 + "type": "string",
624 + "enum": [
625 + "json",
626 + "csv"
627 + ]
628 + },
629 + "description": "csv returns a flat export of the rows"
630 + }
631 + ],
632 + "responses": {
633 + "200": {
634 + "description": "OK",
635 + "content": {
636 + "application/json": {
637 + "schema": {
638 + "type": "object",
639 + "properties": {
640 + "data": {
641 + "type": "array",
642 + "items": {
643 + "$ref": "#/components/schemas/Category"
644 + }
645 + },
646 + "meta": {
647 + "$ref": "#/components/schemas/Meta"
648 + }
649 + }
650 + }
651 + }
652 + }
653 + },
654 + "400": {
655 + "description": "Invalid query",
656 + "content": {
657 + "application/problem+json": {
658 + "schema": {
659 + "$ref": "#/components/schemas/Problem"
660 + }
661 + }
662 + }
663 + },
664 + "401": {
665 + "description": "Invalid API key",
666 + "content": {
667 + "application/problem+json": {
668 + "schema": {
669 + "$ref": "#/components/schemas/Problem"
670 + }
671 + }
672 + }
673 + },
674 + "404": {
675 + "description": "Not found",
676 + "content": {
677 + "application/problem+json": {
678 + "schema": {
679 + "$ref": "#/components/schemas/Problem"
680 + }
681 + }
682 + }
683 + },
684 + "429": {
685 + "description": "Rate limit or quota exceeded",
686 + "content": {
687 + "application/problem+json": {
688 + "schema": {
689 + "$ref": "#/components/schemas/Problem"
690 + }
691 + }
692 + }
693 + }
694 + }
695 + }
696 + },
697 + "/v1/indices": {
698 + "get": {
699 + "tags": [
700 + "indices"
701 + ],
702 + "summary": "RARE and subindices with latest values and changes",
703 + "parameters": [
704 + {
705 + "name": "format",
706 + "in": "query",
707 + "schema": {
708 + "type": "string",
709 + "enum": [
710 + "json",
711 + "csv"
712 + ]
713 + },
714 + "description": "csv returns a flat export of the rows"
715 + }
716 + ],
717 + "responses": {
718 + "200": {
719 + "description": "OK",
720 + "content": {
721 + "application/json": {
722 + "schema": {
723 + "type": "object",
724 + "properties": {
725 + "data": {
726 + "type": "array",
727 + "items": {
728 + "$ref": "#/components/schemas/Index"
729 + }
730 + },
731 + "meta": {
732 + "$ref": "#/components/schemas/Meta"
733 + }
734 + }
735 + }
736 + }
737 + }
738 + },
739 + "400": {
740 + "description": "Invalid query",
741 + "content": {
742 + "application/problem+json": {
743 + "schema": {
744 + "$ref": "#/components/schemas/Problem"
745 + }
746 + }
747 + }
748 + },
749 + "401": {
750 + "description": "Invalid API key",
751 + "content": {
752 + "application/problem+json": {
753 + "schema": {
754 + "$ref": "#/components/schemas/Problem"
755 + }
756 + }
757 + }
758 + },
759 + "404": {
760 + "description": "Not found",
761 + "content": {
762 + "application/problem+json": {
763 + "schema": {
764 + "$ref": "#/components/schemas/Problem"
765 + }
766 + }
767 + }
768 + },
769 + "429": {
770 + "description": "Rate limit or quota exceeded",
771 + "content": {
772 + "application/problem+json": {
773 + "schema": {
774 + "$ref": "#/components/schemas/Problem"
775 + }
776 + }
777 + }
778 + }
779 + }
780 + }
781 + },
782 + "/v1/indices/{ticker}/history": {
783 + "get": {
784 + "tags": [
785 + "indices"
786 + ],
787 + "summary": "Index daily history",
788 + "parameters": [
789 + {
790 + "name": "ticker",
791 + "in": "path",
792 + "required": true,
793 + "schema": {
794 + "type": "string"
795 + },
796 + "example": "RARE-TCG"
797 + },
798 + {
799 + "name": "from",
800 + "in": "query",
801 + "schema": {
802 + "type": "string",
803 + "format": "date"
804 + }
805 + },
806 + {
807 + "name": "to",
808 + "in": "query",
809 + "schema": {
810 + "type": "string",
811 + "format": "date"
812 + }
813 + },
814 + {
815 + "name": "format",
816 + "in": "query",
817 + "schema": {
818 + "type": "string",
819 + "enum": [
820 + "json",
821 + "csv"
822 + ]
823 + }
824 + }
825 + ],
826 + "responses": {
827 + "200": {
828 + "description": "OK",
829 + "content": {
830 + "application/json": {
831 + "schema": {
832 + "type": "object",
833 + "properties": {
834 + "data": {
835 + "type": "array",
836 + "items": {
837 + "$ref": "#/components/schemas/IndexPoint"
838 + }
839 + },
840 + "meta": {
841 + "$ref": "#/components/schemas/Meta"
842 + }
843 + }
844 + }
845 + }
846 + }
847 + },
848 + "400": {
849 + "description": "Invalid query",
850 + "content": {
851 + "application/problem+json": {
852 + "schema": {
853 + "$ref": "#/components/schemas/Problem"
854 + }
855 + }
856 + }
857 + },
858 + "401": {
859 + "description": "Invalid API key",
860 + "content": {
861 + "application/problem+json": {
862 + "schema": {
863 + "$ref": "#/components/schemas/Problem"
864 + }
865 + }
866 + }
867 + },
868 + "404": {
869 + "description": "Not found",
870 + "content": {
871 + "application/problem+json": {
872 + "schema": {
873 + "$ref": "#/components/schemas/Problem"
874 + }
875 + }
876 + }
877 + },
878 + "429": {
879 + "description": "Rate limit or quota exceeded",
880 + "content": {
881 + "application/problem+json": {
882 + "schema": {
883 + "$ref": "#/components/schemas/Problem"
884 + }
885 + }
886 + }
887 + }
888 + }
889 + }
890 + },
891 + "/v1/markets": {
892 + "get": {
893 + "tags": [
894 + "markets"
895 + ],
896 + "summary": "Category markets overview (latest snapshot per family)",
897 + "parameters": [
898 + {
899 + "name": "format",
900 + "in": "query",
901 + "schema": {
902 + "type": "string",
903 + "enum": [
904 + "json",
905 + "csv"
906 + ]
907 + },
908 + "description": "csv returns a flat export of the rows"
909 + }
910 + ],
911 + "responses": {
912 + "200": {
913 + "description": "OK",
914 + "content": {
915 + "application/json": {
916 + "schema": {
917 + "type": "object",
918 + "properties": {
919 + "data": {
920 + "type": "array",
921 + "items": {
922 + "$ref": "#/components/schemas/Market"
923 + }
924 + },
925 + "meta": {
926 + "$ref": "#/components/schemas/Meta"
927 + }
928 + }
929 + }
930 + }
931 + }
932 + },
933 + "400": {
934 + "description": "Invalid query",
935 + "content": {
936 + "application/problem+json": {
937 + "schema": {
938 + "$ref": "#/components/schemas/Problem"
939 + }
940 + }
941 + }
942 + },
943 + "401": {
944 + "description": "Invalid API key",
945 + "content": {
946 + "application/problem+json": {
947 + "schema": {
948 + "$ref": "#/components/schemas/Problem"
949 + }
950 + }
951 + }
952 + },
953 + "404": {
954 + "description": "Not found",
955 + "content": {
956 + "application/problem+json": {
957 + "schema": {
958 + "$ref": "#/components/schemas/Problem"
959 + }
960 + }
961 + }
962 + },
963 + "429": {
964 + "description": "Rate limit or quota exceeded",
965 + "content": {
966 + "application/problem+json": {
967 + "schema": {
968 + "$ref": "#/components/schemas/Problem"
969 + }
970 + }
971 + }
972 + }
973 + }
974 + }
975 + },
976 + "/v1/markets/{slug}": {
977 + "get": {
978 + "tags": [
979 + "markets"
980 + ],
981 + "summary": "Category market detail: movers, most valuable, most liquid, recent sales, history",
982 + "parameters": [
983 + {
984 + "name": "slug",
985 + "in": "path",
986 + "required": true,
987 + "schema": {
988 + "type": "string"
989 + },
990 + "example": "pokemon"
991 + }
992 + ],
993 + "responses": {
994 + "200": {
995 + "description": "OK",
996 + "content": {
997 + "application/json": {
998 + "schema": {
999 + "type": "object",
1000 + "properties": {
1001 + "data": {
1002 + "$ref": "#/components/schemas/MarketDetail"
1003 + },
1004 + "meta": {
1005 + "$ref": "#/components/schemas/Meta"
1006 + }
1007 + }
1008 + }
1009 + }
1010 + }
1011 + },
1012 + "400": {
1013 + "description": "Invalid query",
1014 + "content": {
1015 + "application/problem+json": {
1016 + "schema": {
1017 + "$ref": "#/components/schemas/Problem"
1018 + }
1019 + }
1020 + }
1021 + },
1022 + "401": {
1023 + "description": "Invalid API key",
1024 + "content": {
1025 + "application/problem+json": {
1026 + "schema": {
1027 + "$ref": "#/components/schemas/Problem"
1028 + }
1029 + }
1030 + }
1031 + },
1032 + "404": {
1033 + "description": "Not found",
1034 + "content": {
1035 + "application/problem+json": {
1036 + "schema": {
1037 + "$ref": "#/components/schemas/Problem"
1038 + }
1039 + }
1040 + }
1041 + },
1042 + "429": {
1043 + "description": "Rate limit or quota exceeded",
1044 + "content": {
1045 + "application/problem+json": {
1046 + "schema": {
1047 + "$ref": "#/components/schemas/Problem"
1048 + }
1049 + }
1050 + }
1051 + }
1052 + }
1053 + }
1054 + },
1055 + "/v1/trending": {
1056 + "get": {
1057 + "tags": [
1058 + "markets"
1059 + ],
1060 + "summary": "Trending assets",
1061 + "parameters": [
1062 + {
1063 + "name": "category",
1064 + "in": "query",
1065 + "schema": {
1066 + "type": "string"
1067 + }
1068 + },
1069 + {
1070 + "name": "limit",
1071 + "in": "query",
1072 + "schema": {
1073 + "type": "integer",
1074 + "minimum": 1,
1075 + "maximum": 200,
1076 + "default": 50
1077 + }
1078 + },
1079 + {
1080 + "name": "cursor",
1081 + "in": "query",
1082 + "schema": {
1083 + "type": "string"
1084 + },
1085 + "description": "Opaque cursor from meta.cursor"
1086 + },
1087 + {
1088 + "name": "format",
1089 + "in": "query",
1090 + "schema": {
1091 + "type": "string",
1092 + "enum": [
1093 + "json",
1094 + "csv"
1095 + ]
1096 + },
1097 + "description": "csv returns a flat export of the rows"
1098 + }
1099 + ],
1100 + "responses": {
1101 + "200": {
1102 + "description": "OK",
1103 + "content": {
1104 + "application/json": {
1105 + "schema": {
1106 + "type": "object",
1107 + "properties": {
1108 + "data": {
1109 + "type": "array",
1110 + "items": {
1111 + "$ref": "#/components/schemas/AssetSummary"
1112 + }
1113 + },
1114 + "meta": {
1115 + "$ref": "#/components/schemas/Meta"
1116 + }
1117 + }
1118 + }
1119 + }
1120 + }
1121 + },
1122 + "400": {
1123 + "description": "Invalid query",
1124 + "content": {
1125 + "application/problem+json": {
1126 + "schema": {
1127 + "$ref": "#/components/schemas/Problem"
1128 + }
1129 + }
1130 + }
1131 + },
1132 + "401": {
1133 + "description": "Invalid API key",
1134 + "content": {
1135 + "application/problem+json": {
1136 + "schema": {
1137 + "$ref": "#/components/schemas/Problem"
1138 + }
1139 + }
1140 + }
1141 + },
1142 + "404": {
1143 + "description": "Not found",
1144 + "content": {
1145 + "application/problem+json": {
1146 + "schema": {
1147 + "$ref": "#/components/schemas/Problem"
1148 + }
1149 + }
1150 + }
1151 + },
1152 + "429": {
1153 + "description": "Rate limit or quota exceeded",
1154 + "content": {
1155 + "application/problem+json": {
1156 + "schema": {
1157 + "$ref": "#/components/schemas/Problem"
1158 + }
1159 + }
1160 + }
1161 + }
1162 + }
1163 + }
1164 + },
1165 + "/v1/sales/latest": {
1166 + "get": {
1167 + "tags": [
1168 + "sales"
1169 + ],
1170 + "summary": "Latest observed sales across the platform",
1171 + "parameters": [
1172 + {
1173 + "name": "category",
1174 + "in": "query",
1175 + "schema": {
1176 + "type": "string"
1177 + }
1178 + },
1179 + {
1180 + "name": "min_usd",
1181 + "in": "query",
1182 + "schema": {
1183 + "type": "number"
1184 + }
1185 + },
1186 + {
1187 + "name": "limit",
1188 + "in": "query",
1189 + "schema": {
1190 + "type": "integer",
1191 + "minimum": 1,
1192 + "maximum": 200,
1193 + "default": 50
1194 + }
1195 + },
1196 + {
1197 + "name": "cursor",
1198 + "in": "query",
1199 + "schema": {
1200 + "type": "string"
1201 + },
1202 + "description": "Opaque cursor from meta.cursor"
1203 + },
1204 + {
1205 + "name": "format",
1206 + "in": "query",
1207 + "schema": {
1208 + "type": "string",
1209 + "enum": [
1210 + "json",
1211 + "csv"
1212 + ]
1213 + },
1214 + "description": "csv returns a flat export of the rows"
1215 + }
1216 + ],
1217 + "responses": {
1218 + "200": {
1219 + "description": "OK",
1220 + "content": {
1221 + "application/json": {
1222 + "schema": {
1223 + "type": "object",
1224 + "properties": {
1225 + "data": {
1226 + "type": "array",
1227 + "items": {
1228 + "$ref": "#/components/schemas/SaleWithAsset"
1229 + }
1230 + },
1231 + "meta": {
1232 + "$ref": "#/components/schemas/Meta"
1233 + }
1234 + }
1235 + }
1236 + }
1237 + }
1238 + },
1239 + "400": {
1240 + "description": "Invalid query",
1241 + "content": {
1242 + "application/problem+json": {
1243 + "schema": {
1244 + "$ref": "#/components/schemas/Problem"
1245 + }
1246 + }
1247 + }
1248 + },
1249 + "401": {
1250 + "description": "Invalid API key",
1251 + "content": {
1252 + "application/problem+json": {
1253 + "schema": {
1254 + "$ref": "#/components/schemas/Problem"
1255 + }
1256 + }
1257 + }
1258 + },
1259 + "404": {
1260 + "description": "Not found",
1261 + "content": {
1262 + "application/problem+json": {
1263 + "schema": {
1264 + "$ref": "#/components/schemas/Problem"
1265 + }
1266 + }
1267 + }
1268 + },
1269 + "429": {
1270 + "description": "Rate limit or quota exceeded",
1271 + "content": {
1272 + "application/problem+json": {
1273 + "schema": {
1274 + "$ref": "#/components/schemas/Problem"
1275 + }
1276 + }
1277 + }
1278 + }
1279 + }
1280 + }
1281 + },
1282 + "/v1/records": {
1283 + "get": {
1284 + "tags": [
1285 + "sales"
1286 + ],
1287 + "summary": "Record sale per family (verified transactions only)",
1288 + "parameters": [
1289 + {
1290 + "name": "format",
1291 + "in": "query",
1292 + "schema": {
1293 + "type": "string",
1294 + "enum": [
1295 + "json",
1296 + "csv"
1297 + ]
1298 + },
1299 + "description": "csv returns a flat export of the rows"
1300 + }
1301 + ],
1302 + "responses": {
1303 + "200": {
1304 + "description": "OK",
1305 + "content": {
1306 + "application/json": {
1307 + "schema": {
1308 + "type": "object",
1309 + "properties": {
1310 + "data": {
1311 + "type": "array",
1312 + "items": {
1313 + "$ref": "#/components/schemas/SaleWithAsset"
1314 + }
1315 + },
1316 + "meta": {
1317 + "$ref": "#/components/schemas/Meta"
1318 + }
1319 + }
1320 + }
1321 + }
1322 + }
1323 + },
1324 + "400": {
1325 + "description": "Invalid query",
1326 + "content": {
1327 + "application/problem+json": {
1328 + "schema": {
1329 + "$ref": "#/components/schemas/Problem"
1330 + }
1331 + }
1332 + }
1333 + },
1334 + "401": {
1335 + "description": "Invalid API key",
1336 + "content": {
1337 + "application/problem+json": {
1338 + "schema": {
1339 + "$ref": "#/components/schemas/Problem"
1340 + }
1341 + }
1342 + }
1343 + },
1344 + "404": {
1345 + "description": "Not found",
1346 + "content": {
1347 + "application/problem+json": {
1348 + "schema": {
1349 + "$ref": "#/components/schemas/Problem"
1350 + }
1351 + }
1352 + }
1353 + },
1354 + "429": {
1355 + "description": "Rate limit or quota exceeded",
1356 + "content": {
1357 + "application/problem+json": {
1358 + "schema": {
1359 + "$ref": "#/components/schemas/Problem"
1360 + }
1361 + }
1362 + }
1363 + }
1364 + }
1365 + }
1366 + },
1367 + "/v1/stats": {
1368 + "get": {
1369 + "tags": [
1370 + "reference"
1371 + ],
1372 + "summary": "Platform counts",
1373 + "responses": {
1374 + "200": {
1375 + "description": "OK",
1376 + "content": {
1377 + "application/json": {
1378 + "schema": {
1379 + "type": "object",
1380 + "properties": {
1381 + "data": {
1382 + "$ref": "#/components/schemas/Stats"
1383 + },
1384 + "meta": {
1385 + "$ref": "#/components/schemas/Meta"
1386 + }
1387 + }
1388 + }
1389 + }
1390 + }
1391 + }
1392 + }
1393 + }
1394 + }
1395 + },
1396 + "components": {
1397 + "securitySchemes": {
1398 + "bearerAuth": {
1399 + "type": "http",
1400 + "scheme": "bearer",
1401 + "description": "API key `ri_live_…`. Requests without a key use the public tier (20 req/min)."
1402 + }
1403 + },
1404 + "schemas": {
1405 + "Meta": {
1406 + "type": "object",
1407 + "properties": {
1408 + "count": {
1409 + "type": "integer"
1410 + },
1411 + "cursor": {
1412 + "type": [
1413 + "string",
1414 + "null"
1415 + ]
1416 + },
1417 + "as_of": {
1418 + "type": "string",
1419 + "format": "date-time"
1420 + },
1421 + "attribution": {
1422 + "type": "string"
1423 + }
1424 + },
1425 + "additionalProperties": true
1426 + },
1427 + "Problem": {
1428 + "type": "object",
1429 + "properties": {
1430 + "type": {
1431 + "type": "string"
1432 + },
1433 + "title": {
1434 + "type": "string"
1435 + },
1436 + "status": {
1437 + "type": "integer"
1438 + },
1439 + "detail": {
1440 + "type": "string"
1441 + },
1442 + "instance": {
1443 + "type": "string"
1444 + },
1445 + "request_id": {
1446 + "type": "string"
1447 + }
1448 + }
1449 + },
1450 + "AssetSummary": {
1451 + "type": "object",
1452 + "properties": {
1453 + "id": {
1454 + "type": "string"
1455 + },
1456 + "slug": {
1457 + "type": "string"
1458 + },
1459 + "title": {
1460 + "type": "string"
1461 + },
1462 + "name": {
1463 + "type": "string"
1464 + },
1465 + "category_slug": {
1466 + "type": "string"
1467 + },
1468 + "family_slug": {
1469 + "type": "string"
1470 + },
1471 + "brand": {
1472 + "type": [
1473 + "string",
1474 + "null"
1475 + ]
1476 + },
1477 + "franchise": {
1478 + "type": [
1479 + "string",
1480 + "null"
1481 + ]
1482 + },
1483 + "set_name": {
1484 + "type": [
1485 + "string",
1486 + "null"
1487 + ]
1488 + },
1489 + "number": {
1490 + "type": [
1491 + "string",
1492 + "null"
1493 + ]
1494 + },
1495 + "year": {
1496 + "type": [
1497 + "integer",
1498 + "null"
1499 + ]
1500 + },
1501 + "variant": {
1502 + "type": [
1503 + "string",
1504 + "null"
1505 + ]
1506 + },
1507 + "hero_image_url": {
1508 + "type": [
1509 + "string",
1510 + "null"
1511 + ]
1512 + },
1513 + "riv_usd": {
1514 + "type": [
1515 + "number",
1516 + "null"
1517 + ],
1518 + "description": "RareIndex Valuation (estimate)"
1519 + },
1520 + "riv_low_usd": {
1521 + "type": [
1522 + "number",
1523 + "null"
1524 + ]
1525 + },
1526 + "riv_high_usd": {
1527 + "type": [
1528 + "number",
1529 + "null"
1530 + ]
1531 + },
1532 + "riv_confidence": {
1533 + "type": [
1534 + "number",
1535 + "null"
1536 + ]
1537 + },
1538 + "riv_sample_size": {
1539 + "type": "integer"
1540 + },
1541 + "latest_sale_usd": {
1542 + "type": [
1543 + "number",
1544 + "null"
1545 + ]
1546 + },
1547 + "latest_sale_at": {
1548 + "type": [
1549 + "string",
1550 + "null"
1551 + ]
1552 + },
1553 + "change_7d": {
1554 + "type": [
1555 + "number",
1556 + "null"
1557 + ]
1558 + },
1559 + "change_30d": {
1560 + "type": [
1561 + "number",
1562 + "null"
1563 + ]
1564 + },
1565 + "change_1y": {
1566 + "type": [
1567 + "number",
1568 + "null"
1569 + ]
1570 + },
1571 + "sales_count": {
1572 + "type": "integer"
1573 + },
1574 + "sales_30d": {
1575 + "type": "integer"
1576 + },
1577 + "active_listings": {
1578 + "type": "integer"
1579 + },
1580 + "min_ask_usd": {
1581 + "type": [
1582 + "number",
1583 + "null"
1584 + ]
1585 + },
1586 + "liquidity_score": {
1587 + "type": [
1588 + "number",
1589 + "null"
1590 + ]
1591 + },
1592 + "rarity_score": {
1593 + "type": [
1594 + "number",
1595 + "null"
1596 + ]
1597 + },
1598 + "trending_score": {
1599 + "type": [
1600 + "number",
1601 + "null"
1602 + ]
1603 + }
1604 + }
1605 + },
1606 + "AssetDetail": {
1607 + "allOf": [
1608 + {
1609 + "$ref": "#/components/schemas/AssetSummary"
1610 + },
1611 + {
1612 + "type": "object",
1613 + "properties": {
1614 + "description": {
1615 + "type": [
1616 + "string",
1617 + "null"
1618 + ]
1619 + },
1620 + "identifiers": {
1621 + "type": "object"
1622 + },
1623 + "variants": {
1624 + "type": "array",
1625 + "items": {
1626 + "type": "object"
1627 + }
1628 + },
1629 + "valuation": {
1630 + "type": [
1631 + "object",
1632 + "null"
1633 + ]
1634 + },
1635 + "sources": {
1636 + "type": "array",
1637 + "items": {
1638 + "type": "object"
1639 + }
1640 + }
1641 + }
1642 + }
1643 + ]
1644 + },
1645 + "Sale": {
1646 + "type": "object",
1647 + "properties": {
1648 + "id": {
1649 + "type": "string"
1650 + },
1651 + "source_id": {
1652 + "type": "string"
1653 + },
1654 + "source_url": {
1655 + "type": "string"
1656 + },
1657 + "sale_type": {
1658 + "type": "string"
1659 + },
1660 + "sale_date": {
1661 + "type": "string"
1662 + },
1663 + "price": {
1664 + "type": "number"
1665 + },
1666 + "currency": {
1667 + "type": "string"
1668 + },
1669 + "price_usd": {
1670 + "type": "number"
1671 + },
1672 + "grader": {
1673 + "type": [
1674 + "string",
1675 + "null"
1676 + ]
1677 + },
1678 + "grade": {
1679 + "type": [
1680 + "string",
1681 + "null"
1682 + ]
1683 + },
1684 + "condition": {
1685 + "type": [
1686 + "string",
1687 + "null"
1688 + ]
1689 + },
1690 + "status": {
1691 + "type": "string"
1692 + },
1693 + "confidence": {
1694 + "type": "number"
1695 + }
1696 + }
1697 + },
1698 + "SaleWithAsset": {
1699 + "allOf": [
1700 + {
1701 + "$ref": "#/components/schemas/Sale"
1702 + },
1703 + {
1704 + "type": "object",
1705 + "properties": {
1706 + "asset_id": {
1707 + "type": "string"
1708 + },
1709 + "asset_slug": {
1710 + "type": "string"
1711 + },
1712 + "title": {
1713 + "type": "string"
1714 + }
1715 + }
1716 + }
1717 + ]
1718 + },
1719 + "Listing": {
1720 + "type": "object",
1721 + "properties": {
1722 + "id": {
1723 + "type": "string"
1724 + },
1725 + "source_id": {
1726 + "type": "string"
1727 + },
1728 + "source_url": {
1729 + "type": "string"
1730 + },
1731 + "listing_type": {
1732 + "type": "string"
1733 + },
1734 + "price": {
1735 + "type": [
1736 + "number",
1737 + "null"
1738 + ]
1739 + },
1740 + "currency": {
1741 + "type": [
1742 + "string",
1743 + "null"
1744 + ]
1745 + },
1746 + "price_usd": {
1747 + "type": [
1748 + "number",
1749 + "null"
1750 + ]
1751 + },
1752 + "availability": {
1753 + "type": "string"
1754 + },
1755 + "discount_to_riv": {
1756 + "type": [
1757 + "number",
1758 + "null"
1759 + ]
1760 + }
1761 + }
1762 + },
1763 + "PricePoint": {
1764 + "type": "object",
1765 + "properties": {
1766 + "date": {
1767 + "type": "string",
1768 + "format": "date"
1769 + },
1770 + "riv_usd": {
1771 + "type": [
1772 + "number",
1773 + "null"
1774 + ]
1775 + },
1776 + "latest_sale_usd": {
1777 + "type": [
1778 + "number",
1779 + "null"
1780 + ]
1781 + },
1782 + "median_usd": {
1783 + "type": [
1784 + "number",
1785 + "null"
1786 + ]
1787 + },
1788 + "sales_count": {
1789 + "type": "integer"
1790 + },
1791 + "volume_usd": {
1792 + "type": [
1793 + "number",
1794 + "null"
1795 + ]
1796 + },
1797 + "listings_count": {
1798 + "type": "integer"
1799 + },
1800 + "min_ask_usd": {
1801 + "type": [
1802 + "number",
1803 + "null"
1804 + ]
1805 + }
1806 + }
1807 + },
1808 + "Category": {
1809 + "type": "object",
1810 + "properties": {
1811 + "slug": {
1812 + "type": "string"
1813 + },
1814 + "parent_slug": {
1815 + "type": [
1816 + "string",
1817 + "null"
1818 + ]
1819 + },
1820 + "family_slug": {
1821 + "type": "string"
1822 + },
1823 + "name": {
1824 + "type": "string"
1825 + },
1826 + "level": {
1827 + "type": "integer"
1828 + },
1829 + "phase": {
1830 + "type": "integer"
1831 + },
1832 + "index_ticker": {
1833 + "type": [
1834 + "string",
1835 + "null"
1836 + ]
1837 + },
1838 + "tracked_assets": {
1839 + "type": "integer"
1840 + }
1841 + }
1842 + },
1843 + "Index": {
1844 + "type": "object",
1845 + "properties": {
1846 + "ticker": {
1847 + "type": "string"
1848 + },
1849 + "name": {
1850 + "type": "string"
1851 + },
1852 + "is_flagship": {
1853 + "type": "boolean"
1854 + },
1855 + "as_of": {
1856 + "type": [
1857 + "string",
1858 + "null"
1859 + ]
1860 + },
1861 + "value": {
1862 + "type": [
1863 + "number",
1864 + "null"
1865 + ]
1866 + },
1867 + "published": {
1868 + "type": "boolean"
1869 + },
1870 + "change_1d": {
1871 + "type": [
1872 + "number",
1873 + "null"
1874 + ]
1875 + },
1876 + "change_7d": {
1877 + "type": [
1878 + "number",
1879 + "null"
1880 + ]
1881 + },
1882 + "change_30d": {
1883 + "type": [
1884 + "number",
1885 + "null"
1886 + ]
1887 + },
1888 + "change_ytd": {
1889 + "type": [
1890 + "number",
1891 + "null"
1892 + ]
1893 + },
1894 + "change_1y": {
1895 + "type": [
1896 + "number",
1897 + "null"
1898 + ]
1899 + },
1900 + "constituents_count": {
1901 + "type": [
1902 + "integer",
1903 + "null"
1904 + ]
1905 + },
1906 + "transactions": {
1907 + "type": [
1908 + "integer",
1909 + "null"
1910 + ]
1911 + },
1912 + "market_cap_est_usd": {
1913 + "type": [
1914 + "number",
1915 + "null"
1916 + ]
1917 + },
1918 + "market_cap_confidence": {
1919 + "type": [
1920 + "string",
1921 + "null"
1922 + ]
1923 + }
1924 + }
1925 + },
1926 + "IndexPoint": {
1927 + "type": "object",
1928 + "properties": {
1929 + "date": {
1930 + "type": "string",
1931 + "format": "date"
1932 + },
1933 + "value": {
1934 + "type": "number"
1935 + },
1936 + "constituents_count": {
1937 + "type": "integer"
1938 + },
1939 + "transactions": {
1940 + "type": "integer"
1941 + },
1942 + "volume_usd": {
1943 + "type": [
1944 + "number",
1945 + "null"
1946 + ]
1947 + }
1948 + }
1949 + },
1950 + "Market": {
1951 + "type": "object",
1952 + "properties": {
1953 + "slug": {
1954 + "type": "string"
1955 + },
1956 + "name": {
1957 + "type": "string"
1958 + },
1959 + "as_of": {
1960 + "type": [
1961 + "string",
1962 + "null"
1963 + ]
1964 + },
1965 + "index_value": {
1966 + "type": [
1967 + "number",
1968 + "null"
1969 + ]
1970 + },
1971 + "tracked_assets": {
1972 + "type": [
1973 + "integer",
1974 + "null"
1975 + ]
1976 + },
1977 + "sales": {
1978 + "type": [
1979 + "integer",
1980 + "null"
1981 + ]
1982 + },
1983 + "volume_usd": {
1984 + "type": [
1985 + "number",
1986 + "null"
1987 + ]
1988 + },
1989 + "change_30d": {
1990 + "type": [
1991 + "number",
1992 + "null"
1993 + ]
1994 + }
1995 + }
1996 + },
1997 + "MarketDetail": {
1998 + "type": "object",
1999 + "properties": {
2000 + "category": {
2001 + "type": "object"
2002 + },
2003 + "snapshot": {
2004 + "type": [
2005 + "object",
2006 + "null"
2007 + ]
2008 + },
2009 + "counts": {
2010 + "type": "object"
2011 + },
2012 + "gainers": {
2013 + "type": "array",
2014 + "items": {
2015 + "$ref": "#/components/schemas/AssetSummary"
2016 + }
2017 + },
2018 + "losers": {
2019 + "type": "array",
2020 + "items": {
2021 + "$ref": "#/components/schemas/AssetSummary"
2022 + }
2023 + },
2024 + "most_valuable": {
2025 + "type": "array",
2026 + "items": {
2027 + "$ref": "#/components/schemas/AssetSummary"
2028 + }
2029 + },
2030 + "most_liquid": {
2031 + "type": "array",
2032 + "items": {
2033 + "$ref": "#/components/schemas/AssetSummary"
2034 + }
2035 + },
2036 + "recent_sales": {
2037 + "type": "array",
2038 + "items": {
2039 + "type": "object"
2040 + }
2041 + },
2042 + "history": {
2043 + "type": "array",
2044 + "items": {
2045 + "type": "object"
2046 + }
2047 + }
2048 + }
2049 + },
2050 + "Stats": {
2051 + "type": "object",
2052 + "properties": {
2053 + "assets": {
2054 + "type": "integer"
2055 + },
2056 + "sales": {
2057 + "type": "integer"
2058 + },
2059 + "listings": {
2060 + "type": "integer"
2061 + },
2062 + "sources": {
2063 + "type": "integer"
2064 + },
2065 + "connectors": {
2066 + "type": "integer"
2067 + },
2068 + "categories": {
2069 + "type": "integer"
2070 + }
2071 + }
2072 + }
2073 + }
2074 + }
2075 +}
added packages/ai/package.json +30 −0
@@ -0,0 +1,30 @@
1 +{
2 + "name": "@rareindex/ai",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "exports": {
7 + ".": {
8 + "types": "./src/index.ts",
9 + "default": "./src/index.ts"
10 + }
11 + },
12 + "scripts": {
13 + "build": "tsc -p tsconfig.json --noEmit",
14 + "typecheck": "tsc -p tsconfig.json --noEmit",
15 + "test": "vitest run --passWithNoTests"
16 + },
17 + "dependencies": {
18 + "@anthropic-ai/sdk": "^0.124.0",
19 + "@rareindex/database": "workspace:*",
20 + "@rareindex/shared": "workspace:*",
21 + "@rareindex/taxonomy": "workspace:*",
22 + "openai": "^7.10.0",
23 + "zod": "^4.0.0"
24 + },
25 + "devDependencies": {
26 + "@types/node": "^24.0.0",
27 + "typescript": "^5.9.3",
28 + "vitest": "^3.2.0"
29 + }
30 +}
added packages/ai/src/ai.test.ts +101 −0
@@ -0,0 +1,101 @@
1 +import { describe, expect, it, vi, beforeEach } from 'vitest';
2 +import { z } from 'zod';
3 +import { estimateUsd, priceFor, addUsage, ZERO_USAGE } from './pricing.js';
4 +import { ModelRouter } from './router.js';
5 +import { AiNotConfiguredError, type ModelProvider, type Role } from './types.js';
6 +import { recentCosts, setCostSink, recordCost } from './costs.js';
7 +import { identifyCollectible, IdentificationSchema } from './helpers/identify.js';
8 +
9 +describe('pricing', () => {
10 + it('estimates usd from the price table', () => {
11 + expect(priceFor('claude-opus-5')?.input).toBe(5);
12 + expect(estimateUsd('claude-haiku-4-5', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })).toBe(1);
13 + expect(estimateUsd('claude-opus-5', { inputTokens: 0, outputTokens: 1000, cacheReadTokens: 1_000_000, cacheWriteTokens: 0 })).toBeCloseTo(0.025 + 0.5, 5);
14 + expect(estimateUsd('unknown-local-model', { ...ZERO_USAGE, inputTokens: 5000 })).toBe(0);
15 + expect(addUsage(ZERO_USAGE, { inputTokens: 1, outputTokens: 2, cacheReadTokens: 3, cacheWriteTokens: 4 }).cacheWriteTokens).toBe(4);
16 + });
17 +});
18 +
19 +describe('router', () => {
20 + it('reports not configured without keys', () => {
21 + const r = new ModelRouter({ persistCosts: false });
22 + expect(r.configured).toBe(false);
23 + expect(() => r.providerFor('research')).toThrow(AiNotConfiguredError);
24 + expect(r.table().every((t) => t.provider === null)).toBe(true);
25 + });
26 + it('routes roles and honours overrides', () => {
27 + const r = new ModelRouter({ anthropicApiKey: 'sk-test', openaiApiKey: 'sk-openai', overrides: { research: 'anthropic:claude-opus-5', classify: 'claude-haiku-4-5', embed: 'openai:text-embedding-3-small' }, persistCosts: false });
28 + expect(r.providerFor('research').id).toBe('anthropic');
29 + expect(r.modelFor('research')).toBe('claude-opus-5');
30 + expect(r.modelFor('normalize')).toBe('claude-haiku-4-5');
31 + expect(r.providerFor('embed').id).toBe('openai');
32 + expect(r.modelFor('vision')).toBe('claude-opus-5');
33 + const table = r.table();
34 + expect(table.find((t) => t.role === 'embed')?.provider).toBe('openai');
35 + });
36 + it('embeddings unavailable with Anthropic only', () => {
37 + const r = new ModelRouter({ anthropicApiKey: 'sk-test', persistCosts: false });
38 + expect(r.supports('embed')).toBe(false);
39 + expect(() => r.providerFor('embed')).toThrow(/embeddings/);
40 + });
41 +});
42 +
43 +describe('cost sink', () => {
44 + beforeEach(() => setCostSink(null));
45 + it('records and buffers events', async () => {
46 + const sink = vi.fn();
47 + setCostSink(sink);
48 + await recordCost({ provider: 'anthropic', model: 'claude-haiku-4-5', role: 'classify', usage: ZERO_USAGE, usdEst: 0.001 });
49 + expect(sink).toHaveBeenCalledTimes(1);
50 + expect(recentCosts().at(-1)?.role).toBe('classify');
51 + });
52 +});
53 +
54 +function fakeProvider(data: unknown): ModelProvider {
55 + return {
56 + id: 'fake',
57 + supports: () => true,
58 + modelFor: () => 'fake-model',
59 + complete: async () => ({ text: '', model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0, stopReason: 'end_turn', refusal: null }),
60 + stream: async function* () {},
61 + runTools: async function* () {},
62 + extract: async <T,>(_role: Role, req: { schema: z.ZodType<T> }) => {
63 + const parsed = req.schema.parse(data);
64 + return { data: parsed, confidence: (parsed as { confidence?: number }).confidence ?? 1, model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0 };
65 + },
66 + vision: async <T,>(req: { schema: z.ZodType<T> }) => ({ data: req.schema.parse(data), confidence: 0.9, model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0 }),
67 + embed: async () => ({ vectors: [], model: 'fake', provider: 'fake', dimensions: 0, usdEst: 0 }),
68 + };
69 +}
70 +
71 +describe('identifyCollectible', () => {
72 + it('validates schema and rejects unknown categories', async () => {
73 + const guess = IdentificationSchema.parse({
74 + categorySlug: 'not_a_real_slug',
75 + name: 'Charizard',
76 + brand: null,
77 + franchise: 'Pokémon',
78 + set: 'Base Set',
79 + number: '4/102',
80 + year: 1999,
81 + variant: '1st Edition Holo',
82 + language: 'English',
83 + grader: 'psa',
84 + grade: '10',
85 + certificationNumber: null,
86 + conditionNotes: null,
87 + likelyGradeRange: null,
88 + confidence: 0.82,
89 + rationale: 'Holo foil, 1st edition stamp visible',
90 + searchQueries: ['Charizard Base Set 1st Edition 4/102'],
91 + warnings: [],
92 + });
93 + const res = await identifyCollectible({ text: '1999 Pokemon Base Set Charizard 1st Edition PSA 10', provider: fakeProvider(guess) });
94 + expect(res.data.categorySlug).toBeNull();
95 + expect(res.data.warnings[0]).toMatch(/unknown category/);
96 + expect(res.confidence).toBeCloseTo(0.82);
97 + });
98 + it('requires input', async () => {
99 + await expect(identifyCollectible({ provider: fakeProvider({}) })).rejects.toThrow(/provide images or text/);
100 + });
101 +});
added packages/ai/src/costs.ts +61 −0
@@ -0,0 +1,61 @@
1 +import { newId, logger } from '@rareindex/shared';
2 +import type { CostContext, Usage } from './types.js';
3 +
4 +export interface CostEvent {
5 + provider: string;
6 + model: string;
7 + role: string;
8 + usage: Usage;
9 + usdEst: number;
10 + units?: number;
11 + context?: CostContext;
12 +}
13 +
14 +export type CostSink = (event: CostEvent) => Promise<void> | void;
15 +
16 +let sink: CostSink | null = null;
17 +const buffer: CostEvent[] = [];
18 +
19 +/** Install the persistence sink (the default DB sink is installed lazily by `installDbCostSink`). */
20 +export function setCostSink(fn: CostSink | null): void {
21 + sink = fn;
22 +}
23 +
24 +export async function recordCost(event: CostEvent): Promise<void> {
25 + buffer.push(event);
26 + if (buffer.length > 500) buffer.shift();
27 + if (!sink) return;
28 + try {
29 + await sink(event);
30 + } catch (err) {
31 + logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'ai cost sink failed');
32 + }
33 +}
34 +
35 +/** In-memory tail of recent AI cost events (diagnostics/tests). */
36 +export function recentCosts(): readonly CostEvent[] {
37 + return buffer;
38 +}
39 +
40 +/** Persist AI spend into the shared `costs` table (§169). Imports the DB lazily to keep this package light. */
41 +export async function installDbCostSink(): Promise<void> {
42 + const { getDb, costs } = await import('@rareindex/database');
43 + setCostSink(async (e) => {
44 + await getDb()
45 + .insert(costs)
46 + .values({
47 + id: newId('event'),
48 + occurredAt: new Date(),
49 + kind: 'ai',
50 + provider: e.provider,
51 + connectorId: e.context?.connectorId ?? null,
52 + categorySlug: e.context?.categorySlug ?? null,
53 + endpoint: e.context?.endpoint ?? e.role,
54 + userId: e.context?.userId ?? null,
55 + units: e.units ?? e.usage.inputTokens + e.usage.outputTokens,
56 + credits: 0,
57 + usdEst: e.usdEst,
58 + metadata: { model: e.model, role: e.role, usage: e.usage, ...(e.context?.metadata ?? {}) },
59 + });
60 + });
61 +}
added packages/ai/src/helpers/identify.ts +89 −0
@@ -0,0 +1,89 @@
1 +import { z } from 'zod';
2 +import { CATEGORIES, GRADERS } from '@rareindex/taxonomy';
3 +import { getRouter } from '../router.js';
4 +import type { ContentPart, CostContext, ImageInput, ModelProvider } from '../types.js';
5 +
6 +/** Structured identification guess (§114). Every field is nullable: unknown stays null, never guessed. */
7 +export const IdentificationSchema = z.object({
8 + categorySlug: z.string().nullable().describe('One of the RareIndex taxonomy slugs provided, or null'),
9 + name: z.string().nullable().describe('Item name (e.g. "Charizard", "Daytona", "Air Jordan 1 Chicago", "Millennium Falcon")'),
10 + brand: z.string().nullable(),
11 + franchise: z.string().nullable(),
12 + set: z.string().nullable().describe('Set / series / collection name'),
13 + number: z.string().nullable().describe('Card number, reference number, set number, style code…'),
14 + year: z.number().int().nullable(),
15 + variant: z.string().nullable().describe('Edition/variant: 1st Edition, Holo, Shadowless, Foil, colorway…'),
16 + language: z.string().nullable(),
17 + grader: z.string().nullable().describe('psa, bgs, cgc, sgc, wata, vga, pcgs, ngc… or null if raw/unknown'),
18 + grade: z.string().nullable(),
19 + certificationNumber: z.string().nullable(),
20 + conditionNotes: z.string().nullable().describe('Visible condition observations, hedged'),
21 + likelyGradeRange: z.string().nullable().describe('e.g. "PSA 7–8" — only when the image supports it'),
22 + confidence: z.number().min(0).max(1).describe('Overall confidence 0–1 that the identification is correct'),
23 + rationale: z.string().describe('Short explanation of the visual/textual cues used'),
24 + searchQueries: z.array(z.string()).max(5).describe('Up to 5 short search strings to find this item in a catalog'),
25 + warnings: z.array(z.string()).describe('Anything suspicious: possible reprint, proxy, mismatched slab, unreadable'),
26 +});
27 +export type Identification = z.infer<typeof IdentificationSchema>;
28 +
29 +function taxonomyPrompt(): string {
30 + const lines = CATEGORIES.filter((c) => c.phase <= 3).map((c) => `${c.slug} — ${c.name}${c.parent ? ` (child of ${c.parent})` : ''}`);
31 + return `RareIndex taxonomy slugs (choose the most specific that applies):\n${lines.join('\n')}\n\nGrader slugs: ${GRADERS.map((g) => g.slug).join(', ')}.`;
32 +}
33 +
34 +const SYSTEM = `You are RareIndex's identification model for collectibles (trading cards, sports cards, comics, video games, sneakers, watches, LEGO, toys, coins, books, art…).
35 +Identify the object as precisely as the evidence allows. Never invent details you cannot see or read: unknown fields must be null. Report a calibrated confidence. You do not authenticate items and must flag signs of reprints, proxies or inconsistent grading labels in warnings.
36 +${taxonomyPrompt()}`;
37 +
38 +export interface IdentifyOptions {
39 + images?: ImageInput[];
40 + text?: string;
41 + provider?: ModelProvider;
42 + cost?: CostContext;
43 +}
44 +
45 +/** Identify a collectible from photos and/or a free-text description. */
46 +export async function identifyCollectible(opts: IdentifyOptions) {
47 + const provider = opts.provider ?? getRouter();
48 + const parts: ContentPart[] = [];
49 + for (const img of opts.images ?? []) parts.push({ type: 'image', image: img });
50 + if (opts.text) parts.push({ type: 'text', text: `Description / listing text:\n${opts.text}` });
51 + if (parts.length === 0) throw new Error('identifyCollectible: provide images or text');
52 + const role = opts.images?.length ? 'vision' : 'classify';
53 + const res = await provider.extract(role, {
54 + schema: IdentificationSchema,
55 + system: SYSTEM,
56 + prompt: 'Identify this collectible. Fill the schema; use null for anything not evidenced.',
57 + input: parts,
58 + maxTokens: 2048,
59 + effort: 'medium',
60 + cost: { endpoint: 'identify', ...(opts.cost ?? {}) },
61 + });
62 + const data = res.data;
63 + if (data.categorySlug && !CATEGORIES.some((c) => c.slug === data.categorySlug)) {
64 + data.warnings.push(`model proposed unknown category ${data.categorySlug}`);
65 + data.categorySlug = null;
66 + }
67 + return { ...res, data };
68 +}
69 +
70 +/** Cheap category classification from a title/description (used by normalizers and taxonomy discovery). */
71 +export const CategoryGuessSchema = z.object({
72 + categorySlug: z.string().nullable(),
73 + confidence: z.number().min(0).max(1),
74 + alternatives: z.array(z.string()).max(3),
75 +});
76 +export async function classifyCategory(title: string, opts: { provider?: ModelProvider; cost?: CostContext } = {}) {
77 + const provider = opts.provider ?? getRouter();
78 + const res = await provider.extract('classify', {
79 + schema: CategoryGuessSchema,
80 + system: `Classify collectible listings into RareIndex taxonomy slugs. Return null when none applies.\n${taxonomyPrompt()}`,
81 + prompt: 'Classify this listing title.',
82 + input: title,
83 + maxTokens: 256,
84 + effort: 'low',
85 + cost: { endpoint: 'classify', ...(opts.cost ?? {}) },
86 + });
87 + if (res.data.categorySlug && !CATEGORIES.some((c) => c.slug === res.data.categorySlug)) res.data.categorySlug = null;
88 + return res;
89 +}
added packages/ai/src/helpers/news.ts +37 −0
@@ -0,0 +1,37 @@
1 +import { z } from 'zod';
2 +import { getRouter } from '../router.js';
3 +import type { CostContext, ModelProvider } from '../types.js';
4 +
5 +export interface NewsInput {
6 + url: string;
7 + title: string;
8 + text: string;
9 + publishedAt?: string | null;
10 +}
11 +
12 +export const NewsSummarySchema = z.object({
13 + items: z.array(
14 + z.object({
15 + url: z.string(),
16 + summary: z.string().describe('2–3 factual sentences, no speculation, figures only if present in the source'),
17 + newsType: z.enum(['auction_results', 'record_sales', 'grading', 'releases', 'trends', 'discoveries', 'events', 'other']),
18 + categorySlugs: z.array(z.string()).max(4),
19 + mentionedPrices: z.array(z.object({ amount: z.number(), currency: z.string(), what: z.string() })).max(5),
20 + }),
21 + ),
22 +});
23 +
24 +/** Summarise market news items with source links (§156). Facts only; the UI links each summary to its source. */
25 +export async function summarizeNews(items: NewsInput[], opts: { provider?: ModelProvider; cost?: CostContext } = {}) {
26 + const provider = opts.provider ?? getRouter();
27 + const input = items.map((i, n) => `### Item ${n + 1}\nURL: ${i.url}\nTitle: ${i.title}\nPublished: ${i.publishedAt ?? 'unknown'}\n${i.text.slice(0, 6000)}`).join('\n\n');
28 + return provider.extract('summarize', {
29 + schema: NewsSummarySchema,
30 + system: 'You summarise collectibles-market news for a data terminal. Neutral tone, no investment advice, keep every figure exactly as stated in the source, return one entry per item with the same URL.',
31 + prompt: 'Summarise each item.',
32 + input,
33 + maxTokens: 4096,
34 + effort: 'low',
35 + cost: { endpoint: 'summarize_news', ...(opts.cost ?? {}) },
36 + });
37 +}
added packages/ai/src/helpers/verify-match.ts +38 −0
@@ -0,0 +1,38 @@
1 +import { z } from 'zod';
2 +import { getRouter } from '../router.js';
3 +import type { CostContext, ModelProvider } from '../types.js';
4 +
5 +export interface MatchCandidate {
6 + title: string;
7 + attributes?: Record<string, unknown>;
8 + identifiers?: Record<string, string>;
9 + grade?: { grader?: string | null; grade?: string | null } | null;
10 + source?: string;
11 +}
12 +
13 +export const MatchVerdictSchema = z.object({
14 + sameAsset: z.boolean().describe('True if both describe the same canonical collectible (ignoring grade/condition)'),
15 + sameVariant: z.boolean().describe('True if grade/condition/edition also match'),
16 + confidence: z.number().min(0).max(1),
17 + blockingDifferences: z.array(z.string()).describe('Concrete attribute differences that prevent a match (set, number, edition, language, year…)'),
18 + rationale: z.string(),
19 +});
20 +export type MatchVerdict = z.infer<typeof MatchVerdictSchema>;
21 +
22 +/**
23 + * LLM verification for entity resolution (§112) — the last step after deterministic identifiers,
24 + * canonical keys and fuzzy matching have produced a candidate pair.
25 + */
26 +export async function verifyEntityMatch(a: MatchCandidate, b: MatchCandidate, opts: { provider?: ModelProvider; cost?: CostContext } = {}) {
27 + const provider = opts.provider ?? getRouter();
28 + const res = await provider.extract('resolve', {
29 + schema: MatchVerdictSchema,
30 + system: 'You verify whether two collectible records refer to the same canonical asset. Be strict: different set, card number, edition (1st Edition vs Unlimited), language, year, reference number, colorway or size are different assets. Grade and condition differences make different variants of the same asset.',
31 + prompt: 'Compare record A and record B.',
32 + input: `Record A:\n${JSON.stringify(a, null, 2)}\n\nRecord B:\n${JSON.stringify(b, null, 2)}`,
33 + maxTokens: 800,
34 + effort: 'low',
35 + cost: { endpoint: 'verify_match', ...(opts.cost ?? {}) },
36 + });
37 + return res;
38 +}
added packages/ai/src/index.ts +9 −0
@@ -0,0 +1,9 @@
1 +export * from './types.js';
2 +export * from './pricing.js';
3 +export * from './costs.js';
4 +export * from './router.js';
5 +export { AnthropicProvider, ANTHROPIC_DEFAULT_MODELS } from './providers/anthropic.js';
6 +export { OpenAICompatibleProvider } from './providers/openai-compatible.js';
7 +export * from './helpers/identify.js';
8 +export * from './helpers/verify-match.js';
9 +export * from './helpers/news.js';
added packages/ai/src/pricing.ts +55 −0
@@ -0,0 +1,55 @@
1 +import type { Usage } from './types.js';
2 +
3 +/**
4 + * List prices in USD per 1M tokens (first-party API rates, cached 2026-06). Used only for the
5 + * cost ledger (§169); billing truth lives with the provider. Update when prices change.
6 + */
7 +export interface ModelPrice {
8 + input: number;
9 + output: number;
10 + /** cache read multiplier vs input (Anthropic: 0.1) */
11 + cacheRead: number;
12 + /** cache write multiplier vs input (Anthropic: 1.25) */
13 + cacheWrite: number;
14 +}
15 +
16 +export const PRICE_TABLE: Record<string, ModelPrice> = {
17 + 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.1, cacheWrite: 1.25 },
18 + 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.1, cacheWrite: 1.25 },
19 + 'claude-sonnet-5': { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 1.25 },
20 + 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.1, cacheWrite: 1.25 },
21 + 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
22 + 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.1, cacheWrite: 1.25 },
23 + // OpenAI (approximate list prices; verify before relying on them)
24 + 'gpt-5': { input: 1.25, output: 10, cacheRead: 0.1, cacheWrite: 1 },
25 + 'gpt-5-mini': { input: 0.25, output: 2, cacheRead: 0.1, cacheWrite: 1 },
26 + 'gpt-4o-mini': { input: 0.15, output: 0.6, cacheRead: 0.5, cacheWrite: 1 },
27 + 'text-embedding-3-small': { input: 0.02, output: 0, cacheRead: 1, cacheWrite: 1 },
28 + 'text-embedding-3-large': { input: 0.13, output: 0, cacheRead: 1, cacheWrite: 1 },
29 +};
30 +
31 +/** Local/self-hosted models cost nothing at the API layer. */
32 +export function priceFor(model: string): ModelPrice | null {
33 + if (PRICE_TABLE[model]) return PRICE_TABLE[model]!;
34 + const base = Object.keys(PRICE_TABLE).find((k) => model.startsWith(k));
35 + return base ? PRICE_TABLE[base]! : null;
36 +}
37 +
38 +export function estimateUsd(model: string, usage: Usage): number {
39 + const p = priceFor(model);
40 + if (!p) return 0;
41 + const usd =
42 + (usage.inputTokens * p.input + usage.outputTokens * p.output + usage.cacheReadTokens * p.input * p.cacheRead + usage.cacheWriteTokens * p.input * p.cacheWrite) / 1_000_000;
43 + return Math.round(usd * 1e6) / 1e6;
44 +}
45 +
46 +export const ZERO_USAGE: Usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
47 +
48 +export function addUsage(a: Usage, b: Usage): Usage {
49 + return {
50 + inputTokens: a.inputTokens + b.inputTokens,
51 + outputTokens: a.outputTokens + b.outputTokens,
52 + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
53 + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
54 + };
55 +}
added packages/ai/src/providers/anthropic.ts +275 −0
@@ -0,0 +1,275 @@
1 +import Anthropic from '@anthropic-ai/sdk';
2 +import { z } from 'zod';
3 +import { estimateUsd, addUsage, ZERO_USAGE } from '../pricing.js';
4 +import { recordCost } from '../costs.js';
5 +import { AiNotConfiguredError, AiRefusalError, type ChatMessage, type Completion, type CompletionRequest, type ContentPart, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest, type Usage } from '../types.js';
6 +
7 +export interface AnthropicProviderOptions {
8 + apiKey: string;
9 + /** model per role; defaults below */
10 + models?: Partial<Record<Role, string>>;
11 + /** stable system-prompt prefix cached across calls */
12 + defaultTimeoutMs?: number;
13 +}
14 +
15 +/**
16 + * Default role → model mapping. Fast/cheap for bulk pipeline work, strong for research and vision.
17 + * All ids are the exact strings from the current model table (no date suffixes).
18 + */
19 +export const ANTHROPIC_DEFAULT_MODELS: Record<Role, string> = {
20 + normalize: 'claude-haiku-4-5',
21 + classify: 'claude-haiku-4-5',
22 + summarize: 'claude-haiku-4-5',
23 + resolve: 'claude-sonnet-5',
24 + research: 'claude-opus-5',
25 + vision: 'claude-opus-5',
26 + embed: '',
27 +};
28 +
29 +function usageOf(u: Anthropic.Usage | Anthropic.MessageDeltaUsage | null | undefined): Usage {
30 + return {
31 + inputTokens: u?.input_tokens ?? 0,
32 + outputTokens: u?.output_tokens ?? 0,
33 + cacheReadTokens: u?.cache_read_input_tokens ?? 0,
34 + cacheWriteTokens: u?.cache_creation_input_tokens ?? 0,
35 + };
36 +}
37 +
38 +function toBlocks(content: string | ContentPart[]): string | Anthropic.ContentBlockParam[] {
39 + if (typeof content === 'string') return content;
40 + return content.map((p): Anthropic.ContentBlockParam => {
41 + if (p.type === 'text') return { type: 'text', text: p.text };
42 + const img = p.image;
43 + if (img.url) return { type: 'image', source: { type: 'url', url: img.url } };
44 + return { type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.data ?? '' } };
45 + });
46 +}
47 +
48 +function toMessages(messages: ChatMessage[]): Anthropic.MessageParam[] {
49 + return messages.map((m) => ({ role: m.role, content: toBlocks(m.content) }));
50 +}
51 +
52 +/** Haiku 4.5 still uses budgeted thinking; current-generation models use adaptive thinking. */
53 +function thinkingFor(model: string, effort: string | undefined): Anthropic.ThinkingConfigParam | undefined {
54 + if (model.startsWith('claude-haiku')) return undefined;
55 + if (effort === 'low') return { type: 'adaptive' };
56 + return { type: 'adaptive' };
57 +}
58 +
59 +function outputConfig(model: string, effort: string | undefined, format?: Anthropic.JSONOutputFormat): Anthropic.OutputConfig | undefined {
60 + const cfg: Anthropic.OutputConfig = {};
61 + if (effort && !model.startsWith('claude-haiku')) cfg.effort = effort as Anthropic.OutputConfig['effort'];
62 + if (format) cfg.format = format;
63 + return Object.keys(cfg).length ? cfg : undefined;
64 +}
65 +
66 +export class AnthropicProvider implements ModelProvider {
67 + readonly id = 'anthropic';
68 + private readonly client: Anthropic;
69 + private readonly models: Record<Role, string>;
70 +
71 + constructor(opts: AnthropicProviderOptions) {
72 + if (!opts.apiKey) throw new AiNotConfiguredError('ANTHROPIC_API_KEY missing');
73 + this.client = new Anthropic({ apiKey: opts.apiKey, timeout: opts.defaultTimeoutMs ?? 10 * 60_000, maxRetries: 2 });
74 + this.models = { ...ANTHROPIC_DEFAULT_MODELS, ...(opts.models ?? {}) };
75 + }
76 +
77 + supports(role: Role): boolean {
78 + return role !== 'embed';
79 + }
80 +
81 + modelFor(role: Role): string {
82 + return this.models[role] || ANTHROPIC_DEFAULT_MODELS[role];
83 + }
84 +
85 + private async account(role: Role, model: string, usage: Usage, cost?: CompletionRequest['cost']): Promise<number> {
86 + const usdEst = estimateUsd(model, usage);
87 + await recordCost({ provider: this.id, model, role, usage, usdEst, context: cost });
88 + return usdEst;
89 + }
90 +
91 + async complete(role: Role, req: CompletionRequest): Promise<Completion> {
92 + const model = this.modelFor(role);
93 + const system = req.json ? `${req.system ?? ''}\nRespond with a single JSON object and nothing else.`.trim() : req.system;
94 + const res = await this.client.messages.create({
95 + model,
96 + max_tokens: req.maxTokens ?? 4096,
97 + ...(system ? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] } : {}),
98 + messages: toMessages(req.messages),
99 + ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}),
100 + ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}),
101 + ...(req.stopSequences ? { stop_sequences: req.stopSequences } : {}),
102 + });
103 + const usage = usageOf(res.usage);
104 + const usdEst = await this.account(role, model, usage, req.cost);
105 + if (res.stop_reason === 'refusal') {
106 + const d = res.stop_details;
107 + return { text: '', model, provider: this.id, usage, usdEst, stopReason: 'refusal', refusal: { category: d?.category ?? null, explanation: d?.explanation ?? null } };
108 + }
109 + const text = res.content
110 + .filter((b): b is Anthropic.TextBlock => b.type === 'text')
111 + .map((b) => b.text)
112 + .join('');
113 + return { text, model, provider: this.id, usage, usdEst, stopReason: res.stop_reason, refusal: null };
114 + }
115 +
116 + async *stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> {
117 + yield* this.runTools(role, { ...req, tools: [], execute: async () => null });
118 + }
119 +
120 + async *runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> {
121 + const model = this.modelFor(role);
122 + const messages = toMessages(req.messages);
123 + const tools: Anthropic.Tool[] = req.tools.map((t) => ({
124 + name: t.name,
125 + description: t.description,
126 + input_schema: t.inputSchema as Anthropic.Tool.InputSchema,
127 + }));
128 + const maxIter = req.maxIterations ?? 8;
129 + let total: Usage = ZERO_USAGE;
130 + let iterations = 0;
131 + let stopReason: string | null = null;
132 + try {
133 + while (iterations < maxIter) {
134 + iterations++;
135 + const stream = this.client.messages.stream(
136 + {
137 + model,
138 + max_tokens: req.maxTokens ?? 8192,
139 + ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}),
140 + messages,
141 + ...(tools.length ? { tools } : {}),
142 + ...(thinkingFor(model, req.effort) ? { thinking: { ...thinkingFor(model, req.effort)!, display: 'summarized' } as Anthropic.ThinkingConfigParam } : {}),
143 + ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}),
144 + },
145 + { signal: req.signal },
146 + );
147 + const queue: StreamDelta[] = [];
148 + let resolveWake: (() => void) | null = null;
149 + const wake = () => {
150 + resolveWake?.();
151 + resolveWake = null;
152 + };
153 + stream.on('text', (delta) => {
154 + queue.push({ type: 'text', text: delta });
155 + wake();
156 + });
157 + stream.on('thinking', (delta) => {
158 + queue.push({ type: 'thinking', text: delta });
159 + wake();
160 + });
161 + let finished = false;
162 + let failure: unknown = null;
163 + const finalP = stream
164 + .finalMessage()
165 + .catch((err) => {
166 + failure = err;
167 + return null;
168 + })
169 + .finally(() => {
170 + finished = true;
171 + wake();
172 + });
173 + while (!finished || queue.length) {
174 + if (queue.length) {
175 + yield queue.shift()!;
176 + continue;
177 + }
178 + await new Promise<void>((r) => {
179 + resolveWake = r;
180 + });
181 + }
182 + const message = await finalP;
183 + if (failure || !message) throw failure ?? new Error('stream ended without a message');
184 + const u = usageOf(message.usage);
185 + total = addUsage(total, u);
186 + yield { type: 'usage', usage: u, usdEst: estimateUsd(model, u), model };
187 + stopReason = message.stop_reason;
188 + if (message.stop_reason === 'refusal') {
189 + const d = message.stop_details;
190 + throw new AiRefusalError(d?.category ?? null, d?.explanation ?? null);
191 + }
192 + if (message.stop_reason === 'pause_turn') {
193 + messages.push({ role: 'assistant', content: message.content });
194 + continue;
195 + }
196 + const toolUses = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
197 + if (message.stop_reason !== 'tool_use' || toolUses.length === 0) break;
198 + messages.push({ role: 'assistant', content: message.content });
199 + const results: Anthropic.ToolResultBlockParam[] = [];
200 + for (const tu of toolUses) {
201 + yield { type: 'tool_call', id: tu.id, name: tu.name, input: tu.input };
202 + const started = Date.now();
203 + let output: unknown;
204 + let isError = false;
205 + try {
206 + output = await req.execute(tu.name, tu.input);
207 + } catch (err) {
208 + isError = true;
209 + output = { error: err instanceof Error ? err.message : String(err) };
210 + }
211 + yield { type: 'tool_result', id: tu.id, name: tu.name, output, isError, durationMs: Date.now() - started };
212 + results.push({ type: 'tool_result', tool_use_id: tu.id, content: typeof output === 'string' ? output : JSON.stringify(output ?? null), is_error: isError });
213 + }
214 + messages.push({ role: 'user', content: results });
215 + }
216 + } finally {
217 + await this.account(role, model, total, req.cost);
218 + }
219 + yield { type: 'done', stopReason };
220 + }
221 +
222 + async extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> {
223 + const model = this.modelFor(role);
224 + const schema = z.toJSONSchema(req.schema as z.ZodType, { target: 'draft-2020-12', io: 'output' }) as Record<string, unknown>;
225 + const format: Anthropic.JSONOutputFormat = { type: 'json_schema', schema: stripUnsupported(schema) };
226 + const content = typeof req.input === 'string' ? `${req.prompt}\n\n${req.input}` : [{ type: 'text', text: req.prompt } as ContentPart, ...req.input];
227 + const res = await this.client.messages.create({
228 + model,
229 + max_tokens: req.maxTokens ?? 4096,
230 + ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}),
231 + messages: toMessages([{ role: 'user', content }]),
232 + ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}),
233 + output_config: outputConfig(model, req.effort, format)!,
234 + });
235 + const usage = usageOf(res.usage);
236 + const usdEst = await this.account(role, model, usage, req.cost);
237 + if (res.stop_reason === 'refusal') throw new AiRefusalError(res.stop_details?.category ?? null, res.stop_details?.explanation ?? null);
238 + const text = res.content
239 + .filter((b): b is Anthropic.TextBlock => b.type === 'text')
240 + .map((b) => b.text)
241 + .join('');
242 + const parsed = req.schema.safeParse(JSON.parse(text));
243 + if (!parsed.success) throw new Error(`extract: model output did not match schema: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
244 + const data = parsed.data as T & { confidence?: number };
245 + const confidence = typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 1;
246 + return { data: parsed.data, confidence, model, provider: this.id, usage, usdEst };
247 + }
248 +
249 + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> {
250 + return this.extract('vision', req);
251 + }
252 +
253 + async embed(_req: EmbedRequest): Promise<EmbedResult> {
254 + throw new AiNotConfiguredError('Anthropic has no embeddings endpoint; configure OPENAI_API_KEY or a local embedding model');
255 + }
256 +}
257 +
258 +/** Structured-output schemas reject a few JSON-schema keywords; strip them recursively. */
259 +function stripUnsupported(schema: Record<string, unknown>): Record<string, unknown> {
260 + const drop = new Set(['$schema', 'default', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'pattern', 'format', 'minItems', 'maxItems', 'multipleOf']);
261 + const walk = (node: unknown): unknown => {
262 + if (Array.isArray(node)) return node.map(walk);
263 + if (node && typeof node === 'object') {
264 + const out: Record<string, unknown> = {};
265 + for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
266 + if (drop.has(k)) continue;
267 + out[k] = walk(v);
268 + }
269 + if (out.type === 'object' && out.properties && out.additionalProperties === undefined) out.additionalProperties = false;
270 + return out;
271 + }
272 + return node;
273 + };
274 + return walk(schema) as Record<string, unknown>;
275 +}
added packages/ai/src/providers/openai-compatible.ts +206 −0
@@ -0,0 +1,206 @@
1 +import OpenAI from 'openai';
2 +import { z } from 'zod';
3 +import { estimateUsd, addUsage, ZERO_USAGE } from '../pricing.js';
4 +import { recordCost } from '../costs.js';
5 +import { AiNotConfiguredError, type ChatMessage, type Completion, type CompletionRequest, type ContentPart, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest, type Usage } from '../types.js';
6 +
7 +export interface OpenAICompatibleOptions {
8 + id?: string;
9 + apiKey: string;
10 + baseURL?: string;
11 + models?: Partial<Record<Role, string>>;
12 + embeddingModel?: string;
13 + embeddingDimensions?: number;
14 +}
15 +
16 +const DEFAULT_MODELS: Record<Role, string> = {
17 + normalize: 'gpt-5-mini',
18 + classify: 'gpt-5-mini',
19 + summarize: 'gpt-5-mini',
20 + resolve: 'gpt-5-mini',
21 + research: 'gpt-5',
22 + vision: 'gpt-5',
23 + embed: 'text-embedding-3-small',
24 +};
25 +
26 +function toContent(content: string | ContentPart[]): string | OpenAI.Chat.Completions.ChatCompletionContentPart[] {
27 + if (typeof content === 'string') return content;
28 + return content.map((p): OpenAI.Chat.Completions.ChatCompletionContentPart => {
29 + if (p.type === 'text') return { type: 'text', text: p.text };
30 + const url = p.image.url ?? `data:${p.image.mediaType};base64,${p.image.data ?? ''}`;
31 + return { type: 'image_url', image_url: { url } };
32 + });
33 +}
34 +
35 +function toMessages(system: string | undefined, messages: ChatMessage[]): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
36 + const out: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
37 + if (system) out.push({ role: 'system', content: system });
38 + for (const m of messages) {
39 + if (m.role === 'user') out.push({ role: 'user', content: toContent(m.content) });
40 + else out.push({ role: 'assistant', content: typeof m.content === 'string' ? m.content : m.content.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text).join('') });
41 + }
42 + return out;
43 +}
44 +
45 +function usageOf(u: OpenAI.Completions.CompletionUsage | null | undefined): Usage {
46 + return {
47 + inputTokens: (u?.prompt_tokens ?? 0) - (u?.prompt_tokens_details?.cached_tokens ?? 0),
48 + outputTokens: u?.completion_tokens ?? 0,
49 + cacheReadTokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
50 + cacheWriteTokens: 0,
51 + };
52 +}
53 +
54 +/**
55 + * Provider for any OpenAI-compatible endpoint: OpenAI itself, or the local MacLustr gateway
56 + * (LOCAL_LLM_BASE_URL). Also the only embeddings provider for now.
57 + */
58 +export class OpenAICompatibleProvider implements ModelProvider {
59 + readonly id: string;
60 + private readonly client: OpenAI;
61 + private readonly models: Record<Role, string>;
62 + private readonly embeddingModel: string;
63 + private readonly embeddingDimensions: number;
64 + private readonly isLocal: boolean;
65 +
66 + constructor(opts: OpenAICompatibleOptions) {
67 + if (!opts.apiKey && !opts.baseURL) throw new AiNotConfiguredError('OPENAI_API_KEY or LOCAL_LLM_BASE_URL missing');
68 + this.id = opts.id ?? (opts.baseURL ? 'openai-compatible' : 'openai');
69 + this.isLocal = Boolean(opts.baseURL);
70 + this.client = new OpenAI({ apiKey: opts.apiKey || 'local', baseURL: opts.baseURL, timeout: 5 * 60_000, maxRetries: 2 });
71 + this.models = { ...DEFAULT_MODELS, ...(opts.models ?? {}) };
72 + this.embeddingModel = opts.embeddingModel ?? this.models.embed;
73 + this.embeddingDimensions = opts.embeddingDimensions ?? 1536;
74 + }
75 +
76 + supports(role: Role): boolean {
77 + if (role === 'embed') return !this.isLocal || Boolean(this.embeddingModel);
78 + return true;
79 + }
80 +
81 + modelFor(role: Role): string {
82 + return this.models[role];
83 + }
84 +
85 + private async account(role: Role, model: string, usage: Usage, cost?: CompletionRequest['cost'], units?: number): Promise<number> {
86 + const usdEst = this.isLocal ? 0 : estimateUsd(model, usage);
87 + await recordCost({ provider: this.id, model, role, usage, usdEst, context: cost, units });
88 + return usdEst;
89 + }
90 +
91 + async complete(role: Role, req: CompletionRequest): Promise<Completion> {
92 + const model = this.modelFor(role);
93 + const res = await this.client.chat.completions.create({
94 + model,
95 + messages: toMessages(req.system, req.messages),
96 + max_completion_tokens: req.maxTokens ?? 4096,
97 + ...(req.temperature !== undefined ? { temperature: req.temperature } : {}),
98 + ...(req.json ? { response_format: { type: 'json_object' } } : {}),
99 + ...(req.stopSequences ? { stop: req.stopSequences } : {}),
100 + });
101 + const usage = usageOf(res.usage);
102 + const usdEst = await this.account(role, model, usage, req.cost);
103 + const choice = res.choices[0];
104 + return { text: choice?.message?.content ?? '', model, provider: this.id, usage, usdEst, stopReason: choice?.finish_reason ?? null, refusal: choice?.message?.refusal ? { category: null, explanation: choice.message.refusal } : null };
105 + }
106 +
107 + async *stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> {
108 + yield* this.runTools(role, { ...req, tools: [], execute: async () => null });
109 + }
110 +
111 + async *runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> {
112 + const model = this.modelFor(role);
113 + const messages = toMessages(req.system, req.messages);
114 + const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = req.tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.inputSchema } }));
115 + let total: Usage = ZERO_USAGE;
116 + let stopReason: string | null = null;
117 + const maxIter = req.maxIterations ?? 8;
118 + try {
119 + for (let i = 0; i < maxIter; i++) {
120 + const stream = await this.client.chat.completions.create(
121 + { model, messages, max_completion_tokens: req.maxTokens ?? 8192, ...(tools.length ? { tools } : {}), stream: true, stream_options: { include_usage: true } },
122 + { signal: req.signal },
123 + );
124 + const calls = new Map<number, { id: string; name: string; args: string }>();
125 + let finish: string | null = null;
126 + for await (const chunk of stream) {
127 + if (chunk.usage) {
128 + const u = usageOf(chunk.usage);
129 + total = addUsage(total, u);
130 + yield { type: 'usage', usage: u, usdEst: this.isLocal ? 0 : estimateUsd(model, u), model };
131 + }
132 + const c = chunk.choices[0];
133 + if (!c) continue;
134 + if (c.delta.content) yield { type: 'text', text: c.delta.content };
135 + for (const tc of c.delta.tool_calls ?? []) {
136 + const cur = calls.get(tc.index) ?? { id: tc.id ?? '', name: '', args: '' };
137 + if (tc.id) cur.id = tc.id;
138 + if (tc.function?.name) cur.name += tc.function.name;
139 + if (tc.function?.arguments) cur.args += tc.function.arguments;
140 + calls.set(tc.index, cur);
141 + }
142 + if (c.finish_reason) finish = c.finish_reason;
143 + }
144 + stopReason = finish;
145 + if (finish !== 'tool_calls' || calls.size === 0) break;
146 + const toolCalls = [...calls.values()];
147 + messages.push({ role: 'assistant', content: null, tool_calls: toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.args } })) });
148 + for (const tc of toolCalls) {
149 + let input: unknown = {};
150 + try {
151 + input = tc.args ? JSON.parse(tc.args) : {};
152 + } catch {
153 + input = {};
154 + }
155 + yield { type: 'tool_call', id: tc.id, name: tc.name, input };
156 + const started = Date.now();
157 + let output: unknown;
158 + let isError = false;
159 + try {
160 + output = await req.execute(tc.name, input);
161 + } catch (err) {
162 + isError = true;
163 + output = { error: err instanceof Error ? err.message : String(err) };
164 + }
165 + yield { type: 'tool_result', id: tc.id, name: tc.name, output, isError, durationMs: Date.now() - started };
166 + messages.push({ role: 'tool', tool_call_id: tc.id, content: typeof output === 'string' ? output : JSON.stringify(output ?? null) });
167 + }
168 + }
169 + } finally {
170 + await this.account(role, model, total, req.cost);
171 + }
172 + yield { type: 'done', stopReason };
173 + }
174 +
175 + async extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> {
176 + const model = this.modelFor(role);
177 + const schema = z.toJSONSchema(req.schema as z.ZodType, { target: 'draft-2020-12', io: 'output' }) as Record<string, unknown>;
178 + const content = typeof req.input === 'string' ? `${req.prompt}\n\n${req.input}` : [{ type: 'text', text: req.prompt } as ContentPart, ...req.input];
179 + const res = await this.client.chat.completions.create({
180 + model,
181 + messages: toMessages(req.system, [{ role: 'user', content }]),
182 + max_completion_tokens: req.maxTokens ?? 4096,
183 + response_format: { type: 'json_schema', json_schema: { name: 'extraction', schema, strict: false } },
184 + });
185 + const usage = usageOf(res.usage);
186 + const usdEst = await this.account(role, model, usage, req.cost);
187 + const text = res.choices[0]?.message?.content ?? '';
188 + const parsed = req.schema.safeParse(JSON.parse(text));
189 + if (!parsed.success) throw new Error(`extract: model output did not match schema: ${parsed.error.message}`);
190 + const data = parsed.data as T & { confidence?: number };
191 + return { data: parsed.data, confidence: typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 1, model, provider: this.id, usage, usdEst };
192 + }
193 +
194 + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> {
195 + return this.extract('vision', req);
196 + }
197 +
198 + async embed(req: EmbedRequest): Promise<EmbedResult> {
199 + if (!this.embeddingModel) throw new AiNotConfiguredError('no embedding model configured');
200 + const res = await this.client.embeddings.create({ model: this.embeddingModel, input: req.texts, ...(this.embeddingModel.startsWith('text-embedding-3') ? { dimensions: this.embeddingDimensions } : {}) });
201 + const usage: Usage = { ...ZERO_USAGE, inputTokens: res.usage?.prompt_tokens ?? 0 };
202 + const usdEst = await this.account('embed', this.embeddingModel, usage, req.cost, req.texts.length);
203 + const vectors = res.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
204 + return { vectors, model: this.embeddingModel, provider: this.id, dimensions: vectors[0]?.length ?? this.embeddingDimensions, usdEst };
205 + }
206 +}
added packages/ai/src/router.ts +125 −0
@@ -0,0 +1,125 @@
1 +import { AnthropicProvider } from './providers/anthropic.js';
2 +import { OpenAICompatibleProvider } from './providers/openai-compatible.js';
3 +import { installDbCostSink } from './costs.js';
4 +import { AiNotConfiguredError, type CompletionRequest, type Completion, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest } from './types.js';
5 +
6 +export interface RouterConfig {
7 + anthropicApiKey?: string;
8 + openaiApiKey?: string;
9 + localBaseUrl?: string;
10 + localApiKey?: string;
11 + /** role → "provider:model" or "model" overrides, e.g. { research: 'anthropic:claude-opus-5' } */
12 + overrides?: Partial<Record<Role, string>>;
13 + persistCosts?: boolean;
14 +}
15 +
16 +export const ROLES: Role[] = ['normalize', 'classify', 'summarize', 'resolve', 'research', 'vision', 'embed'];
17 +
18 +/** Read router config from process.env (server-side only). AI_MODEL_<ROLE> overrides the default model. */
19 +export function configFromEnv(env: NodeJS.ProcessEnv = process.env): RouterConfig {
20 + const overrides: Partial<Record<Role, string>> = {};
21 + for (const r of ROLES) {
22 + const v = env[`AI_MODEL_${r.toUpperCase()}`];
23 + if (v) overrides[r] = v;
24 + }
25 + return {
26 + anthropicApiKey: env.ANTHROPIC_API_KEY,
27 + openaiApiKey: env.OPENAI_API_KEY,
28 + localBaseUrl: env.LOCAL_LLM_BASE_URL,
29 + localApiKey: env.LOCAL_LLM_API_KEY,
30 + overrides,
31 + persistCosts: env.NODE_ENV !== 'test',
32 + };
33 +}
34 +
35 +/**
36 + * Picks a provider per role (§168): Anthropic for everything but embeddings when configured,
37 + * OpenAI(-compatible) otherwise, embeddings only via OpenAI-compatible endpoints. Callers never
38 + * see vendor SDKs; a missing provider surfaces as AiNotConfiguredError which the UI renders as
39 + * "AI provider not configured".
40 + */
41 +export class ModelRouter implements ModelProvider {
42 + readonly id = 'router';
43 + readonly providers: ModelProvider[] = [];
44 + private readonly overrides: Partial<Record<Role, string>>;
45 +
46 + constructor(cfg: RouterConfig) {
47 + this.overrides = cfg.overrides ?? {};
48 + const perProviderModels = (pid: string): Partial<Record<Role, string>> => {
49 + const out: Partial<Record<Role, string>> = {};
50 + for (const [role, spec] of Object.entries(this.overrides) as Array<[Role, string]>) {
51 + const [p, m] = spec.includes(':') ? (spec.split(':', 2) as [string, string]) : [null, spec];
52 + if (!p || p === pid) out[role] = m;
53 + }
54 + return out;
55 + };
56 + if (cfg.anthropicApiKey) this.providers.push(new AnthropicProvider({ apiKey: cfg.anthropicApiKey, models: perProviderModels('anthropic') }));
57 + if (cfg.openaiApiKey) this.providers.push(new OpenAICompatibleProvider({ id: 'openai', apiKey: cfg.openaiApiKey, models: perProviderModels('openai') }));
58 + if (cfg.localBaseUrl) this.providers.push(new OpenAICompatibleProvider({ id: 'local', apiKey: cfg.localApiKey ?? 'local', baseURL: cfg.localBaseUrl, models: perProviderModels('local') }));
59 + if (cfg.persistCosts) void installDbCostSink();
60 + }
61 +
62 + get configured(): boolean {
63 + return this.providers.length > 0;
64 + }
65 +
66 + /** Provider for a role: explicit "provider:" override first, else first provider supporting the role. */
67 + providerFor(role: Role): ModelProvider {
68 + const spec = this.overrides[role];
69 + if (spec?.includes(':')) {
70 + const pid = spec.split(':', 1)[0];
71 + const p = this.providers.find((x) => x.id === pid);
72 + if (p) return p;
73 + }
74 + const p = this.providers.find((x) => x.supports(role));
75 + if (!p) throw new AiNotConfiguredError(role === 'embed' ? 'No embeddings provider configured (set OPENAI_API_KEY)' : 'AI provider not configured (set ANTHROPIC_API_KEY or OPENAI_API_KEY)');
76 + return p;
77 + }
78 +
79 + supports(role: Role): boolean {
80 + return this.providers.some((p) => p.supports(role));
81 + }
82 + modelFor(role: Role): string {
83 + return this.providerFor(role).modelFor(role);
84 + }
85 + complete(role: Role, req: CompletionRequest): Promise<Completion> {
86 + return this.providerFor(role).complete(role, req);
87 + }
88 + stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> {
89 + return this.providerFor(role).stream(role, req);
90 + }
91 + runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> {
92 + return this.providerFor(role).runTools(role, req);
93 + }
94 + extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> {
95 + return this.providerFor(role).extract(role, req);
96 + }
97 + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> {
98 + return this.providerFor('vision').vision(req);
99 + }
100 + embed(req: EmbedRequest): Promise<EmbedResult> {
101 + return this.providerFor('embed').embed(req);
102 + }
103 +
104 + /** Table of role → provider/model for diagnostics and /admin. */
105 + table(): Array<{ role: Role; provider: string | null; model: string | null }> {
106 + return ROLES.map((role) => {
107 + try {
108 + const p = this.providerFor(role);
109 + return { role, provider: p.id, model: p.modelFor(role) };
110 + } catch {
111 + return { role, provider: null, model: null };
112 + }
113 + });
114 + }
115 +}
116 +
117 +let _router: ModelRouter | null = null;
118 +/** Process-wide router built from env. */
119 +export function getRouter(): ModelRouter {
120 + if (!_router) _router = new ModelRouter(configFromEnv());
121 + return _router;
122 +}
123 +export function resetRouter(): void {
124 + _router = null;
125 +}
added packages/ai/src/types.ts +150 −0
@@ -0,0 +1,150 @@
1 +import type { z } from 'zod';
2 +
3 +/**
4 + * Provider-neutral model abstraction (CLAUDE.md §168). Nothing outside packages/ai imports a
5 + * vendor SDK; callers speak in roles and these request shapes.
6 + */
7 +export type Role = 'normalize' | 'classify' | 'resolve' | 'research' | 'vision' | 'embed' | 'summarize';
8 +
9 +export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
10 +
11 +export interface CostContext {
12 + connectorId?: string | null;
13 + categorySlug?: string | null;
14 + userId?: string | null;
15 + endpoint?: string | null;
16 + metadata?: Record<string, unknown>;
17 +}
18 +
19 +export interface ImageInput {
20 + /** base64-encoded bytes (no data: prefix) or an https URL */
21 + data?: string;
22 + url?: string;
23 + mediaType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif';
24 +}
25 +
26 +export type ContentPart = { type: 'text'; text: string } | { type: 'image'; image: ImageInput };
27 +
28 +export interface ChatMessage {
29 + role: 'user' | 'assistant';
30 + content: string | ContentPart[];
31 +}
32 +
33 +export interface ToolDefinition {
34 + name: string;
35 + description: string;
36 + /** JSON Schema (draft 2020-12 subset) for the input object */
37 + inputSchema: Record<string, unknown>;
38 +}
39 +
40 +export interface CompletionRequest {
41 + system?: string;
42 + messages: ChatMessage[];
43 + maxTokens?: number;
44 + temperature?: number;
45 + effort?: Effort;
46 + /** ask for JSON output (best effort; use extract() for schema-validated output) */
47 + json?: boolean;
48 + stopSequences?: string[];
49 + cost?: CostContext;
50 +}
51 +
52 +export interface Usage {
53 + inputTokens: number;
54 + outputTokens: number;
55 + cacheReadTokens: number;
56 + cacheWriteTokens: number;
57 +}
58 +
59 +export interface Completion {
60 + text: string;
61 + model: string;
62 + provider: string;
63 + usage: Usage;
64 + usdEst: number;
65 + stopReason: string | null;
66 + refusal: { category: string | null; explanation: string | null } | null;
67 +}
68 +
69 +export type StreamDelta =
70 + | { type: 'text'; text: string }
71 + | { type: 'thinking'; text: string }
72 + | { type: 'tool_call'; id: string; name: string; input: unknown }
73 + | { type: 'tool_result'; id: string; name: string; output: unknown; isError: boolean; durationMs: number }
74 + | { type: 'usage'; usage: Usage; usdEst: number; model: string }
75 + | { type: 'error'; message: string }
76 + | { type: 'done'; stopReason: string | null };
77 +
78 +export interface ToolAgentRequest extends CompletionRequest {
79 + tools: ToolDefinition[];
80 + /** execute a tool call; return JSON-serialisable output (throw to signal an error result) */
81 + execute: (name: string, input: unknown) => Promise<unknown>;
82 + /** max model↔tool round trips (default 8) */
83 + maxIterations?: number;
84 + signal?: AbortSignal;
85 +}
86 +
87 +export interface ExtractRequest<T> {
88 + schema: z.ZodType<T>;
89 + /** instruction for the extraction */
90 + prompt: string;
91 + /** raw input: text or content parts (images allowed) */
92 + input: string | ContentPart[];
93 + system?: string;
94 + maxTokens?: number;
95 + effort?: Effort;
96 + cost?: CostContext;
97 +}
98 +
99 +export interface ExtractResult<T> {
100 + data: T;
101 + /** 0–1: the model's self-reported confidence when the schema has one, else 1 when parsed */
102 + confidence: number;
103 + model: string;
104 + provider: string;
105 + usage: Usage;
106 + usdEst: number;
107 +}
108 +
109 +export interface EmbedRequest {
110 + texts: string[];
111 + cost?: CostContext;
112 +}
113 +
114 +export interface EmbedResult {
115 + vectors: number[][];
116 + model: string;
117 + provider: string;
118 + dimensions: number;
119 + usdEst: number;
120 +}
121 +
122 +export interface ModelProvider {
123 + readonly id: string;
124 + /** which roles this provider can serve given its configuration */
125 + supports(role: Role): boolean;
126 + complete(role: Role, req: CompletionRequest): Promise<Completion>;
127 + stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta>;
128 + /** tool-use agent loop with streaming events */
129 + runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta>;
130 + extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>>;
131 + vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>>;
132 + embed(req: EmbedRequest): Promise<EmbedResult>;
133 + modelFor(role: Role): string;
134 +}
135 +
136 +export class AiNotConfiguredError extends Error {
137 + readonly code = 'ai_not_configured';
138 + constructor(detail = 'AI provider not configured') {
139 + super(detail);
140 + this.name = 'AiNotConfiguredError';
141 + }
142 +}
143 +
144 +export class AiRefusalError extends Error {
145 + readonly code = 'ai_refusal';
146 + constructor(public readonly category: string | null, explanation: string | null) {
147 + super(explanation ?? 'The model declined this request');
148 + this.name = 'AiRefusalError';
149 + }
150 +}
added packages/ai/tsconfig.json +9 −0
@@ -0,0 +1,9 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": {
4 + "rootDir": "src",
5 + "outDir": "dist",
6 + "noEmit": true
7 + },
8 + "include": ["src"]
9 +}
added packages/ai/vitest.config.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { defineConfig } from 'vitest/config';
2 +
3 +export default defineConfig({
4 + test: { include: ['src/**/*.test.ts'] },
5 +});
added packages/database/src/schema/ai.ts +82 −0
@@ -0,0 +1,82 @@
1 +import { pgTable, text, integer, real, index, jsonb, boolean } from 'drizzle-orm/pg-core';
2 +import { createdAt, ts, jsonObject } from './_common.js';
3 +
4 +/**
5 + * Scanner sessions (§114): what the user submitted, what the model guessed, which candidates we
6 + * showed and what the user picked. The pick is the feedback loop for identification quality.
7 + */
8 +export const scannerSessions = pgTable(
9 + 'scanner_sessions',
10 + {
11 + id: text('id').primaryKey(),
12 + userId: text('user_id'),
13 + anonId: text('anon_id'),
14 + ipHash: text('ip_hash'),
15 + mode: text('mode').notNull(), // photo | url | text
16 + inputUrl: text('input_url'),
17 + inputText: text('input_text'),
18 + imageCount: integer('image_count').notNull().default(0),
19 + /** stored thumbnails (data URLs, ≤ 3 × ~40 KB) for review; never the originals */
20 + thumbnails: jsonb('thumbnails').$type<string[]>().notNull().default([]),
21 + guess: jsonObject<Record<string, unknown>>('guess'),
22 + guessConfidence: real('guess_confidence'),
23 + candidates: jsonb('candidates').$type<Array<{ assetId: string; slug: string; title: string; score: number }>>().notNull().default([]),
24 + chosenAssetId: text('chosen_asset_id'),
25 + /** matched listing (url mode) */
26 + listing: jsonObject<Record<string, unknown>>('listing'),
27 + model: text('model'),
28 + usdEst: real('usd_est').notNull().default(0),
29 + durationMs: integer('duration_ms'),
30 + error: text('error'),
31 + createdAt: createdAt(),
32 + chosenAt: ts('chosen_at'),
33 + },
34 + (t) => [index('scanner_sessions_created_idx').on(t.createdAt), index('scanner_sessions_ip_idx').on(t.ipHash, t.createdAt), index('scanner_sessions_user_idx').on(t.userId)],
35 +);
36 +
37 +/** AI Research conversations (§128). One session per anonymous cookie/user thread. */
38 +export const researchSessions = pgTable(
39 + 'research_sessions',
40 + {
41 + id: text('id').primaryKey(),
42 + userId: text('user_id'),
43 + anonId: text('anon_id'),
44 + title: text('title'),
45 + model: text('model'),
46 + messageCount: integer('message_count').notNull().default(0),
47 + usdEst: real('usd_est').notNull().default(0),
48 + archived: boolean('archived').notNull().default(false),
49 + createdAt: createdAt(),
50 + updatedAt: ts('updated_at').notNull().defaultNow(),
51 + },
52 + (t) => [index('research_sessions_anon_idx').on(t.anonId, t.updatedAt), index('research_sessions_user_idx').on(t.userId, t.updatedAt)],
53 +);
54 +
55 +export const researchMessages = pgTable(
56 + 'research_messages',
57 + {
58 + id: text('id').primaryKey(),
59 + sessionId: text('session_id').notNull(),
60 + role: text('role').notNull(), // user | assistant
61 + content: text('content').notNull(),
62 + /** transparent tool trace: [{name, input, ok, ms, rows}] */
63 + toolCalls: jsonb('tool_calls').$type<Array<{ name: string; input: unknown; ok: boolean; ms: number; summary?: string }>>().notNull().default([]),
64 + usage: jsonObject<Record<string, number>>('usage'),
65 + usdEst: real('usd_est').notNull().default(0),
66 + model: text('model'),
67 + createdAt: createdAt(),
68 + },
69 + (t) => [index('research_messages_session_idx').on(t.sessionId, t.createdAt)],
70 +);
71 +
72 +/** Per-key daily quota for anonymous AI features (scanner/research) keyed by ip hash or user. */
73 +export const aiQuotas = pgTable(
74 + 'ai_quotas',
75 + {
76 + key: text('key').notNull(),
77 + feature: text('feature').notNull(),
78 + date: text('date').notNull(),
79 + count: integer('count').notNull().default(0),
80 + },
81 + (t) => [index('ai_quotas_uq').on(t.key, t.feature, t.date)],
82 +);
modified packages/database/src/schema/index.ts +1 −0
@@ -5,3 +5,4 @@ export * from './assets.js';
5 5 export * from './market.js';
6 6 export * from './analytics.js';
7 7 export * from './users.js';
8 +export * from './ai.js';
modified pnpm-lock.yaml +677 −2
@@ -23,13 +23,46 @@ importers:
23 23
24 24 apps/api:
25 25 dependencies:
26 + '@fastify/compress':
27 + specifier: ^9.0.0
28 + version: 9.2.0
29 + '@fastify/cors':
30 + specifier: ^11.0.0
31 + version: 11.3.0
32 + '@fastify/etag':
33 + specifier: ^6.0.0
34 + version: 6.2.0
35 + '@fastify/rate-limit':
36 + specifier: ^11.0.0
37 + version: 11.2.0
38 + '@rareindex/connectors':
39 + specifier: workspace:*
40 + version: link:../../packages/connectors
41 + '@rareindex/database':
42 + specifier: workspace:*
43 + version: link:../../packages/database
26 44 '@rareindex/shared':
27 45 specifier: workspace:*
28 46 version: link:../../packages/shared
47 + '@rareindex/taxonomy':
48 + specifier: workspace:*
49 + version: link:../../packages/taxonomy
50 + fastify:
51 + specifier: ^5.6.0
52 + version: 5.12.3
53 + fastify-plugin:
54 + specifier: ^6.0.0
55 + version: 6.0.0
56 + zod:
57 + specifier: ^4.0.0
58 + version: 4.5.4
29 59 devDependencies:
30 60 '@types/node':
31 61 specifier: ^24.0.0
32 62 version: 24.13.3
63 + tsx:
64 + specifier: ^4.20.0
65 + version: 4.23.13
33 66 typescript:
34 67 specifier: ^5.9.3
35 68 version: 5.9.3
@@ -39,6 +72,9 @@ importers:
39 72
40 73 apps/web:
41 74 dependencies:
75 + '@rareindex/ai':
76 + specifier: workspace:*
77 + version: link:../../packages/ai
42 78 '@rareindex/connectors':
43 79 specifier: workspace:*
44 80 version: link:../../packages/connectors
@@ -60,6 +96,9 @@ importers:
60 96 next:
61 97 specifier: 16.3.4
62 98 version: 16.3.4(@babel/core@7.29.7)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
99 + pg-boss:
100 + specifier: ^12.30.0
101 + version: 12.30.0
63 102 react:
64 103 specifier: 19.2.8
65 104 version: 19.2.8
@@ -126,6 +165,37 @@ importers:
126 165 specifier: ^3.2.0
127 166 version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
128 167
168 + packages/ai:
169 + dependencies:
170 + '@anthropic-ai/sdk':
171 + specifier: ^0.124.0
172 + version: 0.124.0(zod@4.5.4)
173 + '@rareindex/database':
174 + specifier: workspace:*
175 + version: link:../database
176 + '@rareindex/shared':
177 + specifier: workspace:*
178 + version: link:../shared
179 + '@rareindex/taxonomy':
180 + specifier: workspace:*
181 + version: link:../taxonomy
182 + openai:
183 + specifier: ^7.10.0
184 + version: 7.10.0(undici@7.29.1)(zod@4.5.4)
185 + zod:
186 + specifier: ^4.0.0
187 + version: 4.5.4
188 + devDependencies:
189 + '@types/node':
190 + specifier: ^24.0.0
191 + version: 24.13.3
192 + typescript:
193 + specifier: ^5.9.3
194 + version: 5.9.3
195 + vitest:
196 + specifier: ^3.2.0
197 + version: 3.2.7(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.13)
198 +
129 199 packages/connectors:
130 200 dependencies:
131 201 '@rareindex/shared':
@@ -161,7 +231,7 @@ importers:
161 231 version: link:../taxonomy
162 232 drizzle-orm:
163 233 specifier: ^0.45.0
164 version: 0.45.2(postgres@3.4.9)
234 + version: 0.45.2(pg@8.23.0)(postgres@3.4.9)
165 235 postgres:
166 236 specifier: ^3.4.7
167 237 version: 3.4.9
@@ -242,6 +312,15 @@ packages:
242 312 resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
243 313 engines: {node: '>=10'}
244 314
315 + '@anthropic-ai/sdk@0.124.0':
316 + resolution: {integrity: sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==}
317 + hasBin: true
318 + peerDependencies:
319 + zod: ^3.25.0 || ^4.0.0
320 + peerDependenciesMeta:
321 + zod:
322 + optional: true
323 +
245 324 '@babel/code-frame@7.29.7':
246 325 resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
247 326 engines: {node: '>=6.9.0'}
@@ -297,6 +376,10 @@ packages:
297 376 engines: {node: '>=6.0.0'}
298 377 hasBin: true
299 378
379 + '@babel/runtime@7.29.7':
380 + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
381 + engines: {node: '>=6.9.0'}
382 +
300 383 '@babel/template@7.29.7':
301 384 resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
302 385 engines: {node: '>=6.9.0'}
@@ -820,6 +903,39 @@ packages:
820 903 resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
821 904 engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
822 905
906 + '@fastify/accept-negotiator@2.1.0':
907 + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==}
908 +
909 + '@fastify/ajv-compiler@4.0.6':
910 + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
911 +
912 + '@fastify/compress@9.2.0':
913 + resolution: {integrity: sha512-35VL33PIEt/UbD/ZMlRNaICd2BQz0IaG7en74Dv7tI+MHIQkedibS1+9JfekWH4I1g+Y8RbbZD4Zsk9h4XTItg==}
914 +
915 + '@fastify/cors@11.3.0':
916 + resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
917 +
918 + '@fastify/error@4.2.0':
919 + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
920 +
921 + '@fastify/etag@6.2.0':
922 + resolution: {integrity: sha512-ptNM6UYBY+DFbZ4UPCSlGN0YRRIBy1ZAoo/cPLu8m7xJ58XpfTYLvS5bpS5KFws7MEcCqJJuAMu+rvrRCISfgw==}
923 +
924 + '@fastify/fast-json-stringify-compiler@5.1.0':
925 + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==}
926 +
927 + '@fastify/forwarded@3.0.2':
928 + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==}
929 +
930 + '@fastify/merge-json-schemas@0.2.1':
931 + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
932 +
933 + '@fastify/proxy-addr@5.1.0':
934 + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
935 +
936 + '@fastify/rate-limit@11.2.0':
937 + resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==}
938 +
823 939 '@humanfs/core@0.19.2':
824 940 resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
825 941 engines: {node: '>=18.18.0'}
@@ -1018,6 +1134,10 @@ packages:
1018 1134 '@jridgewell/trace-mapping@0.3.31':
1019 1135 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
1020 1136
1137 + '@lukeed/ms@2.0.2':
1138 + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
1139 + engines: {node: '>=8'}
1140 +
1021 1141 '@napi-rs/lzma-linux-x64-gnu@1.5.1':
1022 1142 resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
1023 1143 engines: {node: ^22.20 || ^24.12 || >=25}
@@ -1250,6 +1370,9 @@ packages:
1250 1370 '@rtsao/scc@1.1.0':
1251 1371 resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
1252 1372
1373 + '@stablelib/base64@1.0.1':
1374 + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
1375 +
1253 1376 '@swc/helpers@0.5.23':
1254 1377 resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
1255 1378
@@ -1590,6 +1713,13 @@ packages:
1590 1713 '@vitest/utils@3.2.7':
1591 1714 resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
1592 1715
1716 + abort-controller@3.0.0:
1717 + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
1718 + engines: {node: '>=6.5'}
1719 +
1720 + abstract-logging@2.0.1:
1721 + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
1722 +
1593 1723 acorn-jsx@5.3.2:
1594 1724 resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
1595 1725 peerDependencies:
@@ -1600,9 +1730,20 @@ packages:
1600 1730 engines: {node: '>=0.4.0'}
1601 1731 hasBin: true
1602 1732
1733 + ajv-formats@3.0.1:
1734 + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
1735 + peerDependencies:
1736 + ajv: ^8.0.0
1737 + peerDependenciesMeta:
1738 + ajv:
1739 + optional: true
1740 +
1603 1741 ajv@6.15.0:
1604 1742 resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
1605 1743
1744 + ajv@8.20.0:
1745 + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
1746 +
1606 1747 ansi-styles@4.3.0:
1607 1748 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
1608 1749 engines: {node: '>=8'}
@@ -1665,6 +1806,9 @@ packages:
1665 1806 resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
1666 1807 engines: {node: '>= 0.4'}
1667 1808
1809 + avvio@9.3.0:
1810 + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
1811 +
1668 1812 axe-core@4.13.0:
1669 1813 resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
1670 1814 engines: {node: '>=4'}
@@ -1680,6 +1824,9 @@ packages:
1680 1824 resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
1681 1825 engines: {node: 18 || 20 || >=22}
1682 1826
1827 + base64-js@1.5.1:
1828 + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
1829 +
1683 1830 baseline-browser-mapping@2.11.21:
1684 1831 resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
1685 1832 engines: {node: '>=6.0.0'}
@@ -1707,6 +1854,9 @@ packages:
1707 1854 buffer-from@1.1.2:
1708 1855 resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
1709 1856
1857 + buffer@6.0.3:
1858 + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
1859 +
1710 1860 cac@6.7.14:
1711 1861 resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
1712 1862 engines: {node: '>=8'}
@@ -1765,6 +1915,14 @@ packages:
1765 1915 convert-source-map@2.0.0:
1766 1916 resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
1767 1917
1918 + cookie@1.1.1:
1919 + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
1920 + engines: {node: '>=18'}
1921 +
1922 + cron-parser@5.10.0:
1923 + resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
1924 + engines: {node: '>=18'}
1925 +
1768 1926 cross-spawn@7.0.6:
1769 1927 resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
1770 1928 engines: {node: '>= 8'}
@@ -1826,6 +1984,10 @@ packages:
1826 1984 resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
1827 1985 engines: {node: '>= 0.4'}
1828 1986
1987 + dequal@2.0.3:
1988 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
1989 + engines: {node: '>=6'}
1990 +
1829 1991 detect-libc@2.1.2:
1830 1992 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1831 1993 engines: {node: '>=8'}
@@ -2158,10 +2320,21 @@ packages:
2158 2320 resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
2159 2321 engines: {node: '>=0.10.0'}
2160 2322
2323 + event-target-shim@5.0.1:
2324 + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
2325 + engines: {node: '>=6'}
2326 +
2327 + events@3.3.0:
2328 + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
2329 + engines: {node: '>=0.8.x'}
2330 +
2161 2331 expect-type@1.4.0:
2162 2332 resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
2163 2333 engines: {node: '>=12.0.0'}
2164 2334
2335 + fast-decode-uri-component@1.0.1:
2336 + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
2337 +
2165 2338 fast-deep-equal@3.1.3:
2166 2339 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
2167 2340
@@ -2172,9 +2345,30 @@ packages:
2172 2345 fast-json-stable-stringify@2.1.0:
2173 2346 resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
2174 2347
2348 + fast-json-stringify@7.0.1:
2349 + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==}
2350 +
2175 2351 fast-levenshtein@2.0.6:
2176 2352 resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
2177 2353
2354 + fast-querystring@1.1.2:
2355 + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
2356 +
2357 + fast-sha256@1.3.0:
2358 + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
2359 +
2360 + fast-uri@3.1.7:
2361 + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==}
2362 +
2363 + fast-uri@4.1.4:
2364 + resolution: {integrity: sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==}
2365 +
2366 + fastify-plugin@6.0.0:
2367 + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==}
2368 +
2369 + fastify@5.12.3:
2370 + resolution: {integrity: sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==}
2371 +
2178 2372 fastq@1.20.3:
2179 2373 resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
2180 2374
@@ -2195,6 +2389,10 @@ packages:
2195 2389 resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
2196 2390 engines: {node: '>=8'}
2197 2391
2392 + find-my-way@9.9.0:
2393 + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==}
2394 + engines: {node: '>=20'}
2395 +
2198 2396 find-up@5.0.0:
2199 2397 resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
2200 2398 engines: {node: '>=10'}
@@ -2315,6 +2513,9 @@ packages:
2315 2513 resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
2316 2514 engines: {node: '>=0.10.0'}
2317 2515
2516 + ieee754@1.2.1:
2517 + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
2518 +
2318 2519 ignore@5.3.2:
2319 2520 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
2320 2521 engines: {node: '>= 4'}
@@ -2335,6 +2536,14 @@ packages:
2335 2536 resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
2336 2537 engines: {node: '>= 0.4'}
2337 2538
2539 + ip-address@10.7.0:
2540 + resolution: {integrity: sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==}
2541 + engines: {node: '>= 12'}
2542 +
2543 + ipaddr.js@2.5.0:
2544 + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
2545 + engines: {node: '>= 10'}
2546 +
2338 2547 is-array-buffer@3.0.5:
2339 2548 resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
2340 2549 engines: {node: '>= 0.4'}
@@ -2474,9 +2683,19 @@ packages:
2474 2683 json-buffer@3.0.1:
2475 2684 resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
2476 2685
2686 + json-schema-ref-resolver@3.0.0:
2687 + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
2688 +
2689 + json-schema-to-ts@3.1.1:
2690 + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
2691 + engines: {node: '>=16'}
2692 +
2477 2693 json-schema-traverse@0.4.1:
2478 2694 resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
2479 2695
2696 + json-schema-traverse@1.0.0:
2697 + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
2698 +
2480 2699 json-stable-stringify-without-jsonify@1.0.1:
2481 2700 resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
2482 2701
@@ -2507,6 +2726,9 @@ packages:
2507 2726 resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
2508 2727 engines: {node: '>= 0.8.0'}
2509 2728
2729 + light-my-request@6.6.0:
2730 + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
2731 +
2510 2732 lightningcss-android-arm64@1.32.0:
2511 2733 resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
2512 2734 engines: {node: '>= 12.0.0'}
@@ -2603,6 +2825,10 @@ packages:
2603 2825 peerDependencies:
2604 2826 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
2605 2827
2828 + luxon@3.7.2:
2829 + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
2830 + engines: {node: '>=12'}
2831 +
2606 2832 magic-string@0.30.21:
2607 2833 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
2608 2834
@@ -2618,6 +2844,10 @@ packages:
2618 2844 resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
2619 2845 engines: {node: '>=8.6'}
2620 2846
2847 + mime-db@1.54.0:
2848 + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
2849 + engines: {node: '>= 0.6'}
2850 +
2621 2851 minimatch@10.2.6:
2622 2852 resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
2623 2853 engines: {node: 18 || 20 || >=22}
@@ -2628,6 +2858,10 @@ packages:
2628 2858 minimist@1.2.8:
2629 2859 resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
2630 2860
2861 + minipass@7.1.3:
2862 + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
2863 + engines: {node: '>=16 || 14 >=14.17'}
2864 +
2631 2865 ms@2.1.3:
2632 2866 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
2633 2867
@@ -2673,6 +2907,10 @@ packages:
2673 2907 resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
2674 2908 engines: {node: '>=18'}
2675 2909
2910 + non-error@0.1.0:
2911 + resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
2912 + engines: {node: '>=20'}
2913 +
2676 2914 nth-check@2.1.1:
2677 2915 resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
2678 2916
@@ -2712,6 +2950,30 @@ packages:
2712 2950 resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
2713 2951 engines: {node: '>=14.0.0'}
2714 2952
2953 + openai@7.10.0:
2954 + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==}
2955 + engines: {node: '>=22.0.0'}
2956 + peerDependencies:
2957 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
2958 + '@smithy/hash-node': '>=4.3.0 <5'
2959 + '@smithy/signature-v4': '>=5.4.0 <6'
2960 + undici: '>=5 <9'
2961 + ws: ^8.21.0
2962 + zod: ^3.25 || ^4.0
2963 + peerDependenciesMeta:
2964 + '@aws-sdk/credential-provider-node':
2965 + optional: true
2966 + '@smithy/hash-node':
2967 + optional: true
2968 + '@smithy/signature-v4':
2969 + optional: true
2970 + undici:
2971 + optional: true
2972 + ws:
2973 + optional: true
2974 + zod:
2975 + optional: true
2976 +
2715 2977 optionator@0.9.4:
2716 2978 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
2717 2979 engines: {node: '>= 0.8.0'}
@@ -2759,6 +3021,45 @@ packages:
2759 3021 resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
2760 3022 engines: {node: '>= 14.16'}
2761 3023
3024 + pg-boss@12.30.0:
3025 + resolution: {integrity: sha512-ke4Rr/uMJGwqt7DwKoWS9aa6XFlK43C0fMw1Ma0CoJrVxWSjE7ZwFLwz60dGQODESDA2+yq/B474pOH4n3d3CQ==}
3026 + engines: {node: '>=22.12.0'}
3027 + hasBin: true
3028 +
3029 + pg-cloudflare@1.4.0:
3030 + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
3031 +
3032 + pg-connection-string@2.14.0:
3033 + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
3034 +
3035 + pg-int8@1.0.1:
3036 + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
3037 + engines: {node: '>=4.0.0'}
3038 +
3039 + pg-pool@3.14.0:
3040 + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
3041 + peerDependencies:
3042 + pg: '>=8.0'
3043 +
3044 + pg-protocol@1.16.0:
3045 + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
3046 +
3047 + pg-types@2.2.0:
3048 + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
3049 + engines: {node: '>=4'}
3050 +
3051 + pg@8.23.0:
3052 + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
3053 + engines: {node: '>= 16.0.0'}
3054 + peerDependencies:
3055 + pg-native: '>=3.0.1'
3056 + peerDependenciesMeta:
3057 + pg-native:
3058 + optional: true
3059 +
3060 + pgpass@1.0.5:
3061 + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
3062 +
2762 3063 picocolors@1.1.1:
2763 3064 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
2764 3065
@@ -2792,6 +3093,22 @@ packages:
2792 3093 resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
2793 3094 engines: {node: ^10 || ^12 || >=14}
2794 3095
3096 + postgres-array@2.0.0:
3097 + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
3098 + engines: {node: '>=4'}
3099 +
3100 + postgres-bytea@1.0.1:
3101 + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
3102 + engines: {node: '>=0.10.0'}
3103 +
3104 + postgres-date@1.0.7:
3105 + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
3106 + engines: {node: '>=0.10.0'}
3107 +
3108 + postgres-interval@1.2.0:
3109 + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
3110 + engines: {node: '>=0.10.0'}
3111 +
2795 3112 postgres@3.4.9:
2796 3113 resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==}
2797 3114 engines: {node: '>=12'}
@@ -2800,9 +3117,16 @@ packages:
2800 3117 resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
2801 3118 engines: {node: '>= 0.8.0'}
2802 3119
3120 + process-warning@4.0.1:
3121 + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
3122 +
2803 3123 process-warning@5.1.0:
2804 3124 resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
2805 3125
3126 + process@0.11.10:
3127 + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
3128 + engines: {node: '>= 0.6.0'}
3129 +
2806 3130 prop-types@15.8.1:
2807 3131 resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
2808 3132
@@ -2828,6 +3152,10 @@ packages:
2828 3152 resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
2829 3153 engines: {node: '>=0.10.0'}
2830 3154
3155 + readable-stream@4.7.0:
3156 + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
3157 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
3158 +
2831 3159 real-require@0.2.0:
2832 3160 resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
2833 3161 engines: {node: '>= 12.13.0'}
@@ -2840,6 +3168,10 @@ packages:
2840 3168 resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
2841 3169 engines: {node: '>= 0.4'}
2842 3170
3171 + require-from-string@2.0.2:
3172 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
3173 + engines: {node: '>=0.10.0'}
3174 +
2843 3175 resolve-from@4.0.0:
2844 3176 resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2845 3177 engines: {node: '>=4'}
@@ -2852,10 +3184,17 @@ packages:
2852 3184 engines: {node: '>= 0.4'}
2853 3185 hasBin: true
2854 3186
3187 + ret@0.5.0:
3188 + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
3189 + engines: {node: '>=10'}
3190 +
2855 3191 reusify@1.1.0:
2856 3192 resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
2857 3193 engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
2858 3194
3195 + rfdc@1.4.1:
3196 + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
3197 +
2859 3198 rollup@4.63.1:
2860 3199 resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
2861 3200 engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -2868,6 +3207,9 @@ packages:
2868 3207 resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
2869 3208 engines: {node: '>=0.4'}
2870 3209
3210 + safe-buffer@5.2.1:
3211 + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
3212 +
2871 3213 safe-push-apply@1.0.0:
2872 3214 resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
2873 3215 engines: {node: '>= 0.4'}
@@ -2876,6 +3218,10 @@ packages:
2876 3218 resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
2877 3219 engines: {node: '>= 0.4'}
2878 3220
3221 + safe-regex2@5.1.1:
3222 + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
3223 + hasBin: true
3224 +
2879 3225 safe-stable-stringify@2.5.0:
2880 3226 resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
2881 3227 engines: {node: '>=10'}
@@ -2886,6 +3232,9 @@ packages:
2886 3232 scheduler@0.27.0:
2887 3233 resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
2888 3234
3235 + secure-json-parse@4.1.0:
3236 + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
3237 +
2889 3238 semver@6.3.1:
2890 3239 resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
2891 3240 hasBin: true
@@ -2895,9 +3244,16 @@ packages:
2895 3244 engines: {node: '>=10'}
2896 3245 hasBin: true
2897 3246
3247 + serialize-error@13.0.1:
3248 + resolution: {integrity: sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==}
3249 + engines: {node: '>=20'}
3250 +
2898 3251 server-only@0.0.1:
2899 3252 resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
2900 3253
3254 + set-cookie-parser@2.7.2:
3255 + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
3256 +
2901 3257 set-function-length@1.2.2:
2902 3258 resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
2903 3259 engines: {node: '>= 0.4'}
@@ -2970,6 +3326,9 @@ packages:
2970 3326 stackback@0.0.2:
2971 3327 resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
2972 3328
3329 + standardwebhooks@1.1.1:
3330 + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
3331 +
2973 3332 std-env@3.10.0:
2974 3333 resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
2975 3334
@@ -3000,6 +3359,9 @@ packages:
3000 3359 resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
3001 3360 engines: {node: '>= 0.4'}
3002 3361
3362 + string_decoder@1.3.0:
3363 + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
3364 +
3003 3365 strip-bom@3.0.0:
3004 3366 resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
3005 3367 engines: {node: '>=4'}
@@ -3032,6 +3394,10 @@ packages:
3032 3394 resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
3033 3395 engines: {node: '>= 0.4'}
3034 3396
3397 + tagged-tag@1.0.0:
3398 + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
3399 + engines: {node: '>=20'}
3400 +
3035 3401 tailwindcss@4.3.3:
3036 3402 resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
3037 3403
@@ -3068,6 +3434,13 @@ packages:
3068 3434 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
3069 3435 engines: {node: '>=8.0'}
3070 3436
3437 + toad-cache@3.7.4:
3438 + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==}
3439 + engines: {node: '>=20'}
3440 +
3441 + ts-algebra@2.0.0:
3442 + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
3443 +
3071 3444 ts-api-utils@2.5.0:
3072 3445 resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
3073 3446 engines: {node: '>=18.12'}
@@ -3089,6 +3462,10 @@ packages:
3089 3462 resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
3090 3463 engines: {node: '>= 0.8.0'}
3091 3464
3465 + type-fest@5.9.0:
3466 + resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==}
3467 + engines: {node: '>=20'}
3468 +
3092 3469 typed-array-buffer@1.0.3:
3093 3470 resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
3094 3471 engines: {node: '>= 0.4'}
@@ -3252,6 +3629,10 @@ packages:
3252 3629 resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
3253 3630 engines: {node: '>=0.10.0'}
3254 3631
3632 + xtend@4.0.2:
3633 + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
3634 + engines: {node: '>=0.4'}
3635 +
3255 3636 yallist@3.1.1:
3256 3637 resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
3257 3638
@@ -3272,6 +3653,13 @@ snapshots:
3272 3653
3273 3654 '@alloc/quick-lru@5.3.0': {}
3274 3655
3656 + '@anthropic-ai/sdk@0.124.0(zod@4.5.4)':
3657 + dependencies:
3658 + json-schema-to-ts: 3.1.1
3659 + standardwebhooks: 1.1.1
3660 + optionalDependencies:
3661 + zod: 4.5.4
3662 +
3275 3663 '@babel/code-frame@7.29.7':
3276 3664 dependencies:
3277 3665 '@babel/helper-validator-identifier': 7.29.7
@@ -3349,6 +3737,8 @@ snapshots:
3349 3737 dependencies:
3350 3738 '@babel/types': 7.29.8
3351 3739
3740 + '@babel/runtime@7.29.7': {}
3741 +
3352 3742 '@babel/template@7.29.7':
3353 3743 dependencies:
3354 3744 '@babel/code-frame': 7.29.7
@@ -3678,6 +4068,55 @@ snapshots:
3678 4068 '@eslint/core': 0.17.0
3679 4069 levn: 0.4.1
3680 4070
4071 + '@fastify/accept-negotiator@2.1.0': {}
4072 +
4073 + '@fastify/ajv-compiler@4.0.6':
4074 + dependencies:
4075 + ajv: 8.20.0
4076 + ajv-formats: 3.0.1(ajv@8.20.0)
4077 + fast-uri: 4.1.4
4078 +
4079 + '@fastify/compress@9.2.0':
4080 + dependencies:
4081 + '@fastify/accept-negotiator': 2.1.0
4082 + fastify-plugin: 6.0.0
4083 + mime-db: 1.54.0
4084 + minipass: 7.1.3
4085 + readable-stream: 4.7.0
4086 +
4087 + '@fastify/cors@11.3.0':
4088 + dependencies:
4089 + fastify-plugin: 6.0.0
4090 + toad-cache: 3.7.4
4091 +
4092 + '@fastify/error@4.2.0': {}
4093 +
4094 + '@fastify/etag@6.2.0':
4095 + dependencies:
4096 + fastify-plugin: 6.0.0
4097 +
4098 + '@fastify/fast-json-stringify-compiler@5.1.0':
4099 + dependencies:
4100 + fast-json-stringify: 7.0.1
4101 +
4102 + '@fastify/forwarded@3.0.2': {}
4103 +
4104 + '@fastify/merge-json-schemas@0.2.1':
4105 + dependencies:
4106 + dequal: 2.0.3
4107 +
4108 + '@fastify/proxy-addr@5.1.0':
4109 + dependencies:
4110 + '@fastify/forwarded': 3.0.2
4111 + ipaddr.js: 2.5.0
4112 +
4113 + '@fastify/rate-limit@11.2.0':
4114 + dependencies:
4115 + '@lukeed/ms': 2.0.2
4116 + fastify-plugin: 6.0.0
4117 + ip-address: 10.7.0
4118 + toad-cache: 3.7.4
4119 +
3681 4120 '@humanfs/core@0.19.2':
3682 4121 dependencies:
3683 4122 '@humanfs/types': 0.15.0
@@ -3820,6 +4259,8 @@ snapshots:
3820 4259 '@jridgewell/resolve-uri': 3.1.2
3821 4260 '@jridgewell/sourcemap-codec': 1.6.0
3822 4261
4262 + '@lukeed/ms@2.0.2': {}
4263 +
3823 4264 '@napi-rs/lzma-linux-x64-gnu@1.5.1':
3824 4265 optional: true
3825 4266
@@ -3956,6 +4397,8 @@ snapshots:
3956 4397
3957 4398 '@rtsao/scc@1.1.0': {}
3958 4399
4400 + '@stablelib/base64@1.0.1': {}
4401 +
3959 4402 '@swc/helpers@0.5.23':
3960 4403 dependencies:
3961 4404 tslib: 2.8.1
@@ -4269,12 +4712,22 @@ snapshots:
4269 4712 loupe: 3.2.1
4270 4713 tinyrainbow: 2.0.0
4271 4714
4715 + abort-controller@3.0.0:
4716 + dependencies:
4717 + event-target-shim: 5.0.1
4718 +
4719 + abstract-logging@2.0.1: {}
4720 +
4272 4721 acorn-jsx@5.3.2(acorn@8.18.0):
4273 4722 dependencies:
4274 4723 acorn: 8.18.0
4275 4724
4276 4725 acorn@8.18.0: {}
4277 4726
4727 + ajv-formats@3.0.1(ajv@8.20.0):
4728 + optionalDependencies:
4729 + ajv: 8.20.0
4730 +
4278 4731 ajv@6.15.0:
4279 4732 dependencies:
4280 4733 fast-deep-equal: 3.1.3
@@ -4282,6 +4735,13 @@ snapshots:
4282 4735 json-schema-traverse: 0.4.1
4283 4736 uri-js: 4.4.1
4284 4737
4738 + ajv@8.20.0:
4739 + dependencies:
4740 + fast-deep-equal: 3.1.3
4741 + fast-uri: 3.1.7
4742 + json-schema-traverse: 1.0.0
4743 + require-from-string: 2.0.2
4744 +
4285 4745 ansi-styles@4.3.0:
4286 4746 dependencies:
4287 4747 color-convert: 2.0.1
@@ -4369,6 +4829,11 @@ snapshots:
4369 4829 dependencies:
4370 4830 possible-typed-array-names: 1.1.0
4371 4831
4832 + avvio@9.3.0:
4833 + dependencies:
4834 + '@fastify/error': 4.2.0
4835 + fastq: 1.20.3
4836 +
4372 4837 axe-core@4.13.0: {}
4373 4838
4374 4839 axobject-query@4.1.0: {}
@@ -4377,6 +4842,8 @@ snapshots:
4377 4842
4378 4843 balanced-match@4.0.4: {}
4379 4844
4845 + base64-js@1.5.1: {}
4846 +
4380 4847 baseline-browser-mapping@2.11.21: {}
4381 4848
4382 4849 boolbase@1.0.0: {}
@@ -4404,6 +4871,11 @@ snapshots:
4404 4871
4405 4872 buffer-from@1.1.2: {}
4406 4873
4874 + buffer@6.0.3:
4875 + dependencies:
4876 + base64-js: 1.5.1
4877 + ieee754: 1.2.1
4878 +
4407 4879 cac@6.7.14: {}
4408 4880
4409 4881 call-bind-apply-helpers@1.0.2:
@@ -4477,6 +4949,12 @@ snapshots:
4477 4949
4478 4950 convert-source-map@2.0.0: {}
4479 4951
4952 + cookie@1.1.1: {}
4953 +
4954 + cron-parser@5.10.0:
4955 + dependencies:
4956 + luxon: 3.7.2
4957 +
4480 4958 cross-spawn@7.0.6:
4481 4959 dependencies:
4482 4960 path-key: 3.1.1
@@ -4539,6 +5017,8 @@ snapshots:
4539 5017 has-property-descriptors: 1.0.2
4540 5018 object-keys: 1.1.1
4541 5019
5020 + dequal@2.0.3: {}
5021 +
4542 5022 detect-libc@2.1.2: {}
4543 5023
4544 5024 doctrine@2.1.0:
@@ -4570,8 +5050,9 @@ snapshots:
4570 5050 esbuild: 0.25.12
4571 5051 tsx: 4.23.13
4572 5052
4573 drizzle-orm@0.45.2(postgres@3.4.9):
5053 + drizzle-orm@0.45.2(pg@8.23.0)(postgres@3.4.9):
4574 5054 optionalDependencies:
5055 + pg: 8.23.0
4575 5056 postgres: 3.4.9
4576 5057
4577 5058 dunder-proto@1.0.1:
@@ -5009,8 +5490,14 @@ snapshots:
5009 5490
5010 5491 esutils@2.0.3: {}
5011 5492
5493 + event-target-shim@5.0.1: {}
5494 +
5495 + events@3.3.0: {}
5496 +
5012 5497 expect-type@1.4.0: {}
5013 5498
5499 + fast-decode-uri-component@1.0.1: {}
5500 +
5014 5501 fast-deep-equal@3.1.3: {}
5015 5502
5016 5503 fast-glob@3.3.1:
@@ -5023,8 +5510,47 @@ snapshots:
5023 5510
5024 5511 fast-json-stable-stringify@2.1.0: {}
5025 5512
5513 + fast-json-stringify@7.0.1:
5514 + dependencies:
5515 + '@fastify/merge-json-schemas': 0.2.1
5516 + ajv: 8.20.0
5517 + ajv-formats: 3.0.1(ajv@8.20.0)
5518 + fast-uri: 4.1.4
5519 + json-schema-ref-resolver: 3.0.0
5520 + rfdc: 1.4.1
5521 +
5026 5522 fast-levenshtein@2.0.6: {}
5027 5523
5524 + fast-querystring@1.1.2:
5525 + dependencies:
5526 + fast-decode-uri-component: 1.0.1
5527 +
5528 + fast-sha256@1.3.0: {}
5529 +
5530 + fast-uri@3.1.7: {}
5531 +
5532 + fast-uri@4.1.4: {}
5533 +
5534 + fastify-plugin@6.0.0: {}
5535 +
5536 + fastify@5.12.3:
5537 + dependencies:
5538 + '@fastify/ajv-compiler': 4.0.6
5539 + '@fastify/error': 4.2.0
5540 + '@fastify/fast-json-stringify-compiler': 5.1.0
5541 + '@fastify/proxy-addr': 5.1.0
5542 + abstract-logging: 2.0.1
5543 + avvio: 9.3.0
5544 + fast-json-stringify: 7.0.1
5545 + find-my-way: 9.9.0
5546 + light-my-request: 6.6.0
5547 + pino: 9.14.0
5548 + process-warning: 5.1.0
5549 + rfdc: 1.4.1
5550 + secure-json-parse: 4.1.0
5551 + semver: 7.8.5
5552 + toad-cache: 3.7.4
5553 +
5028 5554 fastq@1.20.3:
5029 5555 dependencies:
5030 5556 reusify: 1.1.0
@@ -5041,6 +5567,12 @@ snapshots:
5041 5567 dependencies:
5042 5568 to-regex-range: 5.0.1
5043 5569
5570 + find-my-way@9.9.0:
5571 + dependencies:
5572 + fast-deep-equal: 3.1.3
5573 + fast-querystring: 1.1.2
5574 + safe-regex2: 5.1.1
5575 +
5044 5576 find-up@5.0.0:
5045 5577 dependencies:
5046 5578 locate-path: 6.0.0
@@ -5168,6 +5700,8 @@ snapshots:
5168 5700 dependencies:
5169 5701 safer-buffer: 2.1.2
5170 5702
5703 + ieee754@1.2.1: {}
5704 +
5171 5705 ignore@5.3.2: {}
5172 5706
5173 5707 ignore@7.0.8: {}
@@ -5185,6 +5719,10 @@ snapshots:
5185 5719 hasown: 2.0.4
5186 5720 side-channel: 1.1.1
5187 5721
5722 + ip-address@10.7.0: {}
5723 +
5724 + ipaddr.js@2.5.0: {}
5725 +
5188 5726 is-array-buffer@3.0.5:
5189 5727 dependencies:
5190 5728 call-bind: 1.0.9
@@ -5328,8 +5866,19 @@ snapshots:
5328 5866
5329 5867 json-buffer@3.0.1: {}
5330 5868
5869 + json-schema-ref-resolver@3.0.0:
5870 + dependencies:
5871 + dequal: 2.0.3
5872 +
5873 + json-schema-to-ts@3.1.1:
5874 + dependencies:
5875 + '@babel/runtime': 7.29.7
5876 + ts-algebra: 2.0.0
5877 +
5331 5878 json-schema-traverse@0.4.1: {}
5332 5879
5880 + json-schema-traverse@1.0.0: {}
5881 +
5333 5882 json-stable-stringify-without-jsonify@1.0.1: {}
5334 5883
5335 5884 json5@1.0.2:
@@ -5360,6 +5909,12 @@ snapshots:
5360 5909 prelude-ls: 1.2.1
5361 5910 type-check: 0.4.0
5362 5911
5912 + light-my-request@6.6.0:
5913 + dependencies:
5914 + cookie: 1.1.1
5915 + process-warning: 4.0.1
5916 + set-cookie-parser: 2.7.2
5917 +
5363 5918 lightningcss-android-arm64@1.32.0:
5364 5919 optional: true
5365 5920
@@ -5429,6 +5984,8 @@ snapshots:
5429 5984 dependencies:
5430 5985 react: 19.2.8
5431 5986
5987 + luxon@3.7.2: {}
5988 +
5432 5989 magic-string@0.30.21:
5433 5990 dependencies:
5434 5991 '@jridgewell/sourcemap-codec': 1.6.0
@@ -5442,6 +5999,8 @@ snapshots:
5442 5999 braces: 3.0.3
5443 6000 picomatch: 2.3.2
5444 6001
6002 + mime-db@1.54.0: {}
6003 +
5445 6004 minimatch@10.2.6:
5446 6005 dependencies:
5447 6006 brace-expansion: 5.0.9
@@ -5452,6 +6011,8 @@ snapshots:
5452 6011
5453 6012 minimist@1.2.8: {}
5454 6013
6014 + minipass@7.1.3: {}
6015 +
5455 6016 ms@2.1.3: {}
5456 6017
5457 6018 nanoid@3.3.18: {}
@@ -5494,6 +6055,8 @@ snapshots:
5494 6055
5495 6056 node-releases@2.0.54: {}
5496 6057
6058 + non-error@0.1.0: {}
6059 +
5497 6060 nth-check@2.1.1:
5498 6061 dependencies:
5499 6062 boolbase: 1.0.0
@@ -5542,6 +6105,11 @@ snapshots:
5542 6105
5543 6106 on-exit-leak-free@2.1.2: {}
5544 6107
6108 + openai@7.10.0(undici@7.29.1)(zod@4.5.4):
6109 + optionalDependencies:
6110 + undici: 7.29.1
6111 + zod: 4.5.4
6112 +
5545 6113 optionator@0.9.4:
5546 6114 dependencies:
5547 6115 deep-is: 0.1.4
@@ -5593,6 +6161,49 @@ snapshots:
5593 6161
5594 6162 pathval@2.0.1: {}
5595 6163
6164 + pg-boss@12.30.0:
6165 + dependencies:
6166 + cron-parser: 5.10.0
6167 + pg: 8.23.0
6168 + serialize-error: 13.0.1
6169 + transitivePeerDependencies:
6170 + - pg-native
6171 +
6172 + pg-cloudflare@1.4.0:
6173 + optional: true
6174 +
6175 + pg-connection-string@2.14.0: {}
6176 +
6177 + pg-int8@1.0.1: {}
6178 +
6179 + pg-pool@3.14.0(pg@8.23.0):
6180 + dependencies:
6181 + pg: 8.23.0
6182 +
6183 + pg-protocol@1.16.0: {}
6184 +
6185 + pg-types@2.2.0:
6186 + dependencies:
6187 + pg-int8: 1.0.1
6188 + postgres-array: 2.0.0
6189 + postgres-bytea: 1.0.1
6190 + postgres-date: 1.0.7
6191 + postgres-interval: 1.2.0
6192 +
6193 + pg@8.23.0:
6194 + dependencies:
6195 + pg-connection-string: 2.14.0
6196 + pg-pool: 3.14.0(pg@8.23.0)
6197 + pg-protocol: 1.16.0
6198 + pg-types: 2.2.0
6199 + pgpass: 1.0.5
6200 + optionalDependencies:
6201 + pg-cloudflare: 1.4.0
6202 +
6203 + pgpass@1.0.5:
6204 + dependencies:
6205 + split2: 4.2.0
6206 +
5596 6207 picocolors@1.1.1: {}
5597 6208
5598 6209 picomatch@2.3.2: {}
@@ -5633,12 +6244,26 @@ snapshots:
5633 6244 picocolors: 1.1.1
5634 6245 source-map-js: 1.2.1
5635 6246
6247 + postgres-array@2.0.0: {}
6248 +
6249 + postgres-bytea@1.0.1: {}
6250 +
6251 + postgres-date@1.0.7: {}
6252 +
6253 + postgres-interval@1.2.0:
6254 + dependencies:
6255 + xtend: 4.0.2
6256 +
5636 6257 postgres@3.4.9: {}
5637 6258
5638 6259 prelude-ls@1.2.1: {}
5639 6260
6261 + process-warning@4.0.1: {}
6262 +
5640 6263 process-warning@5.1.0: {}
5641 6264
6265 + process@0.11.10: {}
6266 +
5642 6267 prop-types@15.8.1:
5643 6268 dependencies:
5644 6269 loose-envify: 1.4.0
@@ -5660,6 +6285,14 @@ snapshots:
5660 6285
5661 6286 react@19.2.8: {}
5662 6287
6288 + readable-stream@4.7.0:
6289 + dependencies:
6290 + abort-controller: 3.0.0
6291 + buffer: 6.0.3
6292 + events: 3.3.0
6293 + process: 0.11.10
6294 + string_decoder: 1.3.0
6295 +
5663 6296 real-require@0.2.0: {}
5664 6297
5665 6298 reflect.getprototypeof@1.0.10:
@@ -5682,6 +6315,8 @@ snapshots:
5682 6315 gopd: 1.2.0
5683 6316 set-function-name: 2.0.2
5684 6317
6318 + require-from-string@2.0.2: {}
6319 +
5685 6320 resolve-from@4.0.0: {}
5686 6321
5687 6322 resolve-pkg-maps@1.0.0: {}
@@ -5695,8 +6330,12 @@ snapshots:
5695 6330 path-parse: 1.0.7
5696 6331 supports-preserve-symlinks-flag: 1.0.0
5697 6332
6333 + ret@0.5.0: {}
6334 +
5698 6335 reusify@1.1.0: {}
5699 6336
6337 + rfdc@1.4.1: {}
6338 +
5700 6339 rollup@4.63.1:
5701 6340 dependencies:
5702 6341 '@types/estree': 1.0.9
@@ -5741,6 +6380,8 @@ snapshots:
5741 6380 has-symbols: 1.1.0
5742 6381 isarray: 2.0.5
5743 6382
6383 + safe-buffer@5.2.1: {}
6384 +
5744 6385 safe-push-apply@1.0.0:
5745 6386 dependencies:
5746 6387 es-errors: 1.3.0
@@ -5752,18 +6393,31 @@ snapshots:
5752 6393 es-errors: 1.3.0
5753 6394 is-regex: 1.2.1
5754 6395
6396 + safe-regex2@5.1.1:
6397 + dependencies:
6398 + ret: 0.5.0
6399 +
5755 6400 safe-stable-stringify@2.5.0: {}
5756 6401
5757 6402 safer-buffer@2.1.2: {}
5758 6403
5759 6404 scheduler@0.27.0: {}
5760 6405
6406 + secure-json-parse@4.1.0: {}
6407 +
5761 6408 semver@6.3.1: {}
5762 6409
5763 6410 semver@7.8.5: {}
5764 6411
6412 + serialize-error@13.0.1:
6413 + dependencies:
6414 + non-error: 0.1.0
6415 + type-fest: 5.9.0
6416 +
5765 6417 server-only@0.0.1: {}
5766 6418
6419 + set-cookie-parser@2.7.2: {}
6420 +
5767 6421 set-function-length@1.2.2:
5768 6422 dependencies:
5769 6423 define-data-property: 1.1.4
@@ -5875,6 +6529,11 @@ snapshots:
5875 6529
5876 6530 stackback@0.0.2: {}
5877 6531
6532 + standardwebhooks@1.1.1:
6533 + dependencies:
6534 + '@stablelib/base64': 1.0.1
6535 + fast-sha256: 1.3.0
6536 +
5878 6537 std-env@3.10.0: {}
5879 6538
5880 6539 stop-iteration-iterator@1.1.0:
@@ -5933,6 +6592,10 @@ snapshots:
5933 6592 define-properties: 1.2.1
5934 6593 es-object-atoms: 1.1.2
5935 6594
6595 + string_decoder@1.3.0:
6596 + dependencies:
6597 + safe-buffer: 5.2.1
6598 +
5936 6599 strip-bom@3.0.0: {}
5937 6600
5938 6601 strip-json-comments@3.1.1: {}
@@ -5954,6 +6617,8 @@ snapshots:
5954 6617
5955 6618 supports-preserve-symlinks-flag@1.0.0: {}
5956 6619
6620 + tagged-tag@1.0.0: {}
6621 +
5957 6622 tailwindcss@4.3.3: {}
5958 6623
5959 6624 tapable@2.3.3: {}
@@ -5981,6 +6646,10 @@ snapshots:
5981 6646 dependencies:
5982 6647 is-number: 7.0.0
5983 6648
6649 + toad-cache@3.7.4: {}
6650 +
6651 + ts-algebra@2.0.0: {}
6652 +
5984 6653 ts-api-utils@2.5.0(typescript@5.9.3):
5985 6654 dependencies:
5986 6655 typescript: 5.9.3
@@ -6004,6 +6673,10 @@ snapshots:
6004 6673 dependencies:
6005 6674 prelude-ls: 1.2.1
6006 6675
6676 + type-fest@5.9.0:
6677 + dependencies:
6678 + tagged-tag: 1.0.0
6679 +
6007 6680 typed-array-buffer@1.0.3:
6008 6681 dependencies:
6009 6682 call-bound: 1.0.4
@@ -6233,6 +6906,8 @@ snapshots:
6233 6906
6234 6907 word-wrap@1.2.5: {}
6235 6908
6909 + xtend@4.0.2: {}
6910 +
6236 6911 yallist@3.1.1: {}
6237 6912
6238 6913 yocto-queue@0.1.0: {}
added scripts/admin-cookie.ts +5 −0
@@ -0,0 +1,5 @@
1 +/** Print the admin cookie value for scripted access: scripts/with-env.sh pnpm tsx scripts/admin-cookie.ts */
2 +import { createHmac } from 'node:crypto';
3 +const token = process.env.ADMIN_TOKEN;
4 +if (!token) throw new Error('ADMIN_TOKEN not set');
5 +console.log(createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update(`admin:${token}`).digest('hex'));
added scripts/ai-smoke.ts +11 −0
@@ -0,0 +1,11 @@
1 +/** Manual smoke test for packages/ai (real API calls, a few cents). Run: pnpm tsx scripts/ai-smoke.ts */
2 +import { getRouter, classifyCategory, verifyEntityMatch, recentCosts } from '../packages/ai/src/index.ts';
3 +
4 +const r = getRouter();
5 +console.log(r.table());
6 +const c = await classifyCategory('1999 Pokemon Base Set Charizard 1st Edition Holo PSA 10 #4/102');
7 +console.log('classify', c.data, c.usdEst, c.model);
8 +const v = await verifyEntityMatch({ title: '1999 Pokemon Base Charizard 1st PSA 10' }, { title: 'Pokémon 1999 First Edition Charizard Holo PSA GEM MT 10' });
9 +console.log('verify', v.data.sameAsset, v.data.sameVariant, v.data.confidence, v.usdEst);
10 +console.log(recentCosts().map((e) => [e.model, e.usdEst]));
11 +process.exit(0);
added scripts/internal-token.ts +3 −0
@@ -0,0 +1,3 @@
1 +/** Print the internal token shared between web and api: scripts/with-env.sh pnpm tsx scripts/internal-token.ts */
2 +import { createHmac } from 'node:crypto';
3 +console.log(createHmac('sha256', process.env.SESSION_SECRET ?? 'dev-only').update('internal').digest('hex'));
added scripts/scan-smoke.py +27 −0
@@ -0,0 +1,27 @@
1 +#!/usr/bin/env python3
2 +"""Manual smoke test of the scanner endpoint. Usage: scan-smoke.py <text|photo|url> [value] [port]"""
3 +import base64, json, sys, urllib.request
4 +
5 +mode = sys.argv[1]
6 +value = sys.argv[2] if len(sys.argv) > 2 else ''
7 +port = sys.argv[3] if len(sys.argv) > 3 else '3002'
8 +if mode == 'photo':
9 + body = {"mode": "photo", "images": [{"data": base64.b64encode(open(value, 'rb').read()).decode(), "mediaType": "image/jpeg"}]}
10 +elif mode == 'url':
11 + body = {"mode": "url", "url": value}
12 +else:
13 + body = {"mode": "text", "text": value}
14 +req = urllib.request.Request(f'http://127.0.0.1:{port}/api/scanner', data=json.dumps(body).encode(), headers={'content-type': 'application/json'})
15 +try:
16 + r = json.load(urllib.request.urlopen(req, timeout=180))
17 +except urllib.error.HTTPError as e:
18 + print('HTTP', e.code, e.read().decode()[:500]); sys.exit(1)
19 +print(json.dumps({k: r.get(k) for k in ('sessionId', 'mode', 'guessConfidence', 'model', 'usdEst', 'durationMs', 'notes')}, indent=1))
20 +g = r.get('guess') or {}
21 +print('guess', {k: g.get(k) for k in ('categorySlug', 'name', 'brand', 'set', 'number', 'year', 'variant', 'grader', 'grade', 'likelyGradeRange')})
22 +print('warnings', g.get('warnings'))
23 +print('candidates', [(c['title'], round(c['score'], 2)) for c in r.get('candidates', [])])
24 +print('best', (r.get('best') or {}).get('title'))
25 +if r.get('listing'):
26 + l = r['listing']
27 + print('listing', l['sourceId'], l['rawTitle'], l['price'], l['currency'], l['verdict'], l['discountToRiv'])
added scripts/with-env.sh +6 −0
@@ -0,0 +1,6 @@
1 +#!/bin/sh
2 +# Run a command with the repo .env loaded: scripts/with-env.sh pnpm tsx scripts/ai-smoke.ts
3 +set -a
4 +. "$(dirname "$0")/../.env"
5 +set +a
6 +exec "$@"
7