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
606 changes: 606 additions & 0 deletions API.md

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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`
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
82 changes: 82 additions & 0 deletions dashboard/app/docs/api/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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 (
<div className="mx-auto max-w-3xl px-5 py-14">
<Reveal>
<span className="text-xs font-medium uppercase tracking-[0.18em] text-muted">
For integrators
</span>
<h1 className="mt-5 font-display text-4xl font-semibold tracking-tight text-ink">
Public API
</h1>
<p className="mt-3 text-muted">
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.
</p>
<a
href={API_DOCS_SOURCE_URL}
target="_blank"
rel="noreferrer"
className="mt-5 inline-flex items-center gap-1.5 rounded-lg border border-line-strong px-4 py-2 text-sm text-ink transition-colors hover:border-accent"
>
View source on GitHub <ExternalLink className="h-3.5 w-3.5" />
</a>
</Reveal>

<Reveal delay={0.08} className="mt-12">
{source ? (
<MarkdownDoc source={source} />
) : (
<div className="rounded-xl border border-danger/25 bg-danger/5 p-8 text-center">
<FileWarning className="mx-auto h-8 w-8 text-danger" />
<h2 className="mt-4 font-display text-lg font-semibold text-ink">
Couldn&apos;t load the API docs
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
The rendered copy is temporarily unavailable. You can always read the canonical source
on GitHub.
</p>
<a
href={API_DOCS_SOURCE_URL}
target="_blank"
rel="noreferrer"
className="mt-5 inline-flex items-center gap-1.5 text-sm text-accent-ink hover:underline"
>
Open API.md <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
</Reveal>
</div>
);
}
28 changes: 28 additions & 0 deletions dashboard/app/lib/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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;
88 changes: 88 additions & 0 deletions dashboard/components/code-block.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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 <pre>. react-markdown's `pre` override is handed the
// element's *children* (the <code>), not the element, so rendering only the
// wrapper here would drop <pre> 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.
<div className="group relative my-6 [&>pre]:my-0 [&>pre]:pr-12 sm:[&>pre]:pr-24">
<pre>{children}</pre>
<button
type="button"
onClick={copy}
// Always visible rather than hover-only: half this page's readers are on a
// phone, where there is no hover and a hidden affordance is no affordance.
// Opaque background because the block scrolls horizontally underneath it.
className="cursor-pointer absolute right-2 top-2 inline-flex items-center gap-1.5 rounded-md border border-line bg-surface-2 px-2 py-1.5 text-xs font-medium text-muted outline-none transition-colors hover:border-accent hover:text-ink focus-visible:border-accent focus-visible:ring-1 focus-visible:ring-accent/60"
aria-label={copied ? 'Copied to clipboard' : 'Copy code to clipboard'}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-safe" aria-hidden />
) : (
<Copy className="h-3.5 w-3.5" aria-hidden />
)}
{/* Hidden below sm: on a narrow screen the label is what pushes the
button wide enough to sit over the code it's meant to let you copy. */}
<span className="hidden sm:inline">{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
);
}
3 changes: 3 additions & 0 deletions dashboard/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export function Footer() {
<Link href="/methodology" className="text-muted hover:text-ink">
Methodology
</Link>
<Link href="/docs/api" className="text-muted hover:text-ink">
API docs
</Link>
</nav>
</div>
</div>
Expand Down
Loading
Loading