diff --git a/API.md b/API.md new file mode 100644 index 0000000..0f348d0 --- /dev/null +++ b/API.md @@ -0,0 +1,606 @@ +# Stenion Public API + +**Free, public, read-only risk data for Stellar/Soroban DeFi lending protocols.** + +Two `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. + +Everything below was captured from the live production API, not written from the type definitions. +The example responses are verbatim bodies from a snapshot taken at **2026-08-19T13:20Z**; the +numbers move every ~5 minutes, the shapes do not. + +--- + +## Base URL + +``` +https://stenion.vercel.app/api/v1 +``` + +There is no separate API host and no sandbox. The production API is the only API, and it serves the +same data the [registry](https://stenion.vercel.app/registry) renders — the site's own pages and +these routes read the same store, so what you get and what we show cannot drift apart. + +### Versioning, and whether we will break you + +Every public path carries a version segment. There are **no unversioned paths** — `/api/protocols` +and `/api/protocol/:id` existed briefly during the move to `/v1` and now `404`. + +The policy, stated plainly because "will you break my integration" is the only versioning question +that actually matters: + +- **Additive changes stay on `v1`.** A new field in a response — a sixth `*Safety` factor, another + piece of protocol metadata, a new component inside a factor — ships on `v1`. It cannot break a + client that ignores fields it does not recognise, so **parse defensively and tolerate unknown + fields**. That is the one thing we ask of you in return. +- **Breaking changes get a `v2`.** Renaming a field, removing one, changing a type, changing what an + existing value _means_, or restructuring the envelope — all of it goes to a new version path. + `v1` keeps serving its existing contract until it is deliberately retired, which would be + announced, not silent. + +**A methodology change is not an API change.** If we change a formula, a threshold, or a weight, +`safetyScore` is still a 0–100 number meaning the same thing, so the contract holds and the version +does not move. What moves is `methodologyVersion` in the response body — see +[The score](#the-score). A change to the factor _taxonomy_, though — renaming or removing one of the +five factors — is breaking, and would be a `v2`. + +--- + +## Two commitments + +**The public registry data is free, and stays free.** The score, the factor breakdown, the history, +and these endpoints are public and unmetered beyond the rate limit below. Stenion's paid tiers add +capability — private tooling, faster refresh, visibility placement — and they never gate access to +anything that is already public, and never change a score. The ranked registry is sorted purely on +`safetyScore` with no paid exceptions. This is a project rule enforced in code and review, not a +launch promise. + +**Nothing here is an endorsement.** A `safetyScore` is analysis of on-chain state, not a +recommendation, a rating, an audit, or financial advice. Protocol names, logos, links, and contract +ids appear as the subject's own properties. Displaying a protocol does not imply endorsement, +partnership, or any relationship between Stenion and that protocol — in either direction — and +integrating this API does not create one either. + +--- + +## Quick start + +```bash +# every protocol, ranked +curl https://stenion.vercel.app/api/v1/protocols + +# one protocol, with factors and run history +curl https://stenion.vercel.app/api/v1/protocol/blend +``` + +--- + +## GET /api/v1/protocols + +The leaderboard: every protocol Stenion tracks, with its latest score. Ranked by `safetyScore` +descending, with never-scored protocols last. + +**Request** + +```bash +curl https://stenion.vercel.app/api/v1/protocols +``` + +**Response** `200 OK` + +```json +{ + "protocols": [ + { + "id": "blend", + "name": "Blend", + "chain": "stellar", + "logo": "/assets/protocols/blend.svg", + "safetyScore": 53, + "computedAt": "2026-08-19T13:20:06.991Z", + "lastRunAt": "2026-08-19T13:20:04.336Z", + "lastRunStatus": "ok" + }, + { + "id": "kinetic", + "name": "Kinetic", + "chain": "stellar", + "logo": "/assets/protocols/kinetic.png", + "safetyScore": 27, + "computedAt": "2026-08-19T13:20:12.614Z", + "lastRunAt": "2026-08-19T13:20:07.182Z", + "lastRunStatus": "ok" + } + ] +} +``` + +| Field | Type | Notes | +| --------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | Stable identifier, **case-sensitive**, used as the path segment on the detail endpoint. | +| `name` | string | Display name. | +| `chain` | string | Currently always `"stellar"`. | +| `logo` | string or null | Root-relative path to a mark **Stenion hosts** — prefix with the base host. `null` is a normal state, not a broken image. | +| `safetyScore` | number or null | 0–100, higher = safer. From the latest **`ok`** run. `null` means never successfully scored — not "zero", not "unsafe". | +| `computedAt` | string or null | ISO 8601 UTC. When that score was computed. `null` if and only if `safetyScore` is `null`. | +| `lastRunAt` | string or null | ISO 8601 UTC. The most recent run of **any** status. See [Staleness](#staleness-is-your-problem-too). | +| `lastRunStatus` | `"ok"`, `"failed"`, null | Status of that most recent run. `null` means the protocol has never been run at all. | + +The board deliberately carries no `contractId`, `site`, or `docs` — those are verification detail +nobody acts on from a list, and repeating them on every row of every fetch is waste. They live on +the detail response. + +--- + +## GET /api/v1/protocol/:id + +One protocol: metadata, the current score, the full factor breakdown, and recent run history. + +**Request** + +```bash +curl https://stenion.vercel.app/api/v1/protocol/blend +``` + +**Response** `200 OK` + +> `history` is truncated to 3 entries below for readability. The live response returns up to **50** +> rows, newest first. Everything else is verbatim. + +```json +{ + "id": "blend", + "name": "Blend", + "chain": "stellar", + "adapter": "BlendAdapter", + "logo": "/assets/protocols/blend.svg", + "contractId": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + "site": "https://www.blend.capital", + "docs": "https://docs.blend.capital", + "safetyScore": 53, + "computedAt": "2026-08-19T13:20:06.991Z", + "factors": { + "oracleSafety": { + "value": 99, + "detail": "all 3 reserves score the same — 306s old (fresh<300s, dead>900s); all reserves have a deviation bound", + "weight": 0.25, + "components": [ + { + "id": "priceFreshness", + "label": "Price freshness", + "value": 99, + "detail": "all 3 reserves score the same — 306s old (fresh<300s, dead>900s); anchored to the aggregator's own resolution and max_age (900s)" + }, + { + "id": "deviationBound", + "label": "Deviation bound", + "value": 100, + "detail": "all 3 reserves score the same — CAS3J7… bounded at 60% per 300s step; CCW67T… bounded at 20% per 300s step; CDTKPW… bounded at 20% per 300s step" + }, + { + "id": "priceAges", + "label": "Price age by feed (not scored)", + "value": null, + "detail": "Other:XLM 306s, Other:USDC 306s, Other:EURC 306s — all 3 within the protocol's own 900s staleness limit. Reported, not graded: priceFreshness already scores the worst of these." + }, + { + "id": "deviationTightness", + "label": "Bound tightness (not scored)", + "value": null, + "detail": "per-reserve max_dev: CAS3J7… 60%, CCW67T… 20%, CDTKPW… 20%. Measured against the previous upstream record, so this bounds movement per publish interval. Reported, not graded — see METHODOLOGY.md §2." + } + ] + }, + "adminKeySafety": { + "value": 40, + "detail": "single-key admin (1 signer(s), high-threshold 0), 0 op(s) in 30d", + "weight": 0.2 + }, + "liquiditySafety": { + "value": 23, + "detail": "worst reserve (CCW67T…) has 23% of supply as free liquidity", + "weight": 0.15 + }, + "collateralSafety": { + "value": 71, + "detail": "top reserve holds 64% of supplied value across 3 reserves (HHI 0.53)", + "weight": 0.2 + }, + "utilizationSafety": { + "value": 14, + "detail": "worst reserve (CCW67T…) at 77% util vs 90% cap", + "weight": 0.2 + } + }, + "methodologyVersion": 1, + "lastRunAt": "2026-08-19T13:20:04.336Z", + "lastRunStatus": "ok", + "history": [ + { + "status": "ok", + "safetyScore": 53, + "methodologyVersion": 1, + "computedAt": "2026-08-19T13:20:06.991Z", + "runAt": "2026-08-19T13:20:04.336Z" + }, + { + "status": "ok", + "safetyScore": 53, + "methodologyVersion": 1, + "computedAt": "2026-08-19T13:15:07.978Z", + "runAt": "2026-08-19T13:15:05.118Z" + }, + { + "status": "ok", + "safetyScore": 53, + "methodologyVersion": 1, + "computedAt": "2026-08-19T13:10:06.937Z", + "runAt": "2026-08-19T13:10:04.295Z" + } + ] +} +``` + +| Field | Type | Notes | +| ----------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `id`, `name`, `chain`, `logo` | | Same as the leaderboard. | +| `adapter` | string | Which Stenion adapter produced the score. Informational. | +| `contractId` | string or null | The Soroban contract the score was derived from. A raw `C…` address, deliberately **not** an explorer URL — pick your own. | +| `site`, `docs` | string or null | The protocol's own links. Listed as its properties, not as a recommendation. | +| `safetyScore`, `computedAt` | | Latest **`ok`** run. Both `null` if never successfully scored. | +| `factors` | object or null | The five-factor breakdown, or `null` if never scored. See below. | +| `methodologyVersion` | number or null | Which rulebook version the current score was computed under. | +| `lastRunAt`, `lastRunStatus` | | Newest run of any status. See [Staleness](#staleness-is-your-problem-too). | +| `history` | array | Up to 50 recent runs, newest first. **A discriminated union — see below.** | + +### The `factors` object + +Five keys, always all five present, defined once as a shared taxonomy so they mean the same thing +for every protocol: `collateralSafety`, `oracleSafety`, `adminKeySafety`, `liquiditySafety`, +`utilizationSafety`. + +Each is either a factor object or `null` (the factor genuinely does not apply to that protocol — +render "N/A", do not treat it as zero). + +| Field | Type | Notes | +| ------------ | --------------- | ---------------------------------------------------------------------------------------- | +| `value` | number | 0–100, **higher = safer** — the same direction as the overall score. | +| `weight` | number | This factor's share of the overall score. Weights of all non-`null` factors sum to 1. | +| `detail` | string | Human-readable, includes the raw on-chain figure it came from. Safe to surface directly. | +| `components` | array, optional | Sub-signals behind `value`. Absent on a factor computed from a single signal. | + +A component with a non-`null` `value` is a scored sub-signal that fed the parent. A component with +`value: null` is a **disclosure** — a real on-chain quantity published deliberately ungraded, +because scoring it would invent comparability the underlying data does not support. Its `detail` +carries the figure. Treat `components` as additive: it may gain entries on `v1`. + +Every factor name ends in `*Safety`, and every one is 0–100 higher-is-safer. There is no factor +anywhere in this API where a bigger number is worse. + +--- + +## History rows are a discriminated union — read this one + +This is the single most likely thing to get wrong, so it gets its own section. + +`history[]` entries are **not** a uniform shape with nullable fields. They are a union discriminated +on `status`: + +- an **`ok`** row carries `safetyScore`, `methodologyVersion`, `computedAt`, and `runAt`. +- a **`failed`** row carries `error` and `runAt`, and **does not have a `safetyScore` key at all** — + not `null`, not `0`. The key is absent. + +That absence is deliberate. A failed run is a **gap in our data**, never a score of zero, and giving +it a `safetyScore: 0` would let a pipeline outage render as a protocol suddenly becoming maximally +dangerous. + +```ts +type HistoryEntry = + | { + status: 'ok'; + safetyScore: number; + methodologyVersion: number; + computedAt: string; + runAt: string; + } + | { + status: 'failed'; + error: string; + runAt: string; + }; +``` + +A `failed` row looks like this. **This example is constructed from the schema, not captured live** — +no production run has ever failed, so there is no real one to show you. The shape is enforced by a +database `CHECK` constraint and pinned by tests, but we are not going to present a fabricated body +as a live capture: + +```json +{ + "status": "failed", + "error": "Blend: simulation of lastprice failed", + "runAt": "2026-08-19T13:20:04.336Z" +} +``` + +**Do this** — branch on `status` and let a failure be a gap: + +```ts +for (const entry of detail.history) { + if (entry.status === 'ok') { + plot(entry.runAt, entry.safetyScore); + } else { + markGap(entry.runAt, entry.error); + } +} +``` + +**Not this** — it silently plots a zero for every failed run, drawing a cliff that never happened: + +```ts +// WRONG +for (const entry of detail.history) { + plot(entry.runAt, entry.safetyScore ?? 0); +} +``` + +`error` is our own message, and it is meant to be readable. It describes **our** failure to read the +chain — an RPC timeout, a decode error — and says nothing about the protocol's safety. Do not +surface it as a risk signal. + +--- + +## Staleness is your problem too + +Stenion re-scores every ~5 minutes. Runs can fail. The API is built to be honest about that rather +than to paper over it, which means you get two independent pieces of information: + +- **`safetyScore` / `computedAt`** — the last score we computed **successfully**. +- **`lastRunAt` / `lastRunStatus`** — the most recent run attempt, of **any** outcome. + +When `lastRunStatus` is `"ok"`, these agree and there is nothing to think about. + +**When `lastRunStatus` is `"failed"`, the score you are holding is still real, but our data is older +than it looks.** It is the last one we successfully computed — at `computedAt` — and we have since +tried and failed to refresh it. The gap between `computedAt` and now is how stale the number +actually is, and `lastRunAt` tells you we were still trying. + +```ts +const stale = detail.lastRunStatus === 'failed'; +const scoreAgeMs = detail.computedAt ? Date.now() - Date.parse(detail.computedAt) : null; +``` + +We think an integrator should surface that to their own users rather than absorb it silently — a +safety number that quietly stopped updating is worse than one labelled as stale, because a user acts +on it either way. Our own registry does this: a failed run gets a pill, a caption, and both +timestamps on the protocol page. + +One deliberate detail worth copying: **never colour a staleness marker with the score bands.** +Green/amber/red mean risk level here. Painting a pipeline fault amber reports our outage as a verdict +on the protocol. + +`safetyScore: null` together with `lastRunStatus: "failed"` means we have **never** had a good score +for that protocol. Render it as unknown. It is not a zero. + +--- + +## The score + +`safetyScore` is **0 to 100, higher is safer**. It is a weighted mean of the five factors, each of +which is also 0–100 higher-is-safer. + +How each factor is computed — every formula, threshold, and weight — is in the +[Methodology](METHODOLOGY.md), which is the public, challengeable rulebook and the source of truth. +It is deliberately not restated here, so that this page cannot drift from it. + +`methodologyVersion` is stamped onto every score at the moment it is computed, and history rows +carry their own. **Scores computed under different methodology versions are not comparable.** If you +chart history, treat a change in `methodologyVersion` between adjacent points as a discontinuity in +the rules, not a real move in risk. History is never backfilled — we label the break rather than +hide it. The current version is `1`. + +--- + +## Caching + +Both `/v1` read routes are served through a CDN, with 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: a cached response can hide a newer indexer run by at most 10 seconds.** + +What you will actually observe on a `200`: + +```http +Cache-Control: public, max-age=0 +Age: 3 +X-Vercel-Cache: HIT +``` + +The `s-maxage` directive that drives the TTL is consumed by the CDN and does not reach you, so do +not look for it. `Age` is how long the copy you received has been sitting in the cache — subtract it +from `Date` if you want the true age of the response. `max-age=0` is intentional: private browser +caches are deliberately kept out, so a copy's real age never exceeds what `Age` reports. There is no +`stale-while-revalidate`, also intentional — it works by serving a body past its deadline, which is +the exact masking described above. + +Errors, `404`s, and `429`s are `no-store`. + +**Polling advice:** the data changes every ~5 minutes, so polling faster than that buys you nothing +but cache hits. Once a minute is generous. Note that the cache key includes the query string, so +adding `?t=` to defeat the cache does not get you fresher data — it just guarantees a cache +miss and pushes you toward the rate limit. + +--- + +## Rate limits + +**60 requests per minute per client, with a burst of 60**, as a token bucket. + +The important and slightly unusual property: **only cache misses count.** The limiter runs inside +the function, and the CDN only invokes the function on a miss. So the documented limit is not a cap +on how many requests you may make — a client polling a cached endpoint can exceed it all day and +never be refused, because we never see those requests. What it bites is the client that defeats the +cache, where every request is a database query. + +Clients are identified by IP. **Behind a shared NAT you share a bucket** with everyone else on that +address — survivable in practice because NAT'd browser traffic overwhelmingly hits the CDN. We store +a salted hash of the address, never the address itself; this is a limiter, not an access log. + +The limiter **fails open**. If its own machinery breaks, requests are allowed rather than refused — a +broken guard rail must not become a broken API. + +### 429 Too Many Requests + +A real refusal, captured live: + +```http +HTTP/1.1 429 Too Many Requests +Content-Type: application/json; charset=utf-8 +Retry-After: 2 +X-RateLimit-Limit: 60 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1787145533 +Cache-Control: no-store +Access-Control-Allow-Origin: * +``` + +```json +{ + "error": "Too many requests. This endpoint is rate limited per client; retry after the wait in the Retry-After header.", + "retryAfter": 2 +} +``` + +| Header | Meaning | +| ----------------------- | ------------------------------------------------------------------------------ | +| `Retry-After` | **Seconds** to wait. This is the one to back off on. | +| `X-RateLimit-Limit` | The sustained per-minute allowance. | +| `X-RateLimit-Remaining` | Always `0` — this header only ships on a refusal. | +| `X-RateLimit-Reset` | **Unix epoch seconds**, the GitHub convention — an absolute time, not a delta. | + +`retryAfter` in the body carries the same seconds value as the `Retry-After` header, for clients that +find it easier to read the body. + +**How to back off:** + +```ts +async function get(url: string): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const res = await fetch(url); + if (res.status !== 429) return res; + const wait = Number(res.headers.get('retry-after') ?? 1); + await new Promise((resolve) => setTimeout(resolve, wait * 1000)); + } + throw new Error('stenion: still rate limited after 5 attempts'); +} +``` + +Honour `Retry-After` rather than retrying immediately or on a fixed schedule — the value is computed +from your actual token balance, so it is the shortest correct wait. + +**These headers are absent on a `200`.** That is deliberate, not an oversight: a `200` is +shared-cached and served to many clients, so an `X-RateLimit-Remaining` baked into one would be a +single client's balance, frozen and replayed to everybody — a number wrong for every reader, +including the one it came from. You learn your standing the one time it matters, which is when you +are refused. + +--- + +## Errors + +Every error a consumer can hit, with the real body. + +### 404 Not Found — unknown protocol id + +```bash +curl https://stenion.vercel.app/api/v1/protocol/does-not-exist +``` + +```json +{ "error": "Protocol not found", "id": "does-not-exist" } +``` + +The id you asked for is echoed back. Ids are **case-sensitive** — `/protocol/BLEND` is a `404`, and +that is the most common cause of an unexpected one. + +A `404` is `no-store` and never cached, on purpose: a protocol added in the next cycle would +otherwise keep 404ing out of a shared cache after it went live. + +### 429 Too Many Requests + +See [Rate limits](#rate-limits) above. + +### 500 Internal Server Error + +```json +{ "error": "Internal server error" } +``` + +Deliberately generic — the underlying error is logged server-side and never leaked. Never cached, so +a `500` cannot outlive the outage that caused it. Retry with backoff. + +### 405 Method Not Allowed + +Both routes are `GET` (plus `HEAD` and `OPTIONS`) only. Any other method returns `405` with an empty +body. + +### Two rough edges, stated rather than hidden + +- **`GET /api/v1/protocol` with no id returns an HTML `404`, not JSON.** No route matches, so the + site's own not-found page is served. If you build the URL by concatenation, guard against an empty + id — a JSON parse of that response will throw something unhelpful. +- **A `404` from a path that matches no route at all** (`/api/v2/protocols`, the removed + `/api/protocols`) is likewise HTML. JSON error bodies come from paths that matched a route. + +So: **branch on `res.status` before parsing, not the other way round.** + +```ts +const res = await fetch('https://stenion.vercel.app/api/v1/protocol/blend'); +if (!res.ok) { + // A 404/429/500 from a matched route is JSON; anything else may be HTML. + throw new Error(`stenion: ${res.status}`); +} +const detail = await res.json(); +``` + +--- + +## CORS + +Both read routes send `Access-Control-Allow-Origin: *` and answer the preflight, so browser clients +on any origin can call them directly — no proxy needed. Allowed methods are `GET, OPTIONS`; the only +allowed request header is `content-type`. Preflights are cached for a day. + +The data is public, read-only, and payment-blind, so `*` is the correct policy here rather than a +shortcut. + +--- + +## Not in this API + +Stated so you do not go looking: + +- **No pagination, filtering, or sort parameters.** Two protocols are tracked today; the leaderboard + is one small response and returns everything, already ranked. If the set grows enough to need + paging, that is an additive change and would arrive on `v1` with a documented default. +- **No historical range query.** `history` is the most recent 50 runs, fixed. There is no `?from=` + or `?limit=`. +- **No factor history.** History rows carry the overall score, not the factor breakdown. The factors + are stored, so this could be added additively — open an issue if you need it. +- **No webhooks or streaming.** Poll. +- **No authentication.** There is nothing to authenticate; it is all public. + +--- + +## Questions, bugs, and disputes + +Stenion is open source — the route handlers behind this document are +[`dashboard/app/api/v1/`](dashboard/app/api/v1/), and if the code and this page ever disagree, that +is a bug worth an issue. + +If you are a protocol being scored and think a threshold is wrong, +[`METHODOLOGY.md`](METHODOLOGY.md) is the rulebook and it tells you how to dispute it. Payment is not +a route to a better number, and never will be. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 310f32b..5446cd4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -229,9 +229,19 @@ not folded in here. **`@stenion/dashboard`** — a Next.js 15 (App Router) site, and the actual deployment target. It's three things in one Vercel project: -1. The public site (homepage, registry, on-site methodology, about, per-protocol detail pages). - Data pages are async Server Components that read `@stenion/db`'s `Store` **in-process** — no - HTTP hop. +1. The public site (homepage, registry, on-site methodology, on-site API docs, about, per-protocol + detail pages). Data pages are async Server Components that read `@stenion/db`'s `Store` + **in-process** — no HTTP hop. + + **Rendered docs** (`/methodology`, `/docs/api`) are a second, separate kind of page: they read a + repo-root markdown file at request time and render it through `components/markdown-doc.tsx`, so + the file stays the single source of truth and is readable both on GitHub and on the site. Each + such route needs an `outputFileTracingIncludes` entry in `next.config.mjs`, because the file + lives outside the dashboard directory and would otherwise be missing from the serverless bundle + — a failure that is invisible in `next dev`, where the file is simply on disk. `MarkdownDoc` + adds heading anchors, wraps tables in their own scroll container, gives code fences a copy + button, and rewrites repo-relative links to the GitHub source **except** for files that are + themselves rendered here (`app/lib/site.ts`'s `RENDERED_DOC_ROUTES`), which stay on-site. The protocol page's **score-history chart** is a client component drawing hand-rolled SVG (no charting library) over the `history` array the detail response already carries — it adds no @@ -555,6 +565,16 @@ The public API is versioned in the URL. The documented, canonical paths are: | `GET /api/v1/protocols` | The leaderboard: every protocol + its latest score. | | `GET /api/v1/protocol/:id` | One protocol's detail, factors, and run history. | +**The consumer-facing reference is [`API.md`](API.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 +response examples, the `ok`/`failed` history union, the staleness model, error shapes, and the +observable caching/rate-limit headers. Its examples are captured from the live production API +rather than written from the types, deliberately: a doc written from `db/src/store.ts` would +reproduce the type rather than the truth. **Re-capture them when a response shape changes** — +and note that what a client actually observes is not always what a route sets (Vercel's CDN +consumes `s-maxage`, so a `200` reaches the client as `Cache-Control: public, max-age=0` plus +`Age`). + **The policy:** - **Additive changes stay on `v1`.** A new field in the response — a sixth `*Safety` factor, an diff --git a/CLAUDE.md b/CLAUDE.md index 4800fd8..f0183fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,8 @@ here** — each public doc owns its content: - **[`README.md`](README.md)** — what Stenion is, the pitch, local quick-start. - **[`ARCHITECTURE.md`](ARCHITECTURE.md)** — monorepo layout, what each package does, data flow, deploy. - **[`METHODOLOGY.md`](METHODOLOGY.md)** — the source of truth for every factor's formula, thresholds, weights. +- **[`API.md`](API.md)** — the public API contract as a consumer meets it: endpoints, live example + responses, the `ok`/`failed` union, staleness, rate limits, errors. Rendered at `/docs/api`. - **[`CONTRIBUTING.md`](CONTRIBUTING.md)** — how to write an adapter, conventions, PR expectations. - **[`ROADMAP.md`](ROADMAP.md)** — what's live, what's planned, what's out of scope, and open taxonomy questions. @@ -27,6 +29,9 @@ These override any default behavior and are enforced in code and review: - **Adapters read trustless on-chain data** (Soroban RPC + Horizon) — never self-reported figures. - **No fabricated numbers.** When real data isn't available for a factor, use a clearly-flagged neutral baseline (e.g. `adminKeySafety`'s contract-admin `60`) — never an invented value. +- **`API.md`'s example responses are captured live, never written from the types.** A doc written + from `db/src/store.ts` reproduces the type rather than the truth, and what a client observes is + not always what a route sets (the CDN eats `s-maxage`). Re-`curl` them when a shape changes. - **Code and `METHODOLOGY.md` are not allowed to drift.** Any change to a formula/threshold/weight changes both together, at the same review bar. Shared rulebook logic that two adapters would otherwise duplicate lives in [`core/src/scoring.ts`](core/src/scoring.ts), so it can't drift @@ -101,7 +106,10 @@ These override any default behavior and are enforced in code and review: ## Deploy architecture (summary — full detail in `ARCHITECTURE.md`) -One Vercel project = the `dashboard`. The API lives as Next.js Route Handlers +One Vercel project = the `dashboard`. A page that renders a repo-root markdown file (`/methodology` +→ `METHODOLOGY.md`, `/docs/api` → `API.md`) **must** have an `outputFileTracingIncludes` entry in +`next.config.mjs` — the file is outside the dashboard dir, and without it the route works in +`next dev` and fails only in production. The API lives as Next.js Route Handlers (`/api/v1/protocols`, `/api/v1/protocol/[id]` — versioned; there are **no** unversioned paths, the former transitional aliases were removed and now 404, and the versioning policy lives in `ARCHITECTURE.md`); the dashboard's own pages read `@stenion/db`'s `Store` diff --git a/README.md b/README.md index 7f19c1f..dc3c25f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Live, on-chain risk intelligence for Stellar/Soroban DeFi.** -[stenion.vercel.app](https://stenion.vercel.app) · [Methodology](METHODOLOGY.md) · [Architecture](ARCHITECTURE.md) · [Contributing](CONTRIBUTING.md) · [Roadmap](ROADMAP.md) +[stenion.vercel.app](https://stenion.vercel.app) · [API](API.md) · [Methodology](METHODOLOGY.md) · [Architecture](ARCHITECTURE.md) · [Contributing](CONTRIBUTING.md) · [Roadmap](ROADMAP.md) --- @@ -91,13 +91,19 @@ real scores at `http://localhost:3000`. The public API is served by the dashboar ### Using the public API +**The full reference is [`API.md`](API.md)** — both endpoints with live example responses, the +`ok`/`failed` history union, the staleness model, error shapes, and the versioning commitment. It's +also rendered on the site at [/docs/api](https://stenion.vercel.app/docs/api). The summary: + It's free, open, needs no key, and allows any origin. Two things to know before you build against it: -- **It's cached, briefly.** Responses carry `Cache-Control` with an `s-maxage` between 10 and 45 - seconds, computed per response so a cached body can never hide a newer indexer run by more than - 10 seconds. Check the `Age` header if you need to know exactly how old a response is. Scores only - change every ~5 minutes, so **polling faster than once a minute gains you nothing.** +- **It's cached, briefly.** The TTL is computed per response, between 10 and 45 seconds, so a + cached body can never hide a newer indexer run by more than 10 seconds. The `s-maxage` that + drives it is consumed by the CDN and never reaches you — what you see is + `Cache-Control: public, max-age=0` plus an `Age` header, which is the one to read if you need to + know exactly how old a response is. Scores only change every ~5 minutes, so **polling faster than + once a minute gains you nothing.** - **It's rate limited: 60 requests/minute per client, with a burst of 60.** Only requests that miss the cache count, so ordinary polling will never come close. Over the limit you get a `429` with a `Retry-After` header in seconds — honour it and you'll be served immediately. `X-RateLimit-Limit` diff --git a/ROADMAP.md b/ROADMAP.md index 5c703b1..ac7bfc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,6 +45,12 @@ commitment — priorities shift as protocols launch and as the project finds fun hidden**: a run that ultimately fails is still recorded as `failed`. The consecutive-failure streak is derived from `risk_scores` rather than stored in a counter, so it needs no new table and cannot disagree with the history it describes. See [`ARCHITECTURE.md`](ARCHITECTURE.md). +- **Public API documentation.** [`API.md`](API.md), rendered on the site at `/docs/api`. Until now + the only way to learn the contract was reading the route handlers on GitHub, which is a barrier + for exactly the wallet-integrator audience the API exists for. Covers both endpoints with live + captured examples, the versioning commitment (additive stays on `v1`, breaking gets a `v2`), the + `ok`/`failed` history union, the staleness model, rate limits, and error shapes. Every example is + a verbatim capture from production rather than written from the types. - **The full stack:** on-chain adapters → indexer → Postgres → API → dashboard, deployed as a single Vercel project with external (cron-job.org) scheduling. See [`ARCHITECTURE.md`](ARCHITECTURE.md). diff --git a/dashboard/app/docs/api/page.tsx b/dashboard/app/docs/api/page.tsx new file mode 100644 index 0000000..00eaf83 --- /dev/null +++ b/dashboard/app/docs/api/page.tsx @@ -0,0 +1,82 @@ +import type { Metadata } from 'next'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { ExternalLink, FileWarning } from 'lucide-react'; +import { MarkdownDoc } from '../../../components/markdown-doc'; +import { API_DOCS_SOURCE_URL } from '../../lib/site'; +import { Reveal } from '../../../components/reveal'; + +export const metadata: Metadata = { + title: 'API', + description: + 'The free, public, read-only Stenion API: two endpoints, live examples, the ok/failed history union, the staleness model, and rate limits.', +}; + +// Same shape as /methodology: the doc lives at the repo root as the single +// source of truth (it's what a developer lands on first from the README, and +// GitHub renders it), and this route renders that same file rather than keeping +// a second copy in the app. next.config pins outputFileTracingRoot to the repo +// root and includes API.md for this route, so it's in the serverless bundle. +async function loadApiDocs(): Promise { + try { + const p = path.join(process.cwd(), '..', 'API.md'); + return await readFile(p, 'utf8'); + } catch { + return null; + } +} + +export default async function ApiDocsPage() { + const source = await loadApiDocs(); + + return ( +
+ + + For integrators + +

+ Public API +

+

+ Two read-only endpoints, no key, open CORS. Every example below is a verbatim capture from + the live production API — not written from the types — so the shapes are checkable rather + than merely plausible. The data is free and stays free. +

+ + View source on GitHub + +
+ + + {source ? ( + + ) : ( +
+ +

+ Couldn't load the API docs +

+

+ The rendered copy is temporarily unavailable. You can always read the canonical source + on GitHub. +

+ + Open API.md + +
+ )} +
+
+ ); +} diff --git a/dashboard/app/lib/site.ts b/dashboard/app/lib/site.ts index 4f4dc2c..e01e74d 100644 --- a/dashboard/app/lib/site.ts +++ b/dashboard/app/lib/site.ts @@ -2,10 +2,38 @@ // in one place. export const GITHUB_URL = 'https://github.com/stenion-lab/stenion'; export const METHODOLOGY_SOURCE_URL = `${GITHUB_URL}/blob/main/METHODOLOGY.md`; +export const API_DOCS_SOURCE_URL = `${GITHUB_URL}/blob/main/API.md`; +/** + * Repo-root markdown files that are ALSO rendered as a route on this site, + * mapped to that route. + * + * Used by MarkdownDoc to keep a cross-reference between two rendered docs on the + * site instead of bouncing the reader out to raw markdown on GitHub. Only add a + * file here once it actually has a route — an entry for a file we don't render + * produces a 404 that the GitHub fallback would not have. + */ +export const RENDERED_DOC_ROUTES: Record = { + 'METHODOLOGY.md': '/methodology', + 'API.md': '/docs/api', +}; + +/** + * The header/footer nav. + * + * API is a TOP-LEVEL item rather than a "Docs" group holding it and Methodology. + * Five items still fit the bar, and the two candidates for grouping are the two + * things people come here to find: Methodology is the differentiator (the public + * rulebook), and API is the whole pitch to integrators. Filing both under a + * generic "Docs" label hides them behind an extra click and a word that says + * nothing. The URL is still nested — `/docs/api`, so adapter-authoring docs can + * land at `/docs/*` later without moving this one — but the nesting doesn't have + * to surface in the nav. + */ export const NAV_LINKS = [ { href: '/', label: 'Home' }, { href: '/registry', label: 'Registry' }, { href: '/methodology', label: 'Methodology' }, + { href: '/docs/api', label: 'API' }, { href: '/about', label: 'About' }, ] as const; diff --git a/dashboard/components/code-block.tsx b/dashboard/components/code-block.tsx new file mode 100644 index 0000000..2701c3f --- /dev/null +++ b/dashboard/components/code-block.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { Check, Copy } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * A rendered markdown code fence with a copy button. + * + * Exists for API.md, where every block is something an integrator is going to + * paste — a curl line, a JSON body, a TypeScript snippet. It is wired into + * MarkdownDoc's `pre` override, so METHODOLOGY.md's blocks get it too; that is + * fine (its formula blocks are also worth copying) and it keeps one code-block + * treatment across the site rather than two. + * + * `raw` is the flattened text, passed in from MarkdownDoc rather than read back + * out of the DOM: the children here are React nodes, and re-deriving the string + * from a ref would mean trusting whatever whitespace the browser reports. + * + * NO SYNTAX HIGHLIGHTING, deliberately. Every option is a new dependency + * (highlight.js, shiki, prism) for colour on a handful of blocks, and this + * project doesn't add dependencies for polish. The blocks are short, fenced with + * a language for anyone reading the source on GitHub, and legible without it. + */ +export function CodeBlock({ raw, children }: { raw: string; children: React.ReactNode }) { + const [copied, setCopied] = useState(false); + const timer = useRef | null>(null); + + // The confirmation is a timeout, so it has to be cancelled if the component + // goes away first (route change mid-flight) or the state set would leak. + useEffect(() => { + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, []); + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(raw); + } catch { + // Clipboard is permission-gated and unavailable on insecure origins. There + // is nothing useful to say and nothing to retry — the text is right there + // and selectable — so fail silently rather than throwing an error toast at + // someone who can just select it. + return; + } + setCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1600); + }, [raw]); + + return ( + // This component OWNS the
. react-markdown's `pre` override is handed the
+    // element's *children* (the ), not the element, so rendering only the
+    // wrapper here would drop 
 from the output entirely — taking monospace,
+    // whitespace preservation and prose's `overflow-x: auto` with it, which is
+    // exactly what keeps a long JSON body from widening the page on a phone.
+    //
+    // `my-0` cancels prose's own pre margins and moves them to the wrapper, so the
+    // absolutely-positioned button anchors to the code block's real top edge
+    // rather than depending on margin collapsing through this div.
+    //
+    // The `pr-*` reserves the button's own footprint, so a short first line — an
+    // `http` header, an opening brace — can't render underneath it. It widens at
+    // `sm` because that's where the button grows a text label. A line long enough
+    // to scroll still passes under the button, which is why the button is opaque.
+    
+
{children}
+ +
+ ); +} diff --git a/dashboard/components/footer.tsx b/dashboard/components/footer.tsx index f549081..1534fff 100644 --- a/dashboard/components/footer.tsx +++ b/dashboard/components/footer.tsx @@ -37,6 +37,9 @@ export function Footer() { Methodology + + API docs + diff --git a/dashboard/components/markdown-doc.tsx b/dashboard/components/markdown-doc.tsx index e6d486b..0fac59b 100644 --- a/dashboard/components/markdown-doc.tsx +++ b/dashboard/components/markdown-doc.tsx @@ -1,6 +1,7 @@ import Markdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { GITHUB_URL } from '../app/lib/site'; +import { GITHUB_URL, RENDERED_DOC_ROUTES } from '../app/lib/site'; +import { CodeBlock } from './code-block'; const REPO_BLOB = `${GITHUB_URL}/blob/main`; @@ -43,12 +44,33 @@ const components: Components = { {children}
), + // Fenced code blocks get a copy button. react-markdown hands `pre` a single + // `code` child, so the raw text is flattened here rather than inside the client + // component — it has the React nodes, and a DOM read-back would be at the mercy + // of whatever whitespace the browser reports. + pre: ({ children }) => {children}, a: ({ href, children }) => { const h = href ?? ''; if (h.startsWith('#')) return {children}; - const url = /^https?:\/\//.test(h) ? h : `${REPO_BLOB}/${h.replace(/^\.?\//, '')}`; + if (/^https?:\/\//.test(h)) { + return ( + + {children} + + ); + } + // A repo-relative link. Most resolve to the GitHub source, because most of + // the docs a rendered page references (ARCHITECTURE.md, an adapter file) only + // exist there. The exception is a doc that IS rendered on this site: sending + // a reader from /docs/api out to raw markdown on GitHub for the methodology + // is worse than keeping them here, so those few map to their own route. + // Written as one relative link so the same file still resolves on GitHub. + const bare = h.replace(/^\.?\//, ''); + const [file, hash] = bare.split('#'); + const route = RENDERED_DOC_ROUTES[file]; + if (route) return {children}; return ( - + {children} ); diff --git a/dashboard/next.config.mjs b/dashboard/next.config.mjs index 2387ebc..cdcd37a 100644 --- a/dashboard/next.config.mjs +++ b/dashboard/next.config.mjs @@ -20,12 +20,14 @@ const nextConfig = { // the supported path for the Node.js runtime. serverExternalPackages: ['pg', '@stellar/stellar-sdk'], - // The /methodology route reads the repo-root METHODOLOGY.md at request time - // (single source of truth, not duplicated). It lives outside the dashboard dir, - // so include it explicitly in that route's serverless bundle or the read 404s - // on Vercel. + // The /methodology and /docs/api routes read their repo-root markdown file at + // request time (single source of truth, not duplicated). Those files live + // outside the dashboard dir, so include each explicitly in its route's + // serverless bundle or the read 404s on Vercel — the failure is silent in dev, + // where the file is simply there on disk. outputFileTracingIncludes: { '/methodology': ['../METHODOLOGY.md'], + '/docs/api': ['../API.md'], }, };