AI package, scanner, research, admin console, public API (agent F2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
76 changed files +7,807 −7
modified
apps/api/package.json
+33 −1
@@ -1 +1,33 @@ | |||||
| 1 | −{"name":"@rareindex/api","version":"0.1.0","private":true,"type":"module","scripts":{"typecheck":"tsc -p tsconfig.json --noEmit","test":"vitest run --passWithNoTests","build":"tsc -p tsconfig.json --noEmit"},"dependencies":{"@rareindex/shared":"workspace:*"},"devDependencies":{"@types/node":"^24.0.0","typescript":"^5.9.3","vitest":"^3.2.0"}} | 1 | +{ | ||
| 2 | + "name": "@rareindex/api", | ||||
| 3 | + "version": "0.1.0", | ||||
| 4 | + "private": true, | ||||
| 5 | + "type": "module", | ||||
| 6 | + "scripts": { | ||||
| 7 | + "dev": "tsx watch src/server.ts", | ||||
| 8 | + "start": "tsx src/server.ts", | ||||
| 9 | + "build": "tsc -p tsconfig.json --noEmit", | ||||
| 10 | + "typecheck": "tsc -p tsconfig.json --noEmit", | ||||
| 11 | + "test": "vitest run --passWithNoTests", | ||||
| 12 | + "create-key": "tsx scripts/create-key.ts" | ||||
| 13 | + }, | ||||
| 14 | + "dependencies": { | ||||
| 15 | + "@fastify/compress": "^9.0.0", | ||||
| 16 | + "@fastify/cors": "^11.0.0", | ||||
| 17 | + "@fastify/etag": "^6.0.0", | ||||
| 18 | + "@fastify/rate-limit": "^11.0.0", | ||||
| 19 | + "@rareindex/connectors": "workspace:*", | ||||
| 20 | + "@rareindex/database": "workspace:*", | ||||
| 21 | + "@rareindex/shared": "workspace:*", | ||||
| 22 | + "@rareindex/taxonomy": "workspace:*", | ||||
| 23 | + "fastify": "^5.6.0", | ||||
| 24 | + "fastify-plugin": "^6.0.0", | ||||
| 25 | + "zod": "^4.0.0" | ||||
| 26 | + }, | ||||
| 27 | + "devDependencies": { | ||||
| 28 | + "@types/node": "^24.0.0", | ||||
| 29 | + "tsx": "^4.20.0", | ||||
| 30 | + "typescript": "^5.9.3", | ||||
| 31 | + "vitest": "^3.2.0" | ||||
| 32 | + } | ||||
| 33 | +} | ||||
added
apps/api/scripts/create-key.ts
+21 −0
@@ -0,0 +1,21 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Mint an API key. Prints the plaintext ONCE; only its hash is stored. | ||||
| 3 | + * pnpm --filter @rareindex/api create-key -- --name "Research desk" --tier professional [--user usr_xxx] | ||||
| 4 | + */ | ||||
| 5 | +import { closeDb } from '@rareindex/database'; | ||||
| 6 | +import { mintApiKey, TIERS, type Tier } from '../src/lib/keys.js'; | ||||
| 7 | + | ||||
| 8 | +const args = process.argv.slice(2); | ||||
| 9 | +const get = (flag: string) => { | ||||
| 10 | + const i = args.indexOf(flag); | ||||
| 11 | + return i >= 0 ? args[i + 1] : undefined; | ||||
| 12 | +}; | ||||
| 13 | +const name = get('--name') ?? 'default'; | ||||
| 14 | +const tier = (get('--tier') ?? 'free') as Tier; | ||||
| 15 | +if (!(tier in TIERS)) { | ||||
| 16 | + console.error(`unknown tier ${tier}; choose one of ${Object.keys(TIERS).join(', ')}`); | ||||
| 17 | + process.exit(1); | ||||
| 18 | +} | ||||
| 19 | +const minted = await mintApiKey({ name, tier, userId: get('--user') ?? null }); | ||||
| 20 | +console.log(`API key created (${minted.tier}, id ${minted.id}). Store it now — it will not be shown again:\n\n ${minted.key}\n`); | ||||
| 21 | +await closeDb(); | ||||
added
apps/api/scripts/gen-openapi.ts
+9 −0
@@ -0,0 +1,9 @@ | |||||
| 1 | +import { writeFileSync } from 'node:fs'; | ||||
| 2 | +import path from 'node:path'; | ||||
| 3 | +import { fileURLToPath } from 'node:url'; | ||||
| 4 | +import { openapiDocument } from '../src/openapi.js'; | ||||
| 5 | + | ||||
| 6 | +const here = path.dirname(fileURLToPath(import.meta.url)); | ||||
| 7 | +const out = path.resolve(here, '../../../docs/openapi.json'); | ||||
| 8 | +writeFileSync(out, JSON.stringify(openapiDocument(), null, 2) + '\n'); | ||||
| 9 | +console.log(`wrote ${out}`); | ||||
added
apps/api/src/app.test.ts
+97 −0
@@ -0,0 +1,97 @@ | |||||
| 1 | +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||||
| 2 | +import type { FastifyInstance } from 'fastify'; | ||||
| 3 | +import { closeDb, getDb, apiKeys, eq } from '@rareindex/database'; | ||||
| 4 | +import { buildApp } from './app.js'; | ||||
| 5 | +import { mintApiKey } from './lib/keys.js'; | ||||
| 6 | +import { toCsv, encodeCursor, decodeCursor } from './lib/envelope.js'; | ||||
| 7 | + | ||||
| 8 | +/** Integration tests against DATABASE_URL (rareindex_ai locally). They only read except for a temporary API key. */ | ||||
| 9 | +let app: FastifyInstance; | ||||
| 10 | +let key: { id: string; key: string }; | ||||
| 11 | + | ||||
| 12 | +beforeAll(async () => { | ||||
| 13 | + process.env.LOG_LEVEL = 'silent'; | ||||
| 14 | + app = await buildApp({ logger: false }); | ||||
| 15 | + key = await mintApiKey({ name: 'vitest', tier: 'hobby' }); | ||||
| 16 | +}); | ||||
| 17 | +afterAll(async () => { | ||||
| 18 | + await getDb().delete(apiKeys).where(eq(apiKeys.id, key.id)); | ||||
| 19 | + await app.close(); | ||||
| 20 | + await closeDb(); | ||||
| 21 | +}); | ||||
| 22 | + | ||||
| 23 | +describe('api', () => { | ||||
| 24 | + it('health and root', async () => { | ||||
| 25 | + const h = await app.inject({ method: 'GET', url: '/healthz' }); | ||||
| 26 | + expect(h.statusCode).toBe(200); | ||||
| 27 | + expect(h.json().ok).toBe(true); | ||||
| 28 | + const r = await app.inject({ method: 'GET', url: '/' }); | ||||
| 29 | + expect(r.json().openapi).toBe('/v1/openapi.json'); | ||||
| 30 | + }); | ||||
| 31 | + it('serves openapi', async () => { | ||||
| 32 | + const r = await app.inject({ method: 'GET', url: '/v1/openapi.json' }); | ||||
| 33 | + expect(r.statusCode).toBe(200); | ||||
| 34 | + expect(r.json().paths['/v1/assets/search']).toBeTruthy(); | ||||
| 35 | + }); | ||||
| 36 | + it('anonymous requests use the public tier with rate-limit headers', async () => { | ||||
| 37 | + const r = await app.inject({ method: 'GET', url: '/v1/categories' }); | ||||
| 38 | + expect(r.statusCode).toBe(200); | ||||
| 39 | + expect(r.headers['x-ratelimit-limit']).toBe('20'); | ||||
| 40 | + expect(r.headers['x-request-id']).toBeTruthy(); | ||||
| 41 | + const body = r.json(); | ||||
| 42 | + expect(body.meta.count).toBeGreaterThan(100); | ||||
| 43 | + expect(body.meta.attribution).toMatch(/RareIndex/); | ||||
| 44 | + expect(body.data.find((c: { slug: string }) => c.slug === 'pokemon')).toBeTruthy(); | ||||
| 45 | + }); | ||||
| 46 | + it('rejects unknown keys and accepts minted keys with tier limits', async () => { | ||||
| 47 | + const bad = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: 'Bearer ri_live_nope_nope' } }); | ||||
| 48 | + expect(bad.statusCode).toBe(401); | ||||
| 49 | + expect(bad.headers['content-type']).toMatch(/problem\+json/); | ||||
| 50 | + const good = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: `Bearer ${key.key}` } }); | ||||
| 51 | + expect(good.statusCode).toBe(200); | ||||
| 52 | + expect(good.headers['x-ratelimit-limit']).toBe('120'); | ||||
| 53 | + const idx = good.json(); | ||||
| 54 | + expect(idx.data.find((i: { ticker: string }) => i.ticker === 'RARE')).toBeTruthy(); | ||||
| 55 | + expect(idx.data.every((i: { published: boolean; value: unknown }) => i.published === (i.value !== null))).toBe(true); | ||||
| 56 | + }); | ||||
| 57 | + it('validates queries and returns problem+json', async () => { | ||||
| 58 | + const r = await app.inject({ method: 'GET', url: '/v1/assets/search?limit=9999' }); | ||||
| 59 | + expect(r.statusCode).toBe(400); | ||||
| 60 | + expect(r.json().title).toBe('Invalid query'); | ||||
| 61 | + }); | ||||
| 62 | + it('404 on unknown asset/market', async () => { | ||||
| 63 | + expect((await app.inject({ method: 'GET', url: '/v1/assets/does-not-exist' })).statusCode).toBe(404); | ||||
| 64 | + expect((await app.inject({ method: 'GET', url: '/v1/markets/does-not-exist' })).statusCode).toBe(404); | ||||
| 65 | + }); | ||||
| 66 | + it('search, trending, sales, records, markets and csv export respond', async () => { | ||||
| 67 | + for (const url of ['/v1/assets/search?q=charizard', '/v1/trending', '/v1/sales/latest', '/v1/records', '/v1/markets', '/v1/markets/pokemon', '/v1/indices/RARE-TCG/history', '/v1/stats']) { | ||||
| 68 | + const r = await app.inject({ method: 'GET', url }); | ||||
| 69 | + expect(r.statusCode, url).toBe(200); | ||||
| 70 | + } | ||||
| 71 | + const csv = await app.inject({ method: 'GET', url: '/v1/categories?format=csv' }); | ||||
| 72 | + expect(csv.headers['content-type']).toMatch(/text\/csv/); | ||||
| 73 | + expect(csv.body.split('\n')[0]).toContain('slug'); | ||||
| 74 | + }); | ||||
| 75 | + it('rate limits by key', async () => { | ||||
| 76 | + const small = await mintApiKey({ name: 'vitest-small', tier: 'free' }); | ||||
| 77 | + try { | ||||
| 78 | + let last = 200; | ||||
| 79 | + for (let i = 0; i < 65; i++) { | ||||
| 80 | + const r = await app.inject({ method: 'GET', url: '/v1/stats', headers: { authorization: `Bearer ${small.key}` } }); | ||||
| 81 | + last = r.statusCode; | ||||
| 82 | + if (last === 429) break; | ||||
| 83 | + } | ||||
| 84 | + expect(last).toBe(429); | ||||
| 85 | + } finally { | ||||
| 86 | + await getDb().delete(apiKeys).where(eq(apiKeys.id, small.id)); | ||||
| 87 | + } | ||||
| 88 | + }); | ||||
| 89 | +}); | ||||
| 90 | + | ||||
| 91 | +describe('envelope helpers', () => { | ||||
| 92 | + it('csv + cursor', () => { | ||||
| 93 | + expect(toCsv([{ a: 1, b: 'x,y' }, { a: 2, b: null }])).toBe('a,b\n1,"x,y"\n2,\n'); | ||||
| 94 | + expect(decodeCursor(encodeCursor(150))).toBe(150); | ||||
| 95 | + expect(decodeCursor('garbage')).toBe(0); | ||||
| 96 | + }); | ||||
| 97 | +}); | ||||
added
apps/api/src/app.ts
+71 −0
@@ -0,0 +1,71 @@ | |||||
| 1 | +import Fastify, { type FastifyInstance } from 'fastify'; | ||||
| 2 | +import cors from '@fastify/cors'; | ||||
| 3 | +import compress from '@fastify/compress'; | ||||
| 4 | +import etag from '@fastify/etag'; | ||||
| 5 | +import rateLimit from '@fastify/rate-limit'; | ||||
| 6 | +import { randomUUID } from 'node:crypto'; | ||||
| 7 | +import { getSql } from '@rareindex/database'; | ||||
| 8 | +import { authPlugin, rateKeyFor, rateLimitFor } from './plugins/auth.js'; | ||||
| 9 | +import { v1Routes } from './routes/v1.js'; | ||||
| 10 | +import { internalRoutes } from './routes/internal.js'; | ||||
| 11 | +import { problem } from './lib/envelope.js'; | ||||
| 12 | + | ||||
| 13 | +export interface BuildOptions { | ||||
| 14 | + logger?: boolean; | ||||
| 15 | +} | ||||
| 16 | + | ||||
| 17 | +export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> { | ||||
| 18 | + const app = Fastify({ | ||||
| 19 | + logger: opts.logger === false ? false : { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization'] }, | ||||
| 20 | + genReqId: (req) => (req.headers['x-request-id'] as string | undefined) ?? randomUUID(), | ||||
| 21 | + trustProxy: true, | ||||
| 22 | + }); | ||||
| 23 | + | ||||
| 24 | + await app.register(cors, { origin: true, methods: ['GET', 'HEAD', 'OPTIONS'], exposedHeaders: ['x-request-id', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset'] }); | ||||
| 25 | + await app.register(compress, { global: true, encodings: ['gzip', 'br', 'deflate'] }); | ||||
| 26 | + await app.register(etag); | ||||
| 27 | + await app.register(authPlugin); | ||||
| 28 | + await app.register(rateLimit, { | ||||
| 29 | + global: true, | ||||
| 30 | + max: (req) => rateLimitFor(req), | ||||
| 31 | + timeWindow: '1 minute', | ||||
| 32 | + keyGenerator: (req) => rateKeyFor(req), | ||||
| 33 | + errorResponseBuilder: (req, ctx) => ({ | ||||
| 34 | + statusCode: 429, | ||||
| 35 | + type: 'https://www.rareindex.io/api-docs#errors', | ||||
| 36 | + title: 'Rate limit exceeded', | ||||
| 37 | + status: 429, | ||||
| 38 | + detail: `Limit ${ctx.max} requests per ${ctx.after}. Retry after ${ctx.ttl} ms.`, | ||||
| 39 | + instance: req.url, | ||||
| 40 | + request_id: req.id, | ||||
| 41 | + }), | ||||
| 42 | + }); | ||||
| 43 | + | ||||
| 44 | + app.addHook('onSend', async (req, reply, payload) => { | ||||
| 45 | + reply.header('x-request-id', req.id); | ||||
| 46 | + return payload; | ||||
| 47 | + }); | ||||
| 48 | + | ||||
| 49 | + app.setErrorHandler((err: Error & { statusCode?: number; status?: number; title?: string; detail?: string }, req, reply) => { | ||||
| 50 | + const status = err.statusCode ?? err.status ?? 500; | ||||
| 51 | + if (status >= 500) req.log.error({ err }, 'unhandled api error'); | ||||
| 52 | + const title = status >= 500 ? 'Internal error' : (err.title ?? err.message); | ||||
| 53 | + const detail = status >= 500 ? 'The request could not be completed.' : (err.detail ?? err.message); | ||||
| 54 | + return problem(reply, status, title, detail); | ||||
| 55 | + }); | ||||
| 56 | + app.setNotFoundHandler((req, reply) => problem(reply, 404, 'Not found', `No route for ${req.method} ${req.url}`)); | ||||
| 57 | + | ||||
| 58 | + app.get('/healthz', async (_req, reply) => { | ||||
| 59 | + try { | ||||
| 60 | + await getSql()`select 1`; | ||||
| 61 | + return { ok: true, service: 'rareindex-api', time: new Date().toISOString() }; | ||||
| 62 | + } catch (err) { | ||||
| 63 | + return reply.code(503).send({ ok: false, service: 'rareindex-api', error: err instanceof Error ? err.message : String(err) }); | ||||
| 64 | + } | ||||
| 65 | + }); | ||||
| 66 | + app.get('/', async () => ({ name: 'RareIndex Public API', docs: 'https://www.rareindex.io/api-docs', openapi: '/v1/openapi.json' })); | ||||
| 67 | + | ||||
| 68 | + await app.register(v1Routes); | ||||
| 69 | + await app.register(internalRoutes); | ||||
| 70 | + return app; | ||||
| 71 | +} | ||||
added
apps/api/src/lib/envelope.ts
+65 −0
@@ -0,0 +1,65 @@ | |||||
| 1 | +import type { FastifyReply } from 'fastify'; | ||||
| 2 | + | ||||
| 3 | +export const ATTRIBUTION = 'Data © RareIndex.io and its sources; valuations are estimates, listing prices are not confirmed transactions. See https://www.rareindex.io/data for methodology and terms.'; | ||||
| 4 | + | ||||
| 5 | +export interface Meta { | ||||
| 6 | + count: number; | ||||
| 7 | + cursor?: string | null; | ||||
| 8 | + as_of: string; | ||||
| 9 | + attribution: string; | ||||
| 10 | + [k: string]: unknown; | ||||
| 11 | +} | ||||
| 12 | + | ||||
| 13 | +export function envelope<T>(data: T, meta: Partial<Meta> & { count?: number } = {}) { | ||||
| 14 | + const count = meta.count ?? (Array.isArray(data) ? data.length : 1); | ||||
| 15 | + return { data, meta: { count, cursor: meta.cursor ?? null, as_of: new Date().toISOString(), attribution: ATTRIBUTION, ...meta } }; | ||||
| 16 | +} | ||||
| 17 | + | ||||
| 18 | +export class ApiProblem extends Error { | ||||
| 19 | + constructor( | ||||
| 20 | + public status: number, | ||||
| 21 | + public title: string, | ||||
| 22 | + detail?: string, | ||||
| 23 | + public type = 'about:blank', | ||||
| 24 | + ) { | ||||
| 25 | + super(detail ?? title); | ||||
| 26 | + } | ||||
| 27 | +} | ||||
| 28 | + | ||||
| 29 | +export function problem(reply: FastifyReply, status: number, title: string, detail?: string, extra: Record<string, unknown> = {}) { | ||||
| 30 | + return reply | ||||
| 31 | + .code(status) | ||||
| 32 | + .type('application/problem+json') | ||||
| 33 | + .send({ type: 'https://www.rareindex.io/api-docs#errors', title, status, detail: detail ?? title, instance: reply.request.url, request_id: reply.request.id, ...extra }); | ||||
| 34 | +} | ||||
| 35 | + | ||||
| 36 | +/** Opaque offset cursor. */ | ||||
| 37 | +export function encodeCursor(offset: number): string { | ||||
| 38 | + return Buffer.from(String(offset), 'utf8').toString('base64url'); | ||||
| 39 | +} | ||||
| 40 | +export function decodeCursor(cursor: string | undefined | null): number { | ||||
| 41 | + if (!cursor) return 0; | ||||
| 42 | + const n = Number(Buffer.from(cursor, 'base64url').toString('utf8')); | ||||
| 43 | + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; | ||||
| 44 | +} | ||||
| 45 | + | ||||
| 46 | +/** Minimal CSV export for flat row arrays. */ | ||||
| 47 | +export function toCsv(rows: Array<Record<string, unknown>>): string { | ||||
| 48 | + if (rows.length === 0) return ''; | ||||
| 49 | + const cols = [...new Set(rows.flatMap((r) => Object.keys(r)))]; | ||||
| 50 | + const esc = (v: unknown) => { | ||||
| 51 | + if (v === null || v === undefined) return ''; | ||||
| 52 | + const s = v instanceof Date ? v.toISOString() : typeof v === 'object' ? JSON.stringify(v) : String(v); | ||||
| 53 | + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; | ||||
| 54 | + }; | ||||
| 55 | + return [cols.join(','), ...rows.map((r) => cols.map((c) => esc(r[c])).join(','))].join('\n') + '\n'; | ||||
| 56 | +} | ||||
| 57 | + | ||||
| 58 | +export function wantsCsv(query: Record<string, unknown>): boolean { | ||||
| 59 | + return String(query.format ?? '').toLowerCase() === 'csv'; | ||||
| 60 | +} | ||||
| 61 | + | ||||
| 62 | +export function sendList(reply: FastifyReply, rows: Array<Record<string, unknown>>, meta: Partial<Meta> = {}, query: Record<string, unknown> = {}) { | ||||
| 63 | + if (wantsCsv(query)) return reply.type('text/csv; charset=utf-8').header('content-disposition', 'attachment; filename="rareindex-export.csv"').send(toCsv(rows)); | ||||
| 64 | + return reply.send(envelope(rows, meta)); | ||||
| 65 | +} | ||||
added
apps/api/src/lib/keys.ts
+54 −0
@@ -0,0 +1,54 @@ | |||||
| 1 | +import { randomBytes } from 'node:crypto'; | ||||
| 2 | +import { sha256, newId } from '@rareindex/shared'; | ||||
| 3 | +import { getDb, apiKeys, eq } from '@rareindex/database'; | ||||
| 4 | + | ||||
| 5 | +/** API tiers (§158). Limits are per key per minute + a daily quota. */ | ||||
| 6 | +export const TIERS = { | ||||
| 7 | + public: { rateLimitPerMinute: 20, dailyQuota: 500 }, | ||||
| 8 | + free: { rateLimitPerMinute: 60, dailyQuota: 1_000 }, | ||||
| 9 | + hobby: { rateLimitPerMinute: 120, dailyQuota: 10_000 }, | ||||
| 10 | + professional: { rateLimitPerMinute: 600, dailyQuota: 100_000 }, | ||||
| 11 | + research: { rateLimitPerMinute: 600, dailyQuota: 250_000 }, | ||||
| 12 | + enterprise: { rateLimitPerMinute: 3_000, dailyQuota: 5_000_000 }, | ||||
| 13 | +} as const; | ||||
| 14 | +export type Tier = keyof typeof TIERS; | ||||
| 15 | + | ||||
| 16 | +export interface MintedKey { | ||||
| 17 | + id: string; | ||||
| 18 | + key: string; | ||||
| 19 | + prefix: string; | ||||
| 20 | + tier: Tier; | ||||
| 21 | +} | ||||
| 22 | + | ||||
| 23 | +/** Create a key: `ri_live_<prefix>_<secret>`; only the hash is stored. The plaintext is returned once. */ | ||||
| 24 | +export async function mintApiKey(input: { name: string; tier?: Tier; userId?: string | null }): Promise<MintedKey> { | ||||
| 25 | + const tier = input.tier ?? 'free'; | ||||
| 26 | + const prefix = randomBytes(4).toString('hex'); | ||||
| 27 | + const secret = randomBytes(24).toString('base64url'); | ||||
| 28 | + const key = `ri_live_${prefix}_${secret}`; | ||||
| 29 | + const id = newId('apiKey'); | ||||
| 30 | + const t = TIERS[tier]; | ||||
| 31 | + await getDb().insert(apiKeys).values({ id, userId: input.userId ?? null, name: input.name, prefix, keyHash: sha256(key), tier, rateLimitPerMinute: t.rateLimitPerMinute, dailyQuota: t.dailyQuota }); | ||||
| 32 | + return { id, key, prefix, tier }; | ||||
| 33 | +} | ||||
| 34 | + | ||||
| 35 | +export interface ResolvedKey { | ||||
| 36 | + id: string; | ||||
| 37 | + tier: Tier; | ||||
| 38 | + rateLimitPerMinute: number; | ||||
| 39 | + dailyQuota: number; | ||||
| 40 | + userId: string | null; | ||||
| 41 | +} | ||||
| 42 | + | ||||
| 43 | +export function parseBearer(header: string | undefined): string | null { | ||||
| 44 | + if (!header) return null; | ||||
| 45 | + const m = header.match(/^Bearer\s+(\S+)$/i); | ||||
| 46 | + return m ? m[1]! : null; | ||||
| 47 | +} | ||||
| 48 | + | ||||
| 49 | +export async function resolveApiKey(key: string): Promise<ResolvedKey | null> { | ||||
| 50 | + if (!key.startsWith('ri_live_')) return null; | ||||
| 51 | + const [row] = await getDb().select().from(apiKeys).where(eq(apiKeys.keyHash, sha256(key))).limit(1); | ||||
| 52 | + if (!row || row.revokedAt) return null; | ||||
| 53 | + return { id: row.id, tier: (row.tier as Tier) ?? 'free', rateLimitPerMinute: row.rateLimitPerMinute, dailyQuota: row.dailyQuota, userId: row.userId }; | ||||
| 54 | +} | ||||
added
apps/api/src/lib/queries.ts
+156 −0
@@ -0,0 +1,156 @@ | |||||
| 1 | +import { getDb, sql } from '@rareindex/database'; | ||||
| 2 | + | ||||
| 3 | +/** | ||||
| 4 | + * Read-only query helpers for the public API. Plain SQL through drizzle's `sql` tag: every | ||||
| 5 | + * function returns already-shaped rows so route handlers stay thin. Nothing here writes. | ||||
| 6 | + */ | ||||
| 7 | +type Row = Record<string, unknown>; | ||||
| 8 | +async function rows<T = Row>(q: ReturnType<typeof sql>): Promise<T[]> { | ||||
| 9 | + const res = await getDb().execute(q); | ||||
| 10 | + return res as unknown as T[]; | ||||
| 11 | +} | ||||
| 12 | + | ||||
| 13 | +export const ASSET_COLUMNS = sql`a.id, a.slug, a.title, a.name, a.category_slug, a.family_slug, a.brand, a.franchise, a.set_name, a.set_code, a.number, a.year, a.edition, a.variant, a.language, a.hero_image_url, a.identifiers, a.data_quality, | ||||
| 14 | + s.riv_usd, s.riv_low_usd, s.riv_high_usd, s.riv_confidence, s.riv_sample_size, s.latest_sale_usd, s.latest_sale_at, s.change_7d, s.change_30d, s.change_1y, s.ath_usd, s.atl_usd, | ||||
| 15 | + s.sales_count, s.sales_30d, s.active_listings, s.min_ask_usd, s.liquidity_score, s.rarity_score, s.momentum_30d, s.trending_score, s.value_opportunity, s.updated_at as stats_updated_at`; | ||||
| 16 | + | ||||
| 17 | +export async function searchAssets(opts: { q?: string; category?: string; limit: number; offset: number }) { | ||||
| 18 | + const q = opts.q?.trim(); | ||||
| 19 | + const catFilter = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``; | ||||
| 20 | + if (q) { | ||||
| 21 | + const tsq = q | ||||
| 22 | + .split(/\s+/) | ||||
| 23 | + .filter(Boolean) | ||||
| 24 | + .map((t) => t.replace(/[^\p{L}\p{N}./-]/gu, '')) | ||||
| 25 | + .filter(Boolean) | ||||
| 26 | + .map((t) => `${t}:*`) | ||||
| 27 | + .join(' & '); | ||||
| 28 | + return rows(sql` | ||||
| 29 | + select ${ASSET_COLUMNS}, | ||||
| 30 | + (coalesce(ts_rank(a.search, to_tsquery('simple', ${tsq})), 0) * 2 + similarity(a.title, ${q}) + coalesce(log(1 + s.sales_count), 0) * 0.05) as score | ||||
| 31 | + from assets a left join asset_stats s on s.asset_id = a.id | ||||
| 32 | + where (a.search @@ to_tsquery('simple', ${tsq}) or a.title % ${q} or a.identifiers::text ilike ${'%' + q + '%'}) ${catFilter} | ||||
| 33 | + order by score desc, s.sales_count desc nulls last | ||||
| 34 | + limit ${opts.limit} offset ${opts.offset}`); | ||||
| 35 | + } | ||||
| 36 | + return rows(sql` | ||||
| 37 | + select ${ASSET_COLUMNS}, 0 as score from assets a left join asset_stats s on s.asset_id = a.id | ||||
| 38 | + where true ${catFilter} | ||||
| 39 | + order by s.sales_count desc nulls last, a.updated_at desc | ||||
| 40 | + limit ${opts.limit} offset ${opts.offset}`); | ||||
| 41 | +} | ||||
| 42 | + | ||||
| 43 | +export async function getAsset(idOrSlug: string) { | ||||
| 44 | + const [asset] = await rows(sql`select ${ASSET_COLUMNS}, a.description, a.reference, a.model, a.series, a.rarity, a.production_quantity, a.original_msrp, a.original_msrp_currency, a.release_date, a.metadata, a.created_at, a.updated_at | ||||
| 45 | + from assets a left join asset_stats s on s.asset_id = a.id where a.id = ${idOrSlug} or a.slug = ${idOrSlug} limit 1`); | ||||
| 46 | + if (!asset) return null; | ||||
| 47 | + const id = asset.id as string; | ||||
| 48 | + const variants = await rows(sql`select v.id, v.variant_key, v.label, v.grader, v.grade, v.qualifier, v.condition, v.completeness, v.is_default, | ||||
| 49 | + vs.riv_usd, vs.riv_low_usd, vs.riv_high_usd, vs.riv_confidence, vs.riv_sample_size, vs.latest_sale_usd, vs.latest_sale_at, vs.change_30d, vs.change_1y, vs.sales_count, vs.sales_30d, vs.active_listings, vs.min_ask_usd, vs.liquidity_score | ||||
| 50 | + from asset_variants v left join variant_stats vs on vs.variant_id = v.id where v.asset_id = ${id} order by vs.sales_count desc nulls last, v.label`); | ||||
| 51 | + const [valuation] = await rows(sql`select id, variant_id, computed_at, riv_usd, low_usd, high_usd, confidence, confidence_label, sample_size, window_days, methods, method, notes from valuations where asset_id = ${id} and variant_id is null order by computed_at desc limit 1`); | ||||
| 52 | + const sources = await rows(sql`select source_id, count(*)::int as sales from sales where asset_id = ${id} and status = 'valid' group by source_id order by sales desc`); | ||||
| 53 | + return { ...(asset as Row & { id: string }), variants, valuation: valuation ?? null, sources }; | ||||
| 54 | +} | ||||
| 55 | + | ||||
| 56 | +export async function assetSales(assetId: string, opts: { limit: number; offset: number; variantId?: string; includeFlagged?: boolean }) { | ||||
| 57 | + const statusFilter = opts.includeFlagged ? sql`and status <> 'excluded'` : sql`and status = 'valid'`; | ||||
| 58 | + const variantFilter = opts.variantId ? sql`and variant_id = ${opts.variantId}` : sql``; | ||||
| 59 | + return rows(sql`select id, variant_id, source_id, source_url, sale_type, sale_date, price, currency, price_usd, buyer_premium_included, condition, grader, grade, certification_number, auction_house, lot_number, image_urls, raw_title, confidence, data_quality, status, flags | ||||
| 60 | + from sales where asset_id = ${assetId} ${statusFilter} ${variantFilter} order by sale_date desc limit ${opts.limit} offset ${opts.offset}`); | ||||
| 61 | +} | ||||
| 62 | + | ||||
| 63 | +export async function assetListings(assetId: string, opts: { limit: number; offset: number; availability?: string }) { | ||||
| 64 | + const avail = opts.availability ?? 'available'; | ||||
| 65 | + return rows(sql`select id, variant_id, source_id, source_url, listing_type, price, currency, price_usd, seller, location, shipping_cost, condition, grader, grade, certification_number, image_urls, raw_title, listed_at, ends_at, availability, bid_count, first_seen_at, last_seen_at, discount_to_riv, flags | ||||
| 66 | + from listings where asset_id = ${assetId} and availability = ${avail} order by price_usd asc nulls last limit ${opts.limit} offset ${opts.offset}`); | ||||
| 67 | +} | ||||
| 68 | + | ||||
| 69 | +export async function assetHistory(assetId: string, opts: { variantId?: string; from?: string; to?: string }) { | ||||
| 70 | + const variant = opts.variantId ?? ''; | ||||
| 71 | + const from = opts.from ?? '1900-01-01'; | ||||
| 72 | + const to = opts.to ?? '2999-12-31'; | ||||
| 73 | + return rows(sql`select date, riv_usd, latest_sale_usd, median_usd, sales_count, volume_usd, listings_count, min_ask_usd, observation_usd | ||||
| 74 | + from price_snapshots where asset_id = ${assetId} and variant_id = ${variant} and date between ${from} and ${to} order by date asc`); | ||||
| 75 | +} | ||||
| 76 | + | ||||
| 77 | +export async function listCategories() { | ||||
| 78 | + return rows(sql`select c.slug, c.parent_slug, c.family_slug, c.name, c.short_name, c.description, c.level, c.phase, c.active, c.index_ticker, c.condition_scale, c.graders, | ||||
| 79 | + (select count(*)::int from assets a where a.category_slug = c.slug) as tracked_assets | ||||
| 80 | + from categories c where c.active order by c.sort_order`); | ||||
| 81 | +} | ||||
| 82 | + | ||||
| 83 | +export async function listIndices() { | ||||
| 84 | + return rows(sql`with latest as ( | ||||
| 85 | + select distinct on (index_id) index_id, date, value, constituents_count, transactions, volume_usd, median_sale_usd, market_cap_est_usd, market_cap_confidence, liquidity_score, momentum, tracked_assets, coverage | ||||
| 86 | + from index_values order by index_id, date desc) | ||||
| 87 | + select i.id, i.ticker, i.name, i.description, i.parent_ticker, i.family_slugs, i.methodology, i.weighting, i.base_date, i.base_value, i.min_constituents, i.is_flagship, i.color, | ||||
| 88 | + l.date as as_of, l.value, l.constituents_count, l.transactions, l.volume_usd, l.median_sale_usd, l.market_cap_est_usd, l.market_cap_confidence, l.liquidity_score, l.momentum, l.tracked_assets, l.coverage, | ||||
| 89 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '1 day' order by date desc limit 1) as value_1d, | ||||
| 90 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '7 day' order by date desc limit 1) as value_7d, | ||||
| 91 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '30 day' order by date desc limit 1) as value_30d, | ||||
| 92 | + (select value from index_values v where v.index_id = i.id and v.date <= l.date - interval '365 day' order by date desc limit 1) as value_1y, | ||||
| 93 | + (select value from index_values v where v.index_id = i.id and v.date <= date_trunc('year', l.date)::date order by date desc limit 1) as value_ytd | ||||
| 94 | + from indices i left join latest l on l.index_id = i.id where i.active order by i.is_flagship desc, i.ticker`); | ||||
| 95 | +} | ||||
| 96 | + | ||||
| 97 | +export async function indexHistory(ticker: string, opts: { from?: string; to?: string }) { | ||||
| 98 | + const from = opts.from ?? '1900-01-01'; | ||||
| 99 | + const to = opts.to ?? '2999-12-31'; | ||||
| 100 | + return rows(sql`select v.date, v.value, v.constituents_count, v.transactions, v.volume_usd, v.median_sale_usd, v.avg_sale_usd, v.market_cap_est_usd, v.liquidity_score, v.momentum, v.coverage | ||||
| 101 | + from index_values v join indices i on i.id = v.index_id where i.ticker = ${ticker} and v.date between ${from} and ${to} order by v.date asc`); | ||||
| 102 | +} | ||||
| 103 | + | ||||
| 104 | +export async function listMarkets() { | ||||
| 105 | + return rows(sql`with latest as ( | ||||
| 106 | + select distinct on (category_slug) * from category_snapshots order by category_slug, date desc) | ||||
| 107 | + select c.slug, c.name, c.family_slug, c.level, c.index_ticker, l.date as as_of, l.index_value, l.tracked_assets, l.assets_with_valuation, l.sales, l.volume_usd, l.median_sale_usd, l.active_listings, l.market_cap_est_usd, l.liquidity_score, l.change_1d, l.change_7d, l.change_30d, l.change_1y, | ||||
| 108 | + (select count(*)::int from assets a where a.family_slug = c.slug or a.category_slug = c.slug) as assets_now | ||||
| 109 | + from categories c left join latest l on l.category_slug = c.slug where c.active and c.level = 0 order by c.sort_order`); | ||||
| 110 | +} | ||||
| 111 | + | ||||
| 112 | +export async function getMarket(slug: string) { | ||||
| 113 | + const [category] = await rows(sql`select slug, parent_slug, family_slug, name, description, level, phase, index_ticker, condition_scale, graders from categories where slug = ${slug}`); | ||||
| 114 | + if (!category) return null; | ||||
| 115 | + const [snapshot] = await rows(sql`select * from category_snapshots where category_slug = ${slug} order by date desc limit 1`); | ||||
| 116 | + const scope = sql`(a.category_slug = ${slug} or a.family_slug = ${slug})`; | ||||
| 117 | + const [counts] = await rows(sql`select count(*)::int as tracked_assets, count(s.riv_usd)::int as valued_assets, coalesce(sum(s.sales_count),0)::int as sales_total, coalesce(sum(s.active_listings),0)::int as active_listings from assets a left join asset_stats s on s.asset_id = a.id where ${scope}`); | ||||
| 118 | + const gainers = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.change_30d is not null and s.riv_sample_size >= 3 order by s.change_30d desc limit 10`); | ||||
| 119 | + const losers = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.change_30d is not null and s.riv_sample_size >= 3 order by s.change_30d asc limit 10`); | ||||
| 120 | + const mostValuable = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.riv_usd is not null order by s.riv_usd desc limit 10`); | ||||
| 121 | + const mostLiquid = await rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where ${scope} and s.liquidity_score is not null order by s.liquidity_score desc limit 10`); | ||||
| 122 | + const recentSales = await rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, sa.source_id, sa.source_url, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade from sales sa join assets a on a.id = sa.asset_id where ${scope} and sa.status = 'valid' order by sa.sale_date desc limit 20`); | ||||
| 123 | + const history = await rows(sql`select date, index_value, sales, volume_usd, median_sale_usd, active_listings, tracked_assets from category_snapshots where category_slug = ${slug} order by date asc`); | ||||
| 124 | + return { category, snapshot: snapshot ?? null, counts, gainers, losers, most_valuable: mostValuable, most_liquid: mostLiquid, recent_sales: recentSales, history }; | ||||
| 125 | +} | ||||
| 126 | + | ||||
| 127 | +export async function trending(opts: { category?: string; limit: number; offset: number }) { | ||||
| 128 | + const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``; | ||||
| 129 | + return rows(sql`select ${ASSET_COLUMNS} from assets a join asset_stats s on s.asset_id = a.id where s.trending_score is not null ${cat} order by s.trending_score desc limit ${opts.limit} offset ${opts.offset}`); | ||||
| 130 | +} | ||||
| 131 | + | ||||
| 132 | +export async function latestSales(opts: { category?: string; minUsd?: number; limit: number; offset: number }) { | ||||
| 133 | + const cat = opts.category ? sql`and (a.category_slug = ${opts.category} or a.family_slug = ${opts.category})` : sql``; | ||||
| 134 | + const min = opts.minUsd ? sql`and sa.price_usd >= ${opts.minUsd}` : sql``; | ||||
| 135 | + return rows(sql`select sa.id, sa.asset_id, a.slug as asset_slug, a.title, a.category_slug, a.hero_image_url, sa.source_id, sa.source_url, sa.sale_type, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.condition, sa.auction_house, sa.confidence | ||||
| 136 | + from sales sa join assets a on a.id = sa.asset_id where sa.status = 'valid' ${cat} ${min} order by sa.sale_date desc, sa.created_at desc limit ${opts.limit} offset ${opts.offset}`); | ||||
| 137 | +} | ||||
| 138 | + | ||||
| 139 | +/** Record sales: highest verified transaction per family (§154). */ | ||||
| 140 | +export async function recordSales() { | ||||
| 141 | + return rows(sql`select distinct on (a.family_slug) a.family_slug, c.name as family_name, sa.id as sale_id, sa.asset_id, a.slug as asset_slug, a.title, sa.source_id, sa.source_url, sa.sale_date, sa.price, sa.currency, sa.price_usd, sa.grader, sa.grade, sa.auction_house | ||||
| 142 | + from sales sa join assets a on a.id = sa.asset_id join categories c on c.slug = a.family_slug | ||||
| 143 | + where sa.status = 'valid' and sa.confidence >= 0.8 and sa.is_bundle = false | ||||
| 144 | + order by a.family_slug, sa.price_usd desc`); | ||||
| 145 | +} | ||||
| 146 | + | ||||
| 147 | +export async function platformStats() { | ||||
| 148 | + const [r] = await rows(sql`select | ||||
| 149 | + (select count(*)::int from assets) as assets, | ||||
| 150 | + (select count(*)::int from sales where status = 'valid') as sales, | ||||
| 151 | + (select count(*)::int from listings where availability = 'available') as listings, | ||||
| 152 | + (select count(*)::int from sources where active) as sources, | ||||
| 153 | + (select count(*)::int from connectors where status = 'active') as connectors, | ||||
| 154 | + (select count(*)::int from categories where active) as categories`); | ||||
| 155 | + return r; | ||||
| 156 | +} | ||||
added
apps/api/src/openapi.ts
+91 −0
@@ -0,0 +1,91 @@ | |||||
| 1 | +/** OpenAPI 3.1 document for the public API (§157). Served at /v1/openapi.json and written to docs/openapi.json by scripts/gen-openapi.ts. */ | ||||
| 2 | + | ||||
| 3 | +const paging = [ | ||||
| 4 | + { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }, | ||||
| 5 | + { name: 'cursor', in: 'query', schema: { type: 'string' }, description: 'Opaque cursor from meta.cursor' }, | ||||
| 6 | + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] }, description: 'csv returns a flat export of the rows' }, | ||||
| 7 | +]; | ||||
| 8 | +const range = [ | ||||
| 9 | + { name: 'from', in: 'query', schema: { type: 'string', format: 'date' } }, | ||||
| 10 | + { name: 'to', in: 'query', schema: { type: 'string', format: 'date' } }, | ||||
| 11 | + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } }, | ||||
| 12 | +]; | ||||
| 13 | +const envelopeOf = (ref: string, list = true) => ({ | ||||
| 14 | + type: 'object', | ||||
| 15 | + properties: { | ||||
| 16 | + data: list ? { type: 'array', items: { $ref: ref } } : { $ref: ref }, | ||||
| 17 | + meta: { $ref: '#/components/schemas/Meta' }, | ||||
| 18 | + }, | ||||
| 19 | +}); | ||||
| 20 | +const ok = (ref: string, list = true) => ({ 200: { description: 'OK', content: { 'application/json': { schema: envelopeOf(ref, list) } } } }); | ||||
| 21 | +const errors = { | ||||
| 22 | + 400: { description: 'Invalid query', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | ||||
| 23 | + 401: { description: 'Invalid API key', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | ||||
| 24 | + 404: { description: 'Not found', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | ||||
| 25 | + 429: { description: 'Rate limit or quota exceeded', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } }, | ||||
| 26 | +}; | ||||
| 27 | + | ||||
| 28 | +export function openapiDocument() { | ||||
| 29 | + return { | ||||
| 30 | + openapi: '3.1.0', | ||||
| 31 | + info: { | ||||
| 32 | + title: 'RareIndex Public API', | ||||
| 33 | + version: '1.0.0', | ||||
| 34 | + description: | ||||
| 35 | + 'Structured market data for collectible assets: canonical assets, observed sales, live listings, price history, category markets and the RareIndex indices. Valuations are estimates with confidence and sample size; listing prices are not confirmed transactions. Attribution to RareIndex and the original sources is required.', | ||||
| 36 | + contact: { name: 'RareIndex', url: 'https://www.rareindex.io/api-docs', email: 'api@rareindex.io' }, | ||||
| 37 | + termsOfService: 'https://www.rareindex.io/about#terms', | ||||
| 38 | + }, | ||||
| 39 | + servers: [{ url: 'https://www.rareindex.io/api', description: 'Production (proxied by the web app)' }, { url: 'http://localhost:8211', description: 'Local API service' }], | ||||
| 40 | + security: [{ bearerAuth: [] }, {}], | ||||
| 41 | + tags: [ | ||||
| 42 | + { name: 'assets' }, | ||||
| 43 | + { name: 'markets' }, | ||||
| 44 | + { name: 'indices' }, | ||||
| 45 | + { name: 'sales' }, | ||||
| 46 | + { name: 'reference' }, | ||||
| 47 | + ], | ||||
| 48 | + paths: { | ||||
| 49 | + '/v1/assets/search': { get: { tags: ['assets'], summary: 'Search canonical assets', parameters: [{ name: 'q', in: 'query', schema: { type: 'string' }, description: 'Natural language query, e.g. "1999 Charizard PSA 10"' }, { name: 'category', in: 'query', schema: { type: 'string' }, description: 'Category or family slug' }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } }, | ||||
| 50 | + '/v1/assets/{id}': { get: { tags: ['assets'], summary: 'Asset detail with variants, latest valuation and sources', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' }, description: 'Asset id (rare_…) or slug' }], responses: { ...ok('#/components/schemas/AssetDetail', false), ...errors } } }, | ||||
| 51 | + '/v1/assets/{id}/sales': { get: { tags: ['assets', 'sales'], summary: 'Observed sales for an asset', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, { name: 'include_flagged', in: 'query', schema: { type: 'boolean' } }, ...paging], responses: { ...ok('#/components/schemas/Sale'), ...errors } } }, | ||||
| 52 | + '/v1/assets/{id}/listings': { get: { tags: ['assets'], summary: 'Listings for an asset (asks, not transactions)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'availability', in: 'query', schema: { type: 'string', enum: ['available', 'sold', 'ended', 'removed'], default: 'available' } }, ...paging], responses: { ...ok('#/components/schemas/Listing'), ...errors } } }, | ||||
| 53 | + '/v1/assets/{id}/history': { get: { tags: ['assets'], summary: 'Daily price history (RIV, latest sale, median, volume, listings)', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'variant', in: 'query', schema: { type: 'string' } }, ...range], responses: { ...ok('#/components/schemas/PricePoint'), ...errors } } }, | ||||
| 54 | + '/v1/categories': { get: { tags: ['reference'], summary: 'Taxonomy', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Category'), ...errors } } }, | ||||
| 55 | + '/v1/indices': { get: { tags: ['indices'], summary: 'RARE and subindices with latest values and changes', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Index'), ...errors } } }, | ||||
| 56 | + '/v1/indices/{ticker}/history': { get: { tags: ['indices'], summary: 'Index daily history', parameters: [{ name: 'ticker', in: 'path', required: true, schema: { type: 'string' }, example: 'RARE-TCG' }, ...range], responses: { ...ok('#/components/schemas/IndexPoint'), ...errors } } }, | ||||
| 57 | + '/v1/markets': { get: { tags: ['markets'], summary: 'Category markets overview (latest snapshot per family)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/Market'), ...errors } } }, | ||||
| 58 | + '/v1/markets/{slug}': { get: { tags: ['markets'], summary: 'Category market detail: movers, most valuable, most liquid, recent sales, history', parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' }, example: 'pokemon' }], responses: { ...ok('#/components/schemas/MarketDetail', false), ...errors } } }, | ||||
| 59 | + '/v1/trending': { get: { tags: ['markets'], summary: 'Trending assets', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, ...paging], responses: { ...ok('#/components/schemas/AssetSummary'), ...errors } } }, | ||||
| 60 | + '/v1/sales/latest': { get: { tags: ['sales'], summary: 'Latest observed sales across the platform', parameters: [{ name: 'category', in: 'query', schema: { type: 'string' } }, { name: 'min_usd', in: 'query', schema: { type: 'number' } }, ...paging], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } }, | ||||
| 61 | + '/v1/records': { get: { tags: ['sales'], summary: 'Record sale per family (verified transactions only)', parameters: [paging[2]!], responses: { ...ok('#/components/schemas/SaleWithAsset'), ...errors } } }, | ||||
| 62 | + '/v1/stats': { get: { tags: ['reference'], summary: 'Platform counts', responses: { ...ok('#/components/schemas/Stats', false) } } }, | ||||
| 63 | + }, | ||||
| 64 | + components: { | ||||
| 65 | + securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', description: 'API key `ri_live_…`. Requests without a key use the public tier (20 req/min).' } }, | ||||
| 66 | + schemas: { | ||||
| 67 | + Meta: { type: 'object', properties: { count: { type: 'integer' }, cursor: { type: ['string', 'null'] }, as_of: { type: 'string', format: 'date-time' }, attribution: { type: 'string' } }, additionalProperties: true }, | ||||
| 68 | + Problem: { type: 'object', properties: { type: { type: 'string' }, title: { type: 'string' }, status: { type: 'integer' }, detail: { type: 'string' }, instance: { type: 'string' }, request_id: { type: 'string' } } }, | ||||
| 69 | + AssetSummary: { | ||||
| 70 | + type: 'object', | ||||
| 71 | + properties: { | ||||
| 72 | + id: { type: 'string' }, slug: { type: 'string' }, title: { type: 'string' }, name: { type: 'string' }, category_slug: { type: 'string' }, family_slug: { type: 'string' }, brand: { type: ['string', 'null'] }, franchise: { type: ['string', 'null'] }, set_name: { type: ['string', 'null'] }, number: { type: ['string', 'null'] }, year: { type: ['integer', 'null'] }, variant: { type: ['string', 'null'] }, hero_image_url: { type: ['string', 'null'] }, | ||||
| 73 | + riv_usd: { type: ['number', 'null'], description: 'RareIndex Valuation (estimate)' }, riv_low_usd: { type: ['number', 'null'] }, riv_high_usd: { type: ['number', 'null'] }, riv_confidence: { type: ['number', 'null'] }, riv_sample_size: { type: 'integer' }, | ||||
| 74 | + latest_sale_usd: { type: ['number', 'null'] }, latest_sale_at: { type: ['string', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, sales_count: { type: 'integer' }, sales_30d: { type: 'integer' }, active_listings: { type: 'integer' }, min_ask_usd: { type: ['number', 'null'] }, liquidity_score: { type: ['number', 'null'] }, rarity_score: { type: ['number', 'null'] }, trending_score: { type: ['number', 'null'] }, | ||||
| 75 | + }, | ||||
| 76 | + }, | ||||
| 77 | + AssetDetail: { allOf: [{ $ref: '#/components/schemas/AssetSummary' }, { type: 'object', properties: { description: { type: ['string', 'null'] }, identifiers: { type: 'object' }, variants: { type: 'array', items: { type: 'object' } }, valuation: { type: ['object', 'null'] }, sources: { type: 'array', items: { type: 'object' } } } }] }, | ||||
| 78 | + Sale: { type: 'object', properties: { id: { type: 'string' }, source_id: { type: 'string' }, source_url: { type: 'string' }, sale_type: { type: 'string' }, sale_date: { type: 'string' }, price: { type: 'number' }, currency: { type: 'string' }, price_usd: { type: 'number' }, grader: { type: ['string', 'null'] }, grade: { type: ['string', 'null'] }, condition: { type: ['string', 'null'] }, status: { type: 'string' }, confidence: { type: 'number' } } }, | ||||
| 79 | + SaleWithAsset: { allOf: [{ $ref: '#/components/schemas/Sale' }, { type: 'object', properties: { asset_id: { type: 'string' }, asset_slug: { type: 'string' }, title: { type: 'string' } } }] }, | ||||
| 80 | + Listing: { type: 'object', properties: { id: { type: 'string' }, source_id: { type: 'string' }, source_url: { type: 'string' }, listing_type: { type: 'string' }, price: { type: ['number', 'null'] }, currency: { type: ['string', 'null'] }, price_usd: { type: ['number', 'null'] }, availability: { type: 'string' }, discount_to_riv: { type: ['number', 'null'] } } }, | ||||
| 81 | + PricePoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, riv_usd: { type: ['number', 'null'] }, latest_sale_usd: { type: ['number', 'null'] }, median_usd: { type: ['number', 'null'] }, sales_count: { type: 'integer' }, volume_usd: { type: ['number', 'null'] }, listings_count: { type: 'integer' }, min_ask_usd: { type: ['number', 'null'] } } }, | ||||
| 82 | + Category: { type: 'object', properties: { slug: { type: 'string' }, parent_slug: { type: ['string', 'null'] }, family_slug: { type: 'string' }, name: { type: 'string' }, level: { type: 'integer' }, phase: { type: 'integer' }, index_ticker: { type: ['string', 'null'] }, tracked_assets: { type: 'integer' } } }, | ||||
| 83 | + Index: { type: 'object', properties: { ticker: { type: 'string' }, name: { type: 'string' }, is_flagship: { type: 'boolean' }, as_of: { type: ['string', 'null'] }, value: { type: ['number', 'null'] }, published: { type: 'boolean' }, change_1d: { type: ['number', 'null'] }, change_7d: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] }, change_ytd: { type: ['number', 'null'] }, change_1y: { type: ['number', 'null'] }, constituents_count: { type: ['integer', 'null'] }, transactions: { type: ['integer', 'null'] }, market_cap_est_usd: { type: ['number', 'null'] }, market_cap_confidence: { type: ['string', 'null'] } } }, | ||||
| 84 | + IndexPoint: { type: 'object', properties: { date: { type: 'string', format: 'date' }, value: { type: 'number' }, constituents_count: { type: 'integer' }, transactions: { type: 'integer' }, volume_usd: { type: ['number', 'null'] } } }, | ||||
| 85 | + Market: { type: 'object', properties: { slug: { type: 'string' }, name: { type: 'string' }, as_of: { type: ['string', 'null'] }, index_value: { type: ['number', 'null'] }, tracked_assets: { type: ['integer', 'null'] }, sales: { type: ['integer', 'null'] }, volume_usd: { type: ['number', 'null'] }, change_30d: { type: ['number', 'null'] } } }, | ||||
| 86 | + MarketDetail: { type: 'object', properties: { category: { type: 'object' }, snapshot: { type: ['object', 'null'] }, counts: { type: 'object' }, gainers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, losers: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_valuable: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, most_liquid: { type: 'array', items: { $ref: '#/components/schemas/AssetSummary' } }, recent_sales: { type: 'array', items: { type: 'object' } }, history: { type: 'array', items: { type: 'object' } } } }, | ||||
| 87 | + Stats: { type: 'object', properties: { assets: { type: 'integer' }, sales: { type: 'integer' }, listings: { type: 'integer' }, sources: { type: 'integer' }, connectors: { type: 'integer' }, categories: { type: 'integer' } } }, | ||||
| 88 | + }, | ||||
| 89 | + }, | ||||
| 90 | + }; | ||||
| 91 | +} | ||||
added
apps/api/src/plugins/auth.ts
+69 −0
@@ -0,0 +1,69 @@ | |||||
| 1 | +import type { FastifyInstance, FastifyRequest } from 'fastify'; | ||||
| 2 | +import fp from 'fastify-plugin'; | ||||
| 3 | +import { getDb, apiKeys, apiUsage, eq, sql } from '@rareindex/database'; | ||||
| 4 | +import { parseBearer, resolveApiKey, TIERS, type ResolvedKey } from '../lib/keys.js'; | ||||
| 5 | +import { problem } from '../lib/envelope.js'; | ||||
| 6 | + | ||||
| 7 | +declare module 'fastify' { | ||||
| 8 | + interface FastifyRequest { | ||||
| 9 | + apiKey: ResolvedKey | null; | ||||
| 10 | + } | ||||
| 11 | +} | ||||
| 12 | + | ||||
| 13 | +/** | ||||
| 14 | + * Bearer API-key authentication. Anonymous requests are allowed on the "public" tier (low limits) | ||||
| 15 | + * so the docs playground and casual use work; keyed requests get their tier's limits and usage counters. | ||||
| 16 | + */ | ||||
| 17 | +export const authPlugin = fp(async (app: FastifyInstance) => { | ||||
| 18 | + app.decorateRequest('apiKey', null); | ||||
| 19 | + const cache = new Map<string, { key: ResolvedKey | null; at: number }>(); | ||||
| 20 | + | ||||
| 21 | + app.addHook('onRequest', async (req, reply) => { | ||||
| 22 | + const bearer = parseBearer(req.headers.authorization); | ||||
| 23 | + if (!bearer) { | ||||
| 24 | + req.apiKey = null; | ||||
| 25 | + return; | ||||
| 26 | + } | ||||
| 27 | + const hit = cache.get(bearer); | ||||
| 28 | + let resolved: ResolvedKey | null; | ||||
| 29 | + if (hit && Date.now() - hit.at < 60_000) resolved = hit.key; | ||||
| 30 | + else { | ||||
| 31 | + resolved = await resolveApiKey(bearer); | ||||
| 32 | + cache.set(bearer, { key: resolved, at: Date.now() }); | ||||
| 33 | + if (cache.size > 5000) cache.clear(); | ||||
| 34 | + } | ||||
| 35 | + if (!resolved) return problem(reply, 401, 'Invalid API key', 'The provided API key is unknown or revoked.'); | ||||
| 36 | + req.apiKey = resolved; | ||||
| 37 | + // daily quota | ||||
| 38 | + const today = new Date().toISOString().slice(0, 10); | ||||
| 39 | + const [row] = await getDb().execute(sql`select coalesce(sum(count),0)::int as used from api_usage where key_id = ${resolved.id} and date = ${today}`); | ||||
| 40 | + const used = Number((row as { used?: number } | undefined)?.used ?? 0); | ||||
| 41 | + if (used >= resolved.dailyQuota) return problem(reply, 429, 'Daily quota exceeded', `Tier ${resolved.tier} allows ${resolved.dailyQuota} requests per day.`, { quota: resolved.dailyQuota, used }); | ||||
| 42 | + }); | ||||
| 43 | + | ||||
| 44 | + app.addHook('onResponse', async (req, reply) => { | ||||
| 45 | + if (!req.apiKey || !req.routeOptions.url?.startsWith('/v1')) return; | ||||
| 46 | + const today = new Date().toISOString().slice(0, 10); | ||||
| 47 | + const endpoint = req.routeOptions.url; | ||||
| 48 | + const latency = reply.elapsedTime; | ||||
| 49 | + try { | ||||
| 50 | + await getDb() | ||||
| 51 | + .insert(apiUsage) | ||||
| 52 | + .values({ keyId: req.apiKey.id, date: today, endpoint, count: 1, latencyMsAvg: latency }) | ||||
| 53 | + .onConflictDoUpdate({ | ||||
| 54 | + target: [apiUsage.keyId, apiUsage.date, apiUsage.endpoint], | ||||
| 55 | + set: { count: sql`${apiUsage.count} + 1`, latencyMsAvg: sql`(${apiUsage.latencyMsAvg} * ${apiUsage.count} + ${latency}) / (${apiUsage.count} + 1)` }, | ||||
| 56 | + }); | ||||
| 57 | + await getDb().update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, req.apiKey.id)); | ||||
| 58 | + } catch (err) { | ||||
| 59 | + req.log.warn({ err }, 'api usage counter failed'); | ||||
| 60 | + } | ||||
| 61 | + }); | ||||
| 62 | +}); | ||||
| 63 | + | ||||
| 64 | +export function rateLimitFor(req: FastifyRequest): number { | ||||
| 65 | + return req.apiKey?.rateLimitPerMinute ?? TIERS.public.rateLimitPerMinute; | ||||
| 66 | +} | ||||
| 67 | +export function rateKeyFor(req: FastifyRequest): string { | ||||
| 68 | + return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`; | ||||
| 69 | +} | ||||
added
apps/api/src/routes/internal.ts
+42 −0
@@ -0,0 +1,42 @@ | |||||
| 1 | +import type { FastifyInstance } from 'fastify'; | ||||
| 2 | +import { z } from 'zod'; | ||||
| 3 | +import { createHmac } from 'node:crypto'; | ||||
| 4 | +import { connectorsForUrl, createRouter, createCrawlContext, listConnectorMeta } from '@rareindex/connectors'; | ||||
| 5 | +import type { NormalizedRecord } from '@rareindex/shared'; | ||||
| 6 | +import { problem } from '../lib/envelope.js'; | ||||
| 7 | + | ||||
| 8 | +/** | ||||
| 9 | + * Internal (loopback-only) endpoints used by the web app. Connector modules are loaded dynamically | ||||
| 10 | + * (`import(path)`), which the Next.js bundler cannot do — so URL lookups run here, where tsx can. | ||||
| 11 | + * Auth: `x-internal-token` = HMAC(SESSION_SECRET, "internal") — both processes share SESSION_SECRET. | ||||
| 12 | + */ | ||||
| 13 | +export function internalToken(secret = process.env.SESSION_SECRET ?? 'dev-only'): string { | ||||
| 14 | + return createHmac('sha256', secret).update('internal').digest('hex'); | ||||
| 15 | +} | ||||
| 16 | + | ||||
| 17 | +export async function internalRoutes(app: FastifyInstance) { | ||||
| 18 | + app.addHook('onRequest', async (req, reply) => { | ||||
| 19 | + if (!req.url.startsWith('/internal/')) return; | ||||
| 20 | + const loopback = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1'; | ||||
| 21 | + const ok = loopback && req.headers['x-internal-token'] === internalToken(); | ||||
| 22 | + if (!ok) return problem(reply, 403, 'Forbidden', 'Internal endpoint'); | ||||
| 23 | + }); | ||||
| 24 | + | ||||
| 25 | + app.get('/internal/lookup/sources', async () => ({ data: listConnectorMeta({ enabled: true }).filter((m) => m.supportsLookup).map((m) => ({ id: m.id, sourceName: m.sourceName, sourceUrl: m.sourceUrl })) })); | ||||
| 26 | + | ||||
| 27 | + app.post('/internal/lookup', async (req, reply) => { | ||||
| 28 | + const body = z.object({ url: z.string().url().max(2000) }).safeParse(req.body); | ||||
| 29 | + if (!body.success) return problem(reply, 400, 'Invalid body', 'url required'); | ||||
| 30 | + const url = body.data.url; | ||||
| 31 | + const connectors = await connectorsForUrl(url); | ||||
| 32 | + const connector = connectors[0]; | ||||
| 33 | + if (!connector?.lookup) return { data: null, meta: { supported: false } }; | ||||
| 34 | + const router = createRouter({ firecrawlApiKey: process.env.FIRECRAWL_API_KEY, scrapflyApiKey: process.env.SCRAPFLY_API_KEY }); | ||||
| 35 | + const ctx = createCrawlContext({ router, meta: connector.meta, options: { mode: 'probe', limit: 1 } }); | ||||
| 36 | + const started = Date.now(); | ||||
| 37 | + const raws = await connector.lookup(url, ctx); | ||||
| 38 | + const records: NormalizedRecord[] = []; | ||||
| 39 | + for (const raw of raws) records.push(...(await connector.normalize({ ...raw, externalId: raw.externalId ?? null, fetchedAt: raw.fetchedAt ?? new Date() }))); | ||||
| 40 | + return { data: { connectorId: connector.meta.id, sourceId: connector.meta.sourceId, records }, meta: { supported: true, durationMs: Date.now() - started, engineStats: ctx.engineStats } }; | ||||
| 41 | + }); | ||||
| 42 | +} | ||||
added
apps/api/src/routes/v1.ts
+129 −0
@@ -0,0 +1,129 @@ | |||||
| 1 | +import type { FastifyInstance } from 'fastify'; | ||||
| 2 | +import { z } from 'zod'; | ||||
| 3 | +import { decodeCursor, encodeCursor, envelope, problem, sendList } from '../lib/envelope.js'; | ||||
| 4 | +import * as q from '../lib/queries.js'; | ||||
| 5 | +import { openapiDocument } from '../openapi.js'; | ||||
| 6 | + | ||||
| 7 | +const Paging = z.object({ limit: z.coerce.number().int().min(1).max(200).default(50), cursor: z.string().optional(), format: z.enum(['json', 'csv']).optional() }); | ||||
| 8 | +const Range = z.object({ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), format: z.enum(['json', 'csv']).optional() }); | ||||
| 9 | + | ||||
| 10 | +function parse<T>(schema: z.ZodType<T>, input: unknown): { ok: true; data: T } | { ok: false; error: string } { | ||||
| 11 | + const r = schema.safeParse(input); | ||||
| 12 | + return r.success ? { ok: true, data: r.data } : { ok: false, error: r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ') }; | ||||
| 13 | +} | ||||
| 14 | + | ||||
| 15 | +export async function v1Routes(app: FastifyInstance) { | ||||
| 16 | + app.get('/v1/openapi.json', async () => openapiDocument()); | ||||
| 17 | + | ||||
| 18 | + app.get('/v1/stats', async () => envelope(await q.platformStats())); | ||||
| 19 | + | ||||
| 20 | + app.get('/v1/assets/search', async (req, reply) => { | ||||
| 21 | + const p = parse(Paging.extend({ q: z.string().max(200).optional(), category: z.string().max(80).optional() }), req.query); | ||||
| 22 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 23 | + const offset = decodeCursor(p.data.cursor); | ||||
| 24 | + const rows = await q.searchAssets({ q: p.data.q, category: p.data.category, limit: p.data.limit + 1, offset }); | ||||
| 25 | + const page = rows.slice(0, p.data.limit); | ||||
| 26 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, query: p.data.q ?? null }, req.query as Record<string, unknown>); | ||||
| 27 | + }); | ||||
| 28 | + | ||||
| 29 | + app.get<{ Params: { id: string } }>('/v1/assets/:id', async (req, reply) => { | ||||
| 30 | + const asset = await q.getAsset(req.params.id); | ||||
| 31 | + if (!asset) return problem(reply, 404, 'Asset not found'); | ||||
| 32 | + reply.header('cache-control', 'public, max-age=60, s-maxage=300'); | ||||
| 33 | + return envelope(asset); | ||||
| 34 | + }); | ||||
| 35 | + | ||||
| 36 | + app.get<{ Params: { id: string } }>('/v1/assets/:id/sales', async (req, reply) => { | ||||
| 37 | + const p = parse(Paging.extend({ variant: z.string().optional(), include_flagged: z.coerce.boolean().optional() }), req.query); | ||||
| 38 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 39 | + const asset = await q.getAsset(req.params.id); | ||||
| 40 | + if (!asset) return problem(reply, 404, 'Asset not found'); | ||||
| 41 | + const offset = decodeCursor(p.data.cursor); | ||||
| 42 | + const rows = await q.assetSales(asset.id as string, { limit: p.data.limit + 1, offset, variantId: p.data.variant, includeFlagged: p.data.include_flagged }); | ||||
| 43 | + const page = rows.slice(0, p.data.limit); | ||||
| 44 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id }, req.query as Record<string, unknown>); | ||||
| 45 | + }); | ||||
| 46 | + | ||||
| 47 | + app.get<{ Params: { id: string } }>('/v1/assets/:id/listings', async (req, reply) => { | ||||
| 48 | + const p = parse(Paging.extend({ availability: z.enum(['available', 'sold', 'ended', 'removed']).optional() }), req.query); | ||||
| 49 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 50 | + const asset = await q.getAsset(req.params.id); | ||||
| 51 | + if (!asset) return problem(reply, 404, 'Asset not found'); | ||||
| 52 | + const offset = decodeCursor(p.data.cursor); | ||||
| 53 | + const rows = await q.assetListings(asset.id as string, { limit: p.data.limit + 1, offset, availability: p.data.availability }); | ||||
| 54 | + const page = rows.slice(0, p.data.limit); | ||||
| 55 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null, asset_id: asset.id, note: 'Listing prices are asks, not confirmed transactions.' }, req.query as Record<string, unknown>); | ||||
| 56 | + }); | ||||
| 57 | + | ||||
| 58 | + app.get<{ Params: { id: string } }>('/v1/assets/:id/history', async (req, reply) => { | ||||
| 59 | + const p = parse(Range.extend({ variant: z.string().optional() }), req.query); | ||||
| 60 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 61 | + const asset = await q.getAsset(req.params.id); | ||||
| 62 | + if (!asset) return problem(reply, 404, 'Asset not found'); | ||||
| 63 | + const rows = await q.assetHistory(asset.id as string, { variantId: p.data.variant, from: p.data.from, to: p.data.to }); | ||||
| 64 | + reply.header('cache-control', 'public, max-age=300'); | ||||
| 65 | + return sendList(reply, rows, { count: rows.length, asset_id: asset.id, variant_id: p.data.variant ?? null }, req.query as Record<string, unknown>); | ||||
| 66 | + }); | ||||
| 67 | + | ||||
| 68 | + app.get('/v1/categories', async (req, reply) => { | ||||
| 69 | + const rows = await q.listCategories(); | ||||
| 70 | + reply.header('cache-control', 'public, max-age=3600'); | ||||
| 71 | + return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>); | ||||
| 72 | + }); | ||||
| 73 | + | ||||
| 74 | + app.get('/v1/indices', async (req, reply) => { | ||||
| 75 | + const rows = await q.listIndices(); | ||||
| 76 | + const shaped = rows.map((r) => { | ||||
| 77 | + const v = r.value as number | null; | ||||
| 78 | + const pct = (prev: unknown) => (v !== null && typeof prev === 'number' && prev > 0 ? (v - prev) / prev : null); | ||||
| 79 | + return { ...r, change_1d: pct(r.value_1d), change_7d: pct(r.value_7d), change_30d: pct(r.value_30d), change_1y: pct(r.value_1y), change_ytd: pct(r.value_ytd), published: v !== null }; | ||||
| 80 | + }); | ||||
| 81 | + reply.header('cache-control', 'public, max-age=300'); | ||||
| 82 | + return sendList(reply, shaped, { count: shaped.length, note: 'An index is published only once it has enough priced constituents; unpublished indices report value=null.' }, req.query as Record<string, unknown>); | ||||
| 83 | + }); | ||||
| 84 | + | ||||
| 85 | + app.get<{ Params: { ticker: string } }>('/v1/indices/:ticker/history', async (req, reply) => { | ||||
| 86 | + const p = parse(Range, req.query); | ||||
| 87 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 88 | + const rows = await q.indexHistory(req.params.ticker.toUpperCase(), p.data); | ||||
| 89 | + reply.header('cache-control', 'public, max-age=300'); | ||||
| 90 | + return sendList(reply, rows, { count: rows.length, ticker: req.params.ticker.toUpperCase() }, req.query as Record<string, unknown>); | ||||
| 91 | + }); | ||||
| 92 | + | ||||
| 93 | + app.get('/v1/markets', async (req, reply) => { | ||||
| 94 | + const rows = await q.listMarkets(); | ||||
| 95 | + reply.header('cache-control', 'public, max-age=300'); | ||||
| 96 | + return sendList(reply, rows, { count: rows.length }, req.query as Record<string, unknown>); | ||||
| 97 | + }); | ||||
| 98 | + | ||||
| 99 | + app.get<{ Params: { slug: string } }>('/v1/markets/:slug', async (req, reply) => { | ||||
| 100 | + const m = await q.getMarket(req.params.slug); | ||||
| 101 | + if (!m) return problem(reply, 404, 'Market not found'); | ||||
| 102 | + reply.header('cache-control', 'public, max-age=300'); | ||||
| 103 | + return envelope(m); | ||||
| 104 | + }); | ||||
| 105 | + | ||||
| 106 | + app.get('/v1/trending', async (req, reply) => { | ||||
| 107 | + const p = parse(Paging.extend({ category: z.string().optional() }), req.query); | ||||
| 108 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 109 | + const offset = decodeCursor(p.data.cursor); | ||||
| 110 | + const rows = await q.trending({ category: p.data.category, limit: p.data.limit + 1, offset }); | ||||
| 111 | + const page = rows.slice(0, p.data.limit); | ||||
| 112 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>); | ||||
| 113 | + }); | ||||
| 114 | + | ||||
| 115 | + app.get('/v1/sales/latest', async (req, reply) => { | ||||
| 116 | + const p = parse(Paging.extend({ category: z.string().optional(), min_usd: z.coerce.number().nonnegative().optional() }), req.query); | ||||
| 117 | + if (!p.ok) return problem(reply, 400, 'Invalid query', p.error); | ||||
| 118 | + const offset = decodeCursor(p.data.cursor); | ||||
| 119 | + const rows = await q.latestSales({ category: p.data.category, minUsd: p.data.min_usd, limit: p.data.limit + 1, offset }); | ||||
| 120 | + const page = rows.slice(0, p.data.limit); | ||||
| 121 | + return sendList(reply, page, { count: page.length, cursor: rows.length > p.data.limit ? encodeCursor(offset + p.data.limit) : null }, req.query as Record<string, unknown>); | ||||
| 122 | + }); | ||||
| 123 | + | ||||
| 124 | + app.get('/v1/records', async (req, reply) => { | ||||
| 125 | + const rows = await q.recordSales(); | ||||
| 126 | + reply.header('cache-control', 'public, max-age=600'); | ||||
| 127 | + return sendList(reply, rows, { count: rows.length, note: 'Highest verified transaction per family; bundles and low-confidence records excluded.' }, req.query as Record<string, unknown>); | ||||
| 128 | + }); | ||||
| 129 | +} | ||||
added
apps/api/src/server.ts
+20 −0
@@ -0,0 +1,20 @@ | |||||
| 1 | +import { buildApp } from './app.js'; | ||||
| 2 | + | ||||
| 3 | +const port = Number(process.env.API_PORT ?? 8211); | ||||
| 4 | +const host = process.env.API_HOST ?? '127.0.0.1'; | ||||
| 5 | + | ||||
| 6 | +const app = await buildApp(); | ||||
| 7 | +try { | ||||
| 8 | + await app.listen({ port, host }); | ||||
| 9 | + app.log.info({ port, host }, 'rareindex-api listening'); | ||||
| 10 | +} catch (err) { | ||||
| 11 | + app.log.error(err); | ||||
| 12 | + process.exit(1); | ||||
| 13 | +} | ||||
| 14 | + | ||||
| 15 | +for (const sig of ['SIGINT', 'SIGTERM'] as const) { | ||||
| 16 | + process.on(sig, async () => { | ||||
| 17 | + await app.close(); | ||||
| 18 | + process.exit(0); | ||||
| 19 | + }); | ||||
| 20 | +} | ||||
added
apps/api/tsconfig.json
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +{ | ||||
| 2 | + "extends": "../../tsconfig.base.json", | ||||
| 3 | + "compilerOptions": { | ||||
| 4 | + "rootDir": ".", | ||||
| 5 | + "noEmit": true | ||||
| 6 | + }, | ||||
| 7 | + "include": ["src", "scripts"] | ||||
| 8 | +} | ||||
added
apps/api/vitest.config.ts
+5 −0
@@ -0,0 +1,5 @@ | |||||
| 1 | +import { defineConfig } from 'vitest/config'; | ||||
| 2 | + | ||||
| 3 | +export default defineConfig({ | ||||
| 4 | + test: { include: ['src/**/*.test.ts'], testTimeout: 30_000 }, | ||||
| 5 | +}); | ||||
modified
apps/web/next.config.ts
+7 −2
@@ -4,8 +4,13 @@ const nextConfig: NextConfig = { | |||||
| 4 | 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