diff --git a/CLAUDE.md b/CLAUDE.md index e643b68..bc20921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,10 +254,9 @@ SDK's CJS build, which requires ESM-only `@noble/hashes`, so the cron route thro `ERR_REQUIRE_ESM` at import time on any runtime without `require(esm)` and the indexer stops silently (`lastRunStatus` stays `ok`, `staleMinutes` climbs). It must stay **bundled**; full incident and the verification recipe are in `architecture/`. The API lives as Next.js Route Handlers -(`/api/v1/protocols`, `/api/v1/coverage`, `/api/v1/protocol/[id]`, `/api/v1/health` — versioned; -there are **no** -unversioned paths, the -former transitional aliases were removed and now 404, and the versioning policy lives in +(`/api/v1/protocols`, `/api/v1/protocols/export`, `/api/v1/coverage`, +`/api/v1/protocol/[id]`, `/api/v1/health` — versioned; there are **no** +unversioned paths, the former transitional aliases were removed and now 404, and the versioning policy lives in `architecture/`); the dashboard's own pages read `@stenion/db`'s `Store` in-process (no HTTP hop). The indexer is triggered by a secret-gated cron route (`POST /api/cron/run-indexer`), which an external cron-job.org job POSTs to every 5 minutes with @@ -274,7 +273,7 @@ kept but not deployed. Env vars: `DATABASE_URL` (Neon pooled), `STENION_RPC_URL` alerts; unset = off), `STENION_RATE_LIMIT_SALT`, and the retry/threshold, rate-limit and health-threshold knobs, which all have defaults. Every variable the repo understands is documented in `.env.example`. -All four `/v1` read routes are rate limited. Three are CDN-cached; `/v1/health` deliberately is +All five `/v1` read routes are rate limited. Four are CDN-cached; `/v1/health` deliberately is **not** (`no-store`) — staleness there advances with the wall clock, so any TTL can serve a `healthy` 200 after the true answer became `degraded`. The cron route is **neither**, and must stay that way — rate limiting an authenticated internal trigger can only block a scheduled run. diff --git a/README.md b/README.md index 05e4ae0..14047aa 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,9 @@ pnpm --filter @stenion/dashboard dev # http://localhost:3000 The dashboard reads Postgres in-process, so once step 4 has landed at least one row you'll see real scores at `http://localhost:3000`. The public API is served by the dashboard at -`/api/v1/protocols`, `/api/v1/coverage`, `/api/v1/protocol/:id`, and `/api/v1/health` — versioned, -with the policy in [`architecture/deploy-architecture.md`](architecture/deploy-architecture.md#api-versioning). +`/api/v1/protocols`, `/api/v1/protocols/export`, `/api/v1/coverage`, `/api/v1/protocol/:id`, and +`/api/v1/health` — versioned, with the policy in +[`architecture/deploy-architecture.md`](architecture/deploy-architecture.md#api-versioning). ### Using the public API diff --git a/api-docs/conventions.md b/api-docs/conventions.md index 7353c1a..d6d10d1 100644 --- a/api-docs/conventions.md +++ b/api-docs/conventions.md @@ -26,14 +26,15 @@ hide it. The current version is `1`. ## Caching -All three `/v1` read routes are served through a CDN. The two scored routes — `/v1/protocols` and -`/v1/protocol/:id` — use a TTL computed per response from the data in the body rather than a fixed -constant. The reason is directly relevant to you: a fixed TTL would serve a body claiming "the last -run succeeded at T" for some seconds after a later run had already failed — the cache would be lying -in exactly the field that exists to stop us lying about freshness. - -**The guarantee, on those two routes: a cached response can hide a newer indexer run by at most 10 -seconds.** +All cacheable `/v1` read routes are served through a CDN. The scored current-state routes — +`/v1/protocols`, `/v1/protocols/export`, and `/v1/protocol/:id` — use a TTL computed per response +from the data in the body rather than a fixed constant. The reason is directly relevant to you: a +fixed TTL would serve a body claiming "the last run succeeded at T" for some seconds after a later +run had already failed — the cache would be lying in exactly the field that exists to stop us lying +about freshness. + +**The guarantee, on those three routes: a cached response can hide a newer indexer run by at most +10 seconds.** `GET /api/v1/coverage` is the deliberate exception, and says so rather than quietly differing. Its body carries no `lastRunAt` — there is no run behind a coverage decision — and its records change diff --git a/api-docs/export.md b/api-docs/export.md new file mode 100644 index 0000000..7256f6e --- /dev/null +++ b/api-docs/export.md @@ -0,0 +1,121 @@ +## GET /api/v1/protocols/export + +A current snapshot export of the scored registry state, downloadable as JSON or CSV. + +This endpoint is intentionally non-historical. It returns one row or object per currently scored +protocol/market, using the same persisted latest-successful score semantics as +[`GET /api/v1/protocols`](protocols.md): `safetyScore`, `computedAt`, `factors`, +`methodologyVersion`, and `operationalState` come from the latest `ok` run, while `lastRunAt` and +`lastRunStatus` describe the newest run of any status. If the latest attempted run failed, the last +successful score remains the exported current score and `lastRunStatus` exposes the failure. A +protocol that has never produced a successful score is excluded from this scored snapshot rather +than exported with nulls or zeros. + +Scores are comparable only within one `category`. Export consumers should not rank `lending` and +`dex` rows against each other, and should tolerate new categories and factor keys. + +> This endpoint was added after the captured production examples at the top of this document. No +> live response body is included here until it can be captured from a deployed API response; the +> contract below documents the shape without fabricating sample data. + +**Request** + +```bash +curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=json" +curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=csv" +``` + +| Query parameter | Values | Notes | +| --------------- | --------------- | ----------------------------------------------- | +| `format` | `json` or `csv` | Optional. Defaults to `json`. Other values 400. | + +**Successful responses** + +| Format | `Content-Type` | `Content-Disposition` filename | +| ------ | --------------------------------- | ------------------------------- | +| JSON | `application/json; charset=utf-8` | `stenion-registry-current.json` | +| CSV | `text/csv; charset=utf-8` | `stenion-registry-current.csv` | + +Successful responses use the same freshness-aware public cache policy as `/api/v1/protocols`: the +shared-cache TTL is derived from exported rows' `lastRunAt` values, so caching does not hide a newer +run for longer than the documented floor. + +### JSON shape + +JSON preserves each factor map as structured JSON: + +```ts +{ + protocols: Array<{ + id: string; + name: string; + chain: string; + category: string; + logo: string | null; + deployedOn: { host: string; label: string } | null; + safetyScore: number; + computedAt: string; + methodologyVersion: number; + factors: { + [factorKey: string]: { + value: number; + weight: number; + detail: string; + components?: Array<{ id: string; label: string; value: number | null; detail: string }>; + } | null; + }; + operationalState: { + level: string; + asOf: string; + origin: 'admin' | 'protocol' | 'indeterminate'; + source: string; + detail: string; + blocked: string[]; + } | null; + lastRunAt: string | null; + lastRunStatus: 'ok' | 'failed' | null; + }>; +} +``` + +### CSV shape + +CSV has exactly one data row per exported current scored protocol. The stable top-level columns come +first: + +```text +id,name,chain,category,logo,deployedOn.host,deployedOn.label,safetyScore,computedAt,methodologyVersion,lastRunAt,lastRunStatus,operationalState.level,operationalState.asOf,operationalState.origin,operationalState.source,operationalState.detail,operationalState.blocked +``` + +After those, the export builds the deterministic union of factor keys present in that response, +sorts them alphabetically, and emits three columns for each: + +```text +.value,.weight,.detail +``` + +Rows whose category does not use a factor leave that factor's cells empty. `operationalState` is +flattened into its public fields; `operationalState.blocked` is joined as a semicolon-separated text +cell. CSV cells are RFC-4180 escaped for commas, quotes, and CR/LF. Empty, null, or undefined values +serialize as empty cells. Text cells beginning with `=`, `+`, `-`, or `@` after leading whitespace +are prefixed with an apostrophe to avoid spreadsheet formula execution; numeric score, value, and +weight cells are not modified by that mitigation. + +**Errors and preflight** + +Unsupported formats return `400` with: + +```json +{ "error": "Unsupported format", "supportedFormats": ["json", "csv"] } +``` + +Database failures return the same generic, uncached `500` shape as the other public routes: + +```json +{ "error": "Internal server error" } +``` + +The endpoint is public, read-only, CORS-enabled, and rate limited like the other `/api/v1` data +routes. `OPTIONS` returns the standard public API preflight response. + +--- diff --git a/api-docs/index.md b/api-docs/index.md index 3f9beeb..3794478 100644 --- a/api-docs/index.md +++ b/api-docs/index.md @@ -2,7 +2,7 @@ **Free, public, read-only risk data for Stellar/Soroban DeFi lending protocols.** -Four `GET` endpoints, no authentication, no API key, CORS open to any origin. If you are building a +Five `GET` endpoints, no authentication, no API key, CORS open to any origin. If you are building a wallet, an aggregator, or a dashboard and you want a live safety number for a protocol your users are about to interact with, this is the whole surface area. @@ -32,6 +32,9 @@ The `/v1/health` examples are mixed, and marked individually at the endpoint. Th captured**: `risk_scores` has never held a failed run, so that state has never occurred in production and cannot be observed until it does. +The `/v1/protocols/export` contract is documented without a body example until a deployed response +can be captured live. + --- ## Base URL @@ -96,6 +99,10 @@ integrating this API does not create one either. # every protocol, ranked curl https://stenion.vercel.app/api/v1/protocols +# current scored registry snapshot, as JSON or CSV +curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=json" +curl -OJ "https://stenion.vercel.app/api/v1/protocols/export?format=csv" + # protocols Stenion assessed and deliberately does not score curl https://stenion.vercel.app/api/v1/coverage diff --git a/architecture/deploy-architecture.md b/architecture/deploy-architecture.md index 1b9e677..bd8b970 100644 --- a/architecture/deploy-architecture.md +++ b/architecture/deploy-architecture.md @@ -4,13 +4,14 @@ separate services. Everything runs from the single Next.js app: - **API** → Next.js Route Handlers inside the dashboard (`app/api/v1/protocols`, - `app/api/v1/coverage`, `app/api/v1/protocol/[id]`, `app/api/v1/health`). The scored routes use the - same `Store` methods and JSON as the original standalone API — a transport change, not a rewrite. - The coverage route combines the static coverage module with live leaderboard ids solely for - deduplication. The health route reports indexer freshness and nothing else — see "The health - endpoint" below. CORS (`access-control-allow-origin: *`) is set on these four routes only, for - future browser/wallet/third-party clients reading public, payment-blind data. `/api/v1/*` is the - only public API surface — see "API versioning" below. All four are rate limited; three of them are + `app/api/v1/protocols/export`, `app/api/v1/coverage`, `app/api/v1/protocol/[id]`, + `app/api/v1/health`). The scored routes use the same `Store` latest-score semantics as the + original standalone API — a transport change, not a rewrite. The coverage route combines the + static coverage module with live leaderboard ids solely for deduplication. The health route + reports indexer freshness and nothing else — see "The health endpoint" below. CORS + (`access-control-allow-origin: *`) is set on these five routes only, for future + browser/wallet/third-party clients reading public, payment-blind data. `/api/v1/*` is the only + public API surface — see "API versioning" below. All five are rate limited; four of them are CDN-cached and health deliberately is not — see "Caching and rate limits" below. - **Indexer** → triggered by `POST /api/cron/run-indexer`, which calls `runIndexerCycle()` once. The route is secret-gated (`Authorization: Bearer `, compared with @@ -668,12 +669,13 @@ asserted nowhere. The public API is versioned in the URL. The documented, canonical paths are: -| Endpoint | Returns | -| -------------------------- | ------------------------------------------------------ | -| `GET /api/v1/protocols` | The leaderboard: every protocol + its latest score. | -| `GET /api/v1/coverage` | Assessed protocols and markets Stenion does not score. | -| `GET /api/v1/protocol/:id` | One protocol's detail, factors, and run history. | -| `GET /api/v1/health` | Indexer freshness per protocol + one overall status. | +| Endpoint | Returns | +| ------------------------------ | ------------------------------------------------------ | +| `GET /api/v1/protocols` | The leaderboard: every protocol + its latest score. | +| `GET /api/v1/protocols/export` | Current scored registry snapshot as JSON or CSV. | +| `GET /api/v1/coverage` | Assessed protocols and markets Stenion does not score. | +| `GET /api/v1/protocol/:id` | One protocol's detail, factors, and run history. | +| `GET /api/v1/health` | Indexer freshness per protocol + one overall status. | **The consumer-facing reference is [`api-docs/index.md`](../api-docs/index.md)**, rendered on the site at `/docs/api`. This section owns the _policy_; that document owns the contract as an integrator meets it — request and diff --git a/architecture/monorepo-layout.md b/architecture/monorepo-layout.md index 7fb4505..d177888 100644 --- a/architecture/monorepo-layout.md +++ b/architecture/monorepo-layout.md @@ -640,8 +640,9 @@ three things in one Vercel project: or a methodology-version change. None of the three is ever drawn through, and a failed run is never rendered as a zero. -2. The public API, as Route Handlers: `GET /api/v1/protocols`, `GET /api/v1/coverage`, - `GET /api/v1/protocol/:id`, `GET /api/v1/health`. +2. The public API, as Route Handlers: `GET /api/v1/protocols`, + `GET /api/v1/protocols/export`, `GET /api/v1/coverage`, `GET /api/v1/protocol/:id`, + `GET /api/v1/health`. 3. A secret-gated cron-trigger route (`POST /api/cron/run-indexer`) that runs one indexer cycle. **`@stenion/api`** — a standalone `node:http` REST server. **Not deployed** — see below. diff --git a/dashboard/app/api/_http.ts b/dashboard/app/api/_http.ts index 6f71217..0c8441c 100644 --- a/dashboard/app/api/_http.ts +++ b/dashboard/app/api/_http.ts @@ -6,13 +6,14 @@ // module deliberately imports nothing: it is the part of the response contract // that is pure, and therefore the part worth pinning. -// CORS for the PUBLIC data routes only. Stenion's /v1/protocols, /v1/coverage, -// /v1/protocol/:id and /v1/health serve public, read-only, payment-blind data — any -// origin may read them (a wallet, a third-party dashboard, anyone), so `*` is the -// correct, simplest policy, carried over verbatim from the standalone @stenion/api. NOTE: the Stenion -// dashboard's own pages do NOT rely on this — they read the Store in-process (see -// app/lib/api.ts), never cross-origin. CORS here is purely for external browser -// clients. The cron route deliberately gets NO CORS (it's secret-gated, not public). +// CORS for the PUBLIC data routes only. Stenion's /v1/protocols, +// /v1/protocols/export, /v1/coverage, /v1/protocol/:id and /v1/health serve public, +// read-only, payment-blind data — any origin may read them (a wallet, a third-party +// dashboard, anyone), so `*` is the correct, simplest policy, carried over verbatim +// from the standalone @stenion/api. NOTE: the Stenion dashboard's own pages do NOT +// rely on this — they read the Store in-process (see app/lib/api.ts), never +// cross-origin. CORS here is purely for external browser clients. The cron route +// deliberately gets NO CORS (it's secret-gated, not public). export const CORS_HEADERS: Record = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, OPTIONS', diff --git a/dashboard/app/api/v1/protocols/export/_export.test.ts b/dashboard/app/api/v1/protocols/export/_export.test.ts new file mode 100644 index 0000000..d47038f --- /dev/null +++ b/dashboard/app/api/v1/protocols/export/_export.test.ts @@ -0,0 +1,300 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { CurrentRegistryExportEntry } from '@stenion/db'; + +import { + CSV_EXPORT_FILENAME, + JSON_EXPORT_FILENAME, + currentRegistryCsv, + currentRegistryExportOptionsResponse, + handleCurrentRegistryExportRequest, +} from './_export.ts'; + +const RUN_AT = '2026-08-16T10:05:00.000Z'; +const COMPUTED_AT = '2026-08-16T10:00:00.000Z'; + +function entry(over: Partial = {}): CurrentRegistryExportEntry { + return { + id: 'blend', + name: 'Blend', + chain: 'stellar', + category: 'lending', + logo: '/assets/protocols/blend.svg', + deployedOn: null, + safetyScore: 53, + computedAt: COMPUTED_AT, + methodologyVersion: 1, + factors: { + collateralSafety: { value: 53, weight: 0.2, detail: 'top reserve holds half' }, + oracleSafety: { value: 70, weight: 0.2, detail: 'fresh prices' }, + }, + operationalState: { + level: 'active', + source: 'PoolConfig.status = 1', + blocked: [], + origin: 'protocol', + detail: 'pool status 1 (Active)', + asOf: COMPUTED_AT, + }, + lastRunAt: RUN_AT, + lastRunStatus: 'ok', + ...over, + }; +} + +function simpleRows(csv: string): string[][] { + return csv + .trimEnd() + .split('\r\n') + .map((line) => line.split(',')); +} + +function deps(protocols: CurrentRegistryExportEntry[]) { + const calls = { rateLimit: 0, store: 0 }; + return { + calls, + deps: { + async enforceRateLimit() { + calls.rateLimit += 1; + return null; + }, + getStore() { + return { + async listCurrentScoredRegistryState() { + calls.store += 1; + return protocols; + }, + }; + }, + }, + }; +} + +describe('currentRegistryCsv', () => { + it('emits one data row per current scored protocol', () => { + const csv = currentRegistryCsv([ + entry({ id: 'high', safetyScore: 80 }), + entry({ id: 'low', safetyScore: 20 }), + ]); + assert.equal(simpleRows(csv).length, 3); + }); + + it('uses deterministic factor columns across category-specific maps', () => { + const csv = currentRegistryCsv([ + entry({ + id: 'dex', + category: 'dex', + factors: { + assetControlSafety: { value: 40, weight: 0.45, detail: 'issuer controls' }, + adminKeySafety: { value: 10, weight: 0.55, detail: 'role posture' }, + }, + }), + entry({ + id: 'lending', + factors: { + collateralSafety: { value: 80, weight: 0.2, detail: 'diverse collateral' }, + }, + }), + ]); + + const [header, dex, lending] = simpleRows(csv); + assert.deepEqual(header.slice(-9), [ + 'adminKeySafety.value', + 'adminKeySafety.weight', + 'adminKeySafety.detail', + 'assetControlSafety.value', + 'assetControlSafety.weight', + 'assetControlSafety.detail', + 'collateralSafety.value', + 'collateralSafety.weight', + 'collateralSafety.detail', + ]); + assert.deepEqual(dex.slice(-3), ['', '', ''], 'absent lending factor cells stay empty'); + assert.deepEqual(lending.slice(-9, -6), ['', '', ''], 'absent dex factor cells stay empty'); + }); + + it('flattens operationalState and deployment fields', () => { + const csv = currentRegistryCsv([ + entry({ + deployedOn: { host: 'Blend', label: 'Blend V2 pool' }, + operationalState: { + level: 'entryDisabled', + source: 'PoolConfig.status = 4', + blocked: ['supply', 'borrow'], + origin: 'admin', + detail: 'borrowing and supplying are disabled', + asOf: COMPUTED_AT, + }, + }), + ]); + const [header, row] = simpleRows(csv); + const at = (column: string) => row[header.indexOf(column)]; + assert.equal(at('deployedOn.host'), 'Blend'); + assert.equal(at('deployedOn.label'), 'Blend V2 pool'); + assert.equal(at('operationalState.level'), 'entryDisabled'); + assert.equal(at('operationalState.blocked'), 'supply; borrow'); + }); + + it('leaves null operationalState and null factor values empty rather than inventing values', () => { + const csv = currentRegistryCsv([ + entry({ + logo: null, + operationalState: null, + factors: { + collateralSafety: null, + }, + }), + ]); + const [header, row] = simpleRows(csv); + const at = (column: string) => row[header.indexOf(column)]; + assert.equal(at('logo'), ''); + assert.equal(at('operationalState.level'), ''); + assert.equal(at('collateralSafety.value'), ''); + assert.equal(at('collateralSafety.weight'), ''); + assert.equal(at('collateralSafety.detail'), ''); + }); + + it('escapes commas, quotes, CR/LF and formula-leading text cells safely', () => { + const csv = currentRegistryCsv([ + entry({ + name: '=Formula Market', + factors: { + collateralSafety: { + value: 53, + weight: 0.2, + detail: 'comma, quote " and newline\ninside', + }, + oracleSafety: { + value: 70, + weight: 0.2, + detail: '-starts like a spreadsheet formula', + }, + }, + }), + ]); + assert.match(csv, /blend,'=Formula Market,/); + assert.match(csv, /"comma, quote "" and newline\ninside"/); + assert.match(csv, /'-starts like a spreadsheet formula/); + assert.match(csv, /\r\n$/); + }); +}); + +describe('handleCurrentRegistryExportRequest', () => { + it('defaults to JSON with the documented envelope and attachment headers', async () => { + const fixture = [entry()]; + const { calls, deps: handlerDeps } = deps(fixture); + const res = await handleCurrentRegistryExportRequest( + new Request('https://stenion.test/api/v1/protocols/export'), + handlerDeps, + ); + + assert.equal(calls.rateLimit, 1); + assert.equal(calls.store, 1); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'application/json; charset=utf-8'); + assert.equal( + res.headers.get('content-disposition'), + `attachment; filename="${JSON_EXPORT_FILENAME}"`, + ); + assert.match(res.headers.get('cache-control') ?? '', /^public, max-age=0, s-maxage=/); + assert.deepEqual(await res.json(), { protocols: fixture }); + }); + + it('supports CSV with a text/csv content type and attachment filename', async () => { + const { deps: handlerDeps } = deps([entry()]); + const res = await handleCurrentRegistryExportRequest( + new Request('https://stenion.test/api/v1/protocols/export?format=csv'), + handlerDeps, + ); + + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'text/csv; charset=utf-8'); + assert.equal( + res.headers.get('content-disposition'), + `attachment; filename="${CSV_EXPORT_FILENAME}"`, + ); + assert.match(await res.text(), /^id,name,chain,category,logo,/); + }); + + it('rejects unsupported formats without querying the store', async () => { + const { calls, deps: handlerDeps } = deps([entry()]); + const res = await handleCurrentRegistryExportRequest( + new Request('https://stenion.test/api/v1/protocols/export?format=xml'), + handlerDeps, + ); + + assert.equal(calls.rateLimit, 1); + assert.equal(calls.store, 0); + assert.equal(res.status, 400); + assert.equal(res.headers.get('cache-control'), 'no-store'); + assert.deepEqual(await res.json(), { + error: 'Unsupported format', + supportedFormats: ['json', 'csv'], + }); + }); + + it('returns a rate-limit response before format parsing or store work', async () => { + const limited = new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { + status: 429, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + let storeCalled = false; + const res = await handleCurrentRegistryExportRequest( + new Request('https://stenion.test/api/v1/protocols/export?format=xml'), + { + async enforceRateLimit() { + return limited; + }, + getStore() { + storeCalled = true; + return { + async listCurrentScoredRegistryState() { + return [entry()]; + }, + }; + }, + }, + ); + + assert.equal(res, limited); + assert.equal(storeCalled, false); + }); + + it('keeps 500 responses generic and uncached', async () => { + const logs: unknown[][] = []; + const res = await handleCurrentRegistryExportRequest( + new Request('https://stenion.test/api/v1/protocols/export?format=csv'), + { + async enforceRateLimit() { + return null; + }, + getStore() { + return { + async listCurrentScoredRegistryState() { + throw new Error('raw database details'); + }, + }; + }, + logError: (...args) => logs.push(args), + }, + ); + + assert.equal(res.status, 500); + assert.equal(res.headers.get('content-type'), 'application/json; charset=utf-8'); + assert.equal(res.headers.get('cache-control'), 'no-store'); + assert.deepEqual(await res.json(), { error: 'Internal server error' }); + assert.equal(logs.length, 1); + }); +}); + +describe('currentRegistryExportOptionsResponse', () => { + it('matches the public API CORS preflight contract', () => { + const res = currentRegistryExportOptionsResponse(); + assert.equal(res.status, 204); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + assert.equal(res.headers.get('access-control-allow-methods'), 'GET, OPTIONS'); + assert.equal(res.headers.get('access-control-max-age'), '86400'); + assert.match(res.headers.get('cache-control') ?? '', /^public, max-age=0, s-maxage=86400$/); + }); +}); diff --git a/dashboard/app/api/v1/protocols/export/_export.ts b/dashboard/app/api/v1/protocols/export/_export.ts new file mode 100644 index 0000000..0c827f4 --- /dev/null +++ b/dashboard/app/api/v1/protocols/export/_export.ts @@ -0,0 +1,182 @@ +import type { CurrentRegistryExportEntry } from '@stenion/db'; + +import { + NO_STORE, + PREFLIGHT_MAX_AGE_SECONDS, + cacheTtlSeconds, + publicCacheControl, +} from '../../../_cache.ts'; +import { CORS_HEADERS, jsonResponse } from '../../../_http.ts'; + +export const JSON_EXPORT_FILENAME = 'stenion-registry-current.json'; +export const CSV_EXPORT_FILENAME = 'stenion-registry-current.csv'; + +type ExportFormat = 'json' | 'csv'; + +interface CurrentRegistryExportStore { + listCurrentScoredRegistryState(): Promise; +} + +export interface CurrentRegistryExportHandlerDeps { + enforceRateLimit(req: Request): Promise; + getStore(): CurrentRegistryExportStore; + logError?(...args: unknown[]): void; +} + +const TOP_LEVEL_COLUMNS = [ + 'id', + 'name', + 'chain', + 'category', + 'logo', + 'deployedOn.host', + 'deployedOn.label', + 'safetyScore', + 'computedAt', + 'methodologyVersion', + 'lastRunAt', + 'lastRunStatus', + 'operationalState.level', + 'operationalState.asOf', + 'operationalState.origin', + 'operationalState.source', + 'operationalState.detail', + 'operationalState.blocked', +] as const; + +function parseFormat(req: Request): ExportFormat | null { + const format = new URL(req.url).searchParams.get('format') ?? 'json'; + return format === 'json' || format === 'csv' ? format : null; +} + +function attachment(filename: string): string { + return `attachment; filename="${filename}"`; +} + +function successHeaders(format: ExportFormat, lastRunAts: readonly (string | null)[]) { + const ttl = cacheTtlSeconds(lastRunAts); + return { + 'cache-control': publicCacheControl(ttl), + 'content-disposition': attachment( + format === 'json' ? JSON_EXPORT_FILENAME : CSV_EXPORT_FILENAME, + ), + }; +} + +function csvText(value: string | null | undefined): string { + if (value == null) return ''; + return csvEscape(preventFormula(value)); +} + +function csvNumber(value: number | null | undefined): string { + if (value == null) return ''; + return csvEscape(String(value)); +} + +function preventFormula(value: string): string { + return /^[\t\r\n ]*[=+\-@]/.test(value) ? `'${value}` : value; +} + +function csvEscape(value: string): string { + return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value; +} + +function factorKeys(protocols: readonly CurrentRegistryExportEntry[]): string[] { + const keys = new Set(); + for (const protocol of protocols) { + for (const key of Object.keys(protocol.factors)) keys.add(key); + } + return [...keys].sort(); +} + +function csvColumns(protocols: readonly CurrentRegistryExportEntry[]): string[] { + return [ + ...TOP_LEVEL_COLUMNS, + ...factorKeys(protocols).flatMap((key) => [`${key}.value`, `${key}.weight`, `${key}.detail`]), + ]; +} + +function csvRow(protocol: CurrentRegistryExportEntry, keys: readonly string[]): string[] { + return [ + csvText(protocol.id), + csvText(protocol.name), + csvText(protocol.chain), + csvText(protocol.category), + csvText(protocol.logo), + csvText(protocol.deployedOn?.host), + csvText(protocol.deployedOn?.label), + csvNumber(protocol.safetyScore), + csvText(protocol.computedAt), + csvNumber(protocol.methodologyVersion), + csvText(protocol.lastRunAt), + csvText(protocol.lastRunStatus), + csvText(protocol.operationalState?.level), + csvText(protocol.operationalState?.asOf), + csvText(protocol.operationalState?.origin), + csvText(protocol.operationalState?.source), + csvText(protocol.operationalState?.detail), + csvText(protocol.operationalState?.blocked.join('; ')), + ...keys.flatMap((key) => { + const factor = protocol.factors[key]; + return [csvNumber(factor?.value), csvNumber(factor?.weight), csvText(factor?.detail)]; + }), + ]; +} + +export function currentRegistryCsv(protocols: readonly CurrentRegistryExportEntry[]): string { + const keys = factorKeys(protocols); + const lines = [ + csvColumns(protocols).map(csvEscape).join(','), + ...protocols.map((protocol) => csvRow(protocol, keys).join(',')), + ]; + return `${lines.join('\r\n')}\r\n`; +} + +export async function handleCurrentRegistryExportRequest( + req: Request, + deps: CurrentRegistryExportHandlerDeps, +): Promise { + const limited = await deps.enforceRateLimit(req); + if (limited) return limited; + + const format = parseFormat(req); + if (!format) { + return jsonResponse({ error: 'Unsupported format', supportedFormats: ['json', 'csv'] }, 400, { + 'cache-control': NO_STORE, + }); + } + + try { + const protocols = await deps.getStore().listCurrentScoredRegistryState(); + const headers = successHeaders( + format, + protocols.map((protocol) => protocol.lastRunAt), + ); + if (format === 'json') { + return jsonResponse({ protocols }, 200, headers); + } + + return new Response(currentRegistryCsv(protocols), { + status: 200, + headers: { + 'content-type': 'text/csv; charset=utf-8', + ...CORS_HEADERS, + ...headers, + }, + }); + } catch (err) { + deps.logError?.('GET /api/v1/protocols/export failed:', err); + return jsonResponse({ error: 'Internal server error' }, 500, { 'cache-control': NO_STORE }); + } +} + +export function currentRegistryExportOptionsResponse(): Response { + return new Response(null, { + status: 204, + headers: { + ...CORS_HEADERS, + 'access-control-max-age': String(PREFLIGHT_MAX_AGE_SECONDS), + 'cache-control': publicCacheControl(PREFLIGHT_MAX_AGE_SECONDS), + }, + }); +} diff --git a/dashboard/app/api/v1/protocols/export/route.ts b/dashboard/app/api/v1/protocols/export/route.ts new file mode 100644 index 0000000..cc98bdc --- /dev/null +++ b/dashboard/app/api/v1/protocols/export/route.ts @@ -0,0 +1,27 @@ +// GET /api/v1/protocols/export — one-shot current scored registry export. +// +// This is a formatting layer over the same persisted latest-ok score and +// latest-run freshness semantics as GET /api/v1/protocols. It does not recompute +// scores, does not fetch per-protocol detail rows, and deliberately excludes +// protocols that have never produced a successful score. + +import { enforceRateLimit, getStore } from '../../../_shared'; +import { + currentRegistryExportOptionsResponse, + handleCurrentRegistryExportRequest, +} from './_export'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request): Promise { + return handleCurrentRegistryExportRequest(req, { + enforceRateLimit, + getStore, + logError: console.error, + }); +} + +export function OPTIONS(): Response { + return currentRegistryExportOptionsResponse(); +} diff --git a/dashboard/app/lib/api.ts b/dashboard/app/lib/api.ts index d76ef95..acb250b 100644 --- a/dashboard/app/lib/api.ts +++ b/dashboard/app/lib/api.ts @@ -3,8 +3,8 @@ // ARCHITECTURE NOTE — deploy merge (2026-08-12). The public API used to be a // separate @stenion/api service that this module fetched over HTTP (STENION_API_URL). // For the Vercel deploy the API is merged INTO this Next.js app as Route Handlers -// (app/api/v1/protocols, app/api/v1/coverage, app/api/v1/protocol/[id], app/api/v1/health) so -// there is one deployable, not two. +// (app/api/v1/protocols, app/api/v1/protocols/export, app/api/v1/coverage, +// app/api/v1/protocol/[id], app/api/v1/health) so there is one deployable, not two. // // Because the dashboard renders on the server, its pages now read the same // @stenion/db Store DIRECTLY here — no HTTP hop, no self-fetch. Self-fetching our diff --git a/dashboard/app/lib/doc-parts.mjs b/dashboard/app/lib/doc-parts.mjs index ac7fec1..c489f91 100644 --- a/dashboard/app/lib/doc-parts.mjs +++ b/dashboard/app/lib/doc-parts.mjs @@ -35,6 +35,7 @@ export const ARCHITECTURE_PARTS = [ export const API_PARTS = [ 'api-docs/index.md', 'api-docs/protocols.md', + 'api-docs/export.md', 'api-docs/coverage.md', 'api-docs/protocol-by-id.md', 'api-docs/health.md', diff --git a/db/src/index.ts b/db/src/index.ts index ba3b4ff..a476ec5 100644 --- a/db/src/index.ts +++ b/db/src/index.ts @@ -4,6 +4,7 @@ export { createStore, DETAIL_HISTORY_LIMIT, type Store, + type CurrentRegistryExportEntry, type RunRecord, type LeaderboardEntry, type HistoryEntry, diff --git a/db/src/store.integration.test.ts b/db/src/store.integration.test.ts index 4a7430c..cd9fb14 100644 --- a/db/src/store.integration.test.ts +++ b/db/src/store.integration.test.ts @@ -57,15 +57,41 @@ describe('Store SQL (integration)', { skip }, () => { error?: string; runAt: string; version?: number; + category?: string; + factors?: Record; + operationalState?: Record | null; }, ) { await pool.query( `INSERT INTO risk_scores - (protocol_id, status, safety_score, factors, error, computed_at, run_at, methodology_version) - VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8)`, + (protocol_id, status, safety_score, factors, error, computed_at, run_at, methodology_version, + operational_state, category) + VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9::jsonb,$10)`, row.status === 'ok' - ? [protocolId, 'ok', row.score, '{}', null, row.runAt, row.runAt, row.version ?? 1] - : [protocolId, 'failed', null, null, row.error ?? 'boom', null, row.runAt, null], + ? [ + protocolId, + 'ok', + row.score, + JSON.stringify(row.factors ?? {}), + null, + row.runAt, + row.runAt, + row.version ?? 1, + row.operationalState == null ? null : JSON.stringify(row.operationalState), + row.category ?? 'lending', + ] + : [ + protocolId, + 'failed', + null, + null, + row.error ?? 'boom', + null, + row.runAt, + null, + null, + null, + ], ); } @@ -254,6 +280,121 @@ describe('Store SQL (integration)', { skip }, () => { assert.equal(board[2].safetyScore, null, 'the unscored one sorts last, with a null score'); }); + it('exports only current scored rows with latest-ok factors and latest-run status', async () => { + // Same staleness semantics as listProtocolsWithLatestScore, but as a scored + // snapshot: the never-scored protocol is excluded rather than exported with + // null score fields. + const stale = id('export-stale'); + const newerOk = id('export-newer-ok'); + const neverScored = id('export-never-scored'); + + for (const [p, name, category] of [ + [stale, 'Export Stale', 'lending'], + [newerOk, 'Export DEX', 'dex'], + [neverScored, 'Export Never', 'lending'], + ] as const) { + await store.upsertProtocol({ + id: p, + name, + chain: 'stellar', + category, + adapterRef: 'FakeAdapter', + logo: p === stale ? '/assets/protocols/blend.svg' : undefined, + deployedOn: + p === stale + ? { + host: 'Blend', + label: 'Blend V2 pool', + } + : undefined, + }); + } + + await insertRun(stale, { + status: 'ok', + score: 44, + runAt: '2026-08-16T10:00:00Z', + factors: { + collateralSafety: { value: 44, weight: 0.2, detail: 'older row' }, + }, + operationalState: { + level: OperationalLevel.EntryDisabled, + source: 'PoolConfig.status = 4', + blocked: ['supply', 'borrow'], + origin: 'admin', + detail: 'pool status 4 (Admin Frozen)', + asOf: '2026-08-16T10:00:00.000Z', + }, + }); + await insertRun(stale, { + status: 'failed', + error: 'RPC down', + runAt: '2026-08-16T10:05:00Z', + }); + await insertRun(newerOk, { + status: 'ok', + score: 21, + runAt: '2026-08-16T10:00:00Z', + category: 'dex', + factors: { + adminKeySafety: { value: 21, weight: 0.55, detail: 'old dex factor' }, + }, + }); + await insertRun(newerOk, { + status: 'ok', + score: 24, + runAt: '2026-08-16T10:10:00Z', + category: 'dex', + version: 2, + factors: { + adminKeySafety: { value: 24, weight: 0.55, detail: 'new dex factor' }, + assetControlSafety: { value: 28, weight: 0.45, detail: 'reserve issuer controls' }, + }, + operationalState: { + level: OperationalLevel.Active, + source: 'router.get_emergency_mode() = false', + blocked: [], + origin: 'indeterminate', + detail: 'the AMM router is not in emergency mode', + asOf: '2026-08-16T10:10:00.000Z', + }, + }); + await insertRun(neverScored, { + status: 'failed', + error: 'never produced a score', + runAt: '2026-08-16T10:10:00Z', + }); + + const ids = new Set([stale, newerOk, neverScored]); + const exported = (await store.listCurrentScoredRegistryState()).filter((row) => + ids.has(row.id), + ); + assert.deepEqual( + exported.map((row) => row.id), + [stale, newerOk], + 'one current scored row per protocol, sorted by latest ok score', + ); + + const staleRow = exported.find((row) => row.id === stale); + assert.ok(staleRow); + assert.equal(staleRow.safetyScore, 44); + assert.equal(staleRow.lastRunStatus, 'failed'); + assert.equal(staleRow.lastRunAt, '2026-08-16T10:05:00.000Z'); + assert.deepEqual(staleRow.deployedOn, { host: 'Blend', label: 'Blend V2 pool' }); + assert.deepEqual(staleRow.factors, { + collateralSafety: { value: 44, weight: 0.2, detail: 'older row' }, + }); + assert.equal(staleRow.operationalState?.level, OperationalLevel.EntryDisabled); + + const dexRow = exported.find((row) => row.id === newerOk); + assert.ok(dexRow); + assert.equal(dexRow.category, 'dex'); + assert.equal(dexRow.safetyScore, 24); + assert.equal(dexRow.methodologyVersion, 2); + assert.deepEqual(Object.keys(dexRow.factors).sort(), ['adminKeySafety', 'assetControlSafety']); + assert.ok(!exported.some((row) => row.id === neverScored)); + }); + it('enforces the ok/failed split at the database level', async () => { // The risk_scores_shape CHECK is a second line of defence behind the // RunRecord union: a failed row carrying a score should be impossible even diff --git a/db/src/store.test.ts b/db/src/store.test.ts index 7f5120d..88b5731 100644 --- a/db/src/store.test.ts +++ b/db/src/store.test.ts @@ -20,10 +20,12 @@ import { describe, it } from 'node:test'; import { toDeployedOn, + toCurrentRegistryExportEntry, toHistoryEntry, toLeaderboardEntry, toProtocolDetail, toRunHealthEntry, + type CurrentRegistryExportRow, type HistoryRow, type LeaderboardRow, type ProtocolDetailRow, @@ -389,6 +391,76 @@ describe('toLeaderboardEntry', () => { }); }); +describe('toCurrentRegistryExportEntry', () => { + const row = (over: Partial = {}): CurrentRegistryExportRow => ({ + id: 'blend', + name: 'Blend', + chain: 'stellar', + category: 'lending', + logo: '/assets/protocols/blend.svg', + deployment_host: null, + deployment_label: null, + safety_score: '53', + computed_at: COMPUTED_AT, + methodology_version: 1, + factors: FACTORS, + operational_state: OPERATIONAL_STATE, + last_run_at: RUN_AT, + last_run_status: 'ok', + ...over, + }); + + it('maps a currently scored row with factors and methodologyVersion', () => { + const entry = toCurrentRegistryExportEntry(row()); + assert.deepEqual(entry, { + id: 'blend', + name: 'Blend', + chain: 'stellar', + category: 'lending', + logo: '/assets/protocols/blend.svg', + deployedOn: null, + safetyScore: 53, + computedAt: '2026-08-16T11:25:01.000Z', + methodologyVersion: 1, + factors: FACTORS, + operationalState: OPERATIONAL_STATE, + lastRunAt: '2026-08-16T11:25:02.000Z', + lastRunStatus: 'ok', + }); + }); + + it('keeps a stale latest-run status without disturbing the latest successful score', () => { + const failedAt = new Date('2026-08-16T11:30:02.000Z'); + const entry = toCurrentRegistryExportEntry( + row({ last_run_at: failedAt, last_run_status: 'failed' }), + ); + assert.equal(entry.safetyScore, 53); + assert.equal(entry.computedAt, COMPUTED_AT.toISOString()); + assert.equal(entry.lastRunStatus, 'failed'); + assert.equal(entry.lastRunAt, failedAt.toISOString()); + }); + + it('preserves category-specific factor maps exactly', () => { + const dexFactors = { + adminKeySafety: { value: 10, weight: 0.55, detail: 'role posture' }, + assetControlSafety: { value: 40, weight: 0.45, detail: 'issuer controls' }, + }; + const entry = toCurrentRegistryExportEntry( + row({ category: 'dex', factors: dexFactors, safety_score: '24' }), + ); + assert.equal(entry.category, 'dex'); + assert.deepEqual(entry.factors, dexFactors); + assert.deepEqual(Object.keys(entry.factors).sort(), ['adminKeySafety', 'assetControlSafety']); + }); + + it('passes a null operationalState through as not-read, not active', () => { + assert.equal( + toCurrentRegistryExportEntry(row({ operational_state: null })).operationalState, + null, + ); + }); +}); + // --------------------------------------------------------------------------- // The shape an EMPTY `risk_scores` table produces. // diff --git a/db/src/store.ts b/db/src/store.ts index e4c7e92..0494dcd 100644 --- a/db/src/store.ts +++ b/db/src/store.ts @@ -156,6 +156,31 @@ export interface LeaderboardEntry { lastRunStatus: 'ok' | 'failed' | null; } +/** + * One currently scored registry row for export. + * + * This is deliberately the leaderboard's current-state shape plus the score + * payload the board omits (`factors` and `methodologyVersion`). It is still a + * snapshot of the latest **ok** score, not history, and it still carries + * `lastRunAt`/`lastRunStatus` from the latest run of any status so a stale score + * remains visible when a newer attempt failed. + */ +export interface CurrentRegistryExportEntry { + id: string; + name: string; + chain: string; + category: ProtocolCategory; + logo: string | null; + deployedOn: ProtocolDeployment | null; + safetyScore: number; + computedAt: string; + methodologyVersion: number; + factors: FactorMap; + operationalState: OperationalState | null; + lastRunAt: string | null; + lastRunStatus: 'ok' | 'failed' | null; +} + /** * One row of a protocol's recent score history (GET /api/v1/protocol/:id). A * discriminated union on `status` mirroring the persisted RunRecord: `ok` rows @@ -316,6 +341,11 @@ export interface Store { insertRunRecord(record: RunRecord): Promise; /** Every protocol with its latest-ok score, ranked by score desc (nulls last). */ listProtocolsWithLatestScore(): Promise; + /** + * Every currently scored protocol/market, one row per latest successful score. + * Never-scored protocols are excluded rather than exported with fake scores. + */ + listCurrentScoredRegistryState(): Promise; /** One protocol's detail + recent history, or null if the id is unknown. */ getProtocolDetail(id: string): Promise; /** @@ -521,6 +551,52 @@ export function toLeaderboardEntry(row: LeaderboardRow): LeaderboardEntry { }; } +/** + * A scored export row as pg returns it. It is the leaderboard row plus the + * latest-ok score payload, with non-null score fields because the query joins + * only protocols that have a successful run. + */ +export interface CurrentRegistryExportRow { + id: string; + name: string; + chain: string; + category: string; + logo: string | null; + deployment_host: string | null; + deployment_label: string | null; + safety_score: string; + computed_at: Date; + methodology_version: number; + factors: FactorMap; + operational_state: OperationalState | null; + last_run_at: Date | null; + last_run_status: 'ok' | 'failed' | null; +} + +/** One export row -> one current scored registry entry. */ +export function toCurrentRegistryExportEntry( + row: CurrentRegistryExportRow, +): CurrentRegistryExportEntry { + return { + id: row.id, + name: row.name, + chain: row.chain, + /* see toProtocolDetail on the cast */ + category: row.category as ProtocolCategory, + logo: row.logo, + deployedOn: toDeployedOn(row.deployment_host, row.deployment_label), + // Non-null because the SQL joins an ok risk_scores row, whose shape CHECK + // requires these fields on the ok arm. + safetyScore: Number(row.safety_score), + computedAt: row.computed_at.toISOString(), + methodologyVersion: row.methodology_version, + factors: row.factors, + operationalState: row.operational_state, + lastRunAt: toIso(row.last_run_at), + lastRunStatus: row.last_run_status, + }; +} + /** A `protocols` row joined with its run-freshness timestamps, as pg returns it. */ export interface RunHealthRow { id: string; @@ -547,6 +623,66 @@ export function toRunHealthEntry(row: RunHealthRow): RunHealthEntry { }; } +/** + * The staleness join: the newest run of ANY status, per protocol. + * + * Extracted because it is the same join in every query that reports + * `lastRunAt` / `lastRunStatus` — the leaderboard, the current-state export, the + * detail row and the health probe. It was previously written out four times, and + * four copies of the staleness model is four places to change when it moves and + * three places to forget. The whole point of the pair is that a stale score + * stays visible while a newer failure is reported beside it; a copy that drifts + * doesn't announce itself, it just quietly reports a different freshness than + * the route next to it. + * + * LEFT, always: a protocol with no runs at all still has to come back, with null + * timestamps meaning "never run". An inner join here would silently drop a + * newly-registered protocol from the board. + * + * Assumes the enclosing query aliases `protocols` as `p`, and exposes the join + * as `latest`. + */ +const LATEST_RUN_LATERAL = `LEFT JOIN LATERAL ( + SELECT run_at, status + FROM risk_scores + WHERE protocol_id = p.id + ORDER BY run_at DESC + LIMIT 1 + ) latest ON true`; + +/** + * The current-score join: the newest SUCCESSFUL run, per protocol. + * + * The other half of the pair above, and extracted for the same reason — the + * `status = 'ok' ORDER BY run_at DESC LIMIT 1` predicate is what "current score" + * MEANS here, and it was written out four times. What legitimately varies per + * caller is only which columns that row is asked for, so that is the parameter; + * the predicate is not. + * + * `join` is the one other axis, and it is `'JOIN'` in exactly one place. The + * export is a scored snapshot, so a protocol with no successful run has no + * current score to export and is excluded by the join itself rather than + * filtered out afterwards. Everywhere else the join is LEFT, because a + * never-scored protocol is a row the caller still needs — as `safetyScore: null` + * on the board, or as a 200 with an empty history on the detail route. + * + * `projection` is a SQL fragment, never caller input: every call site below + * passes a hardcoded column list. Assumes `protocols` is aliased `p`, and + * exposes the join as `ok`. + */ +function latestOkScoreLateral( + projection: string, + join: 'LEFT JOIN' | 'JOIN' = 'LEFT JOIN', +): string { + return `${join} LATERAL ( + SELECT ${projection} + FROM risk_scores + WHERE protocol_id = p.id AND status = 'ok' + ORDER BY run_at DESC + LIMIT 1 + ) ok ON true`; +} + export function createStore(pool: Pool): Store { return { async upsertProtocol(metadata) { @@ -668,26 +804,37 @@ export function createStore(pool: Pool): Store { ok.safety_score, ok.computed_at, ok.operational_state, latest.run_at AS last_run_at, latest.status AS last_run_status FROM protocols p - LEFT JOIN LATERAL ( - SELECT safety_score, computed_at, operational_state - FROM risk_scores - WHERE protocol_id = p.id AND status = 'ok' - ORDER BY run_at DESC - LIMIT 1 - ) ok ON true - LEFT JOIN LATERAL ( - SELECT run_at, status - FROM risk_scores - WHERE protocol_id = p.id - ORDER BY run_at DESC - LIMIT 1 - ) latest ON true + ${latestOkScoreLateral('safety_score, computed_at, operational_state')} + ${LATEST_RUN_LATERAL} ORDER BY ok.safety_score DESC NULLS LAST, p.id`, ); return rows.map(toLeaderboardEntry); }, + async listCurrentScoredRegistryState() { + // Same latest-successful-score + latest-run semantics as the leaderboard, + // but the ok side is an INNER LATERAL join because this export is a scored + // snapshot: a protocol with no successful run has no current score or + // factor map to export. This is one query, with no per-row detail fetches. + const { rows } = await pool.query( + `SELECT p.id, p.name, p.chain, p.category, p.logo, + p.deployment_host, p.deployment_label, + ok.safety_score, ok.computed_at, ok.methodology_version, + ok.factors, ok.operational_state, + latest.run_at AS last_run_at, latest.status AS last_run_status + FROM protocols p + ${latestOkScoreLateral( + 'safety_score, computed_at, methodology_version, factors, operational_state', + 'JOIN', + )} + ${LATEST_RUN_LATERAL} + ORDER BY ok.safety_score DESC, p.id`, + ); + + return rows.map(toCurrentRegistryExportEntry); + }, + async getProtocolDetail(id) { // Protocol row + latest-ok score/factors + newest-run staleness fields, in // one query. No row → unknown id → null (the API turns this into a 404). @@ -699,20 +846,10 @@ export function createStore(pool: Pool): Store { ok.methodology_version, latest.run_at AS last_run_at, latest.status AS last_run_status FROM protocols p - LEFT JOIN LATERAL ( - SELECT safety_score, computed_at, factors, operational_state, methodology_version - FROM risk_scores - WHERE protocol_id = p.id AND status = 'ok' - ORDER BY run_at DESC - LIMIT 1 - ) ok ON true - LEFT JOIN LATERAL ( - SELECT run_at, status - FROM risk_scores - WHERE protocol_id = p.id - ORDER BY run_at DESC - LIMIT 1 - ) latest ON true + ${latestOkScoreLateral( + 'safety_score, computed_at, factors, operational_state, methodology_version', + )} + ${LATEST_RUN_LATERAL} WHERE p.id = $1`, [id], ); @@ -780,20 +917,8 @@ export function createStore(pool: Pool): Store { ok.run_at AS last_successful_run_at, latest.run_at AS last_run_at, latest.status AS last_run_status FROM protocols p - LEFT JOIN LATERAL ( - SELECT run_at - FROM risk_scores - WHERE protocol_id = p.id AND status = 'ok' - ORDER BY run_at DESC - LIMIT 1 - ) ok ON true - LEFT JOIN LATERAL ( - SELECT run_at, status - FROM risk_scores - WHERE protocol_id = p.id - ORDER BY run_at DESC - LIMIT 1 - ) latest ON true + ${latestOkScoreLateral('run_at')} + ${LATEST_RUN_LATERAL} ORDER BY p.id`, ); diff --git a/indexer/src/cycle.test.ts b/indexer/src/cycle.test.ts index e0dba92..730debb 100644 --- a/indexer/src/cycle.test.ts +++ b/indexer/src/cycle.test.ts @@ -82,6 +82,9 @@ function fakeStore( async listProtocolsWithLatestScore() { return []; }, + async listCurrentScoredRegistryState() { + return []; + }, async getProtocolDetail() { return null; },