Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 9 additions & 8 deletions api-docs/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions api-docs/export.md
Original file line number Diff line number Diff line change
@@ -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
<factor>.value,<factor>.weight,<factor>.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.

---
9 changes: 8 additions & 1 deletion api-docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
28 changes: 15 additions & 13 deletions architecture/deploy-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CRON_SECRET>`, compared with
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions architecture/monorepo-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 8 additions & 7 deletions dashboard/app/api/_http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'access-control-allow-origin': '*',
'access-control-allow-methods': 'GET, OPTIONS',
Expand Down
Loading
Loading