SPB Git

spb/rareindex

Public

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

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

76 changed files +7,807 −7

76 changed files
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
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/admin/status/route.ts +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/research/[id]/route.ts +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/research/route.ts +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/scanner/[id]/choose/route.ts +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/api/scanner/route.ts +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/research/page.tsx +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/scanner/page.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/shell.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/research/markdown.tsx +71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/research/research-client.tsx +212 −0
Large diff (212 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/scanner/scanner-client.tsx +350 −0
Large diff (350 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/actions.ts +156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/auth.ts +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/queries.ts +121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/queue.ts +57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/ai/candidates.ts +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/ai/lookup.ts +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/ai/request.ts +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/ai/research-tools.ts +259 −0
Large diff (259 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/ai/research.ts +135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/ai/scanner.ts +169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/proxy.ts +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/tsconfig.json +2 −2
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added docs/API.md +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added docs/PENDING-SCHEMA-ai.md +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added docs/openapi.json +2075 −0
Large diff (2,075 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/package.json +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/ai.test.ts +101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/costs.ts +61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/helpers/identify.ts +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/helpers/news.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/helpers/verify-match.ts +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/index.ts +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/pricing.ts +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/providers/anthropic.ts +275 −0
Large diff (275 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/providers/openai-compatible.ts +206 −0
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/router.ts +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/src/types.ts +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/tsconfig.json +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added packages/ai/vitest.config.ts +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added packages/database/src/schema/ai.ts +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
modified packages/database/src/schema/index.ts +1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
modified pnpm-lock.yaml +677 −2
Large diff (1,240 lines) is not loaded by default to keep the page fast.Load diff
added scripts/admin-cookie.ts +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added scripts/ai-smoke.ts +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added scripts/internal-token.ts +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added scripts/scan-smoke.py +27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added scripts/with-env.sh +6 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme