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 reactStrictMode: true, 4 reactStrictMode: true,
5 poweredByHeader: false, 5 poweredByHeader: false,
6 // Workspace packages are consumed as TypeScript sources. 6 // Workspace packages are consumed as TypeScript sources.
7− transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors'], 7+ transpilePackages: ['@rareindex/shared', '@rareindex/database', '@rareindex/taxonomy', '@rareindex/connectors', '@rareindex/ai'],
8− serverExternalPackages: ['postgres', 'pino', 'cheerio'], 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 images: { 14 images: {
10 remotePatterns: [{ protocol: 'https', hostname: '**' }], 15 remotePatterns: [{ protocol: 'https', hostname: '**' }],
11 formats: ['image/avif', 'image/webp'], 16 formats: ['image/avif', 'image/webp'],
modified apps/web/package.json +2 −0
@@ -11,6 +11,7 @@
11 "test": "vitest run --passWithNoTests" 11 "test": "vitest run --passWithNoTests"
12 }, 12 },
13 "dependencies": { 13 "dependencies": {
14+ "@rareindex/ai": "workspace:*",
14 "@rareindex/connectors": "workspace:*", 15 "@rareindex/connectors": "workspace:*",
15 "@rareindex/database": "workspace:*", 16 "@rareindex/database": "workspace:*",
16 "@rareindex/shared": "workspace:*", 17 "@rareindex/shared": "workspace:*",
@@ -18,6 +19,7 @@
18 "@tanstack/react-query": "^5.90.0", 19 "@tanstack/react-query": "^5.90.0",
19 "lucide-react": "^1.0.0", 20 "lucide-react": "^1.0.0",
20 "next": "16.3.4", 21 "next": "16.3.4",
22+ "pg-boss": "^12.30.0",
21 "react": "19.2.8", 23 "react": "19.2.8",
22 "react-dom": "19.2.8", 24 "react-dom": "19.2.8",
23 "server-only": "^0.0.1", 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