diff --git a/.env.example b/.env.example index 0ab8e61..fb6bb08 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,9 @@ CRON_SECRET=replace-with-a-long-random-string # below) — without it the indexer still retries and still records failed runs, # it just never tells anyone. The retry/threshold knobs all have defaults and # only need setting to override them. +# Worth setting too: STENION_RATE_LIMIT_SALT (see "Public API rate limiting" +# below). The rate limiter works without it; the salt is what makes the stored +# client hashes actually irreversible. # STENION_INTERVAL_MS / STENION_RUN_ONCE are NOT used by the cron route (it always # runs exactly one cycle); scheduling is external (cron-job.org), and that schedule # is configured in their dashboard — it is not in this repo. @@ -135,6 +138,50 @@ CRON_SECRET=replace-with-a-long-random-string # real outage, and never touches Postgres. See scripts/smoke-alert-webhook.mjs. #STENION_ALERT_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/xxxx +# --- Public API rate limiting --- +# +# The two public read routes (/api/v1/protocols, /api/v1/protocol/:id) are rate +# limited per client. The counter is a row in Postgres (table `api_rate_limits`, +# migration 0005), NOT in memory: serverless gives each invocation its own +# process, so an in-memory counter would allow N x the limit across N warm +# instances while reporting that the limit was enforced. +# +# WHAT IS COUNTED: only requests that reach the function, i.e. CDN cache MISSES. +# A cache hit never runs this code and never touches the database, so it is not +# counted — the limit protects Neon, and a cache hit costs Neon nothing. See +# ARCHITECTURE.md "Caching and rate limits" for the full reasoning and for what +# this does and does not protect against. +# +# The cron trigger (POST /api/cron/run-indexer) is deliberately NOT rate limited: +# it is secret-gated and internal, and limiting it could only block a scheduled +# run. The dashboard's own pages are unaffected too — they read the Store +# in-process and never go through these routes. +# +# NOTHING HERE IS REQUIRED. Every value has a default, a malformed value falls +# back to that default rather than throwing, and the limiter fails OPEN if its +# own query errors (including "table does not exist" before you have run the +# migration) — a broken guard rail must not become a broken API. + +# Sustained requests per minute per client. Default 60 (one per second). +#STENION_RATE_LIMIT_PER_MIN=60 + +# Burst allowance: requests a client may make back-to-back before the sustained +# rate binds. Default 60 — a full minute's worth up front, because bursty is what +# honest clients look like. +#STENION_RATE_LIMIT_BURST=60 + +# Salt for the client-key hash. The limiter stores a SHA-256 prefix of the client +# IP, never the IP itself, so the table cannot become a log of who reads the +# public API. Unsalted, that hash is reversible by enumerating the IPv4 space — +# so SET THIS IN PRODUCTION for the pseudonymity to be real. Any long random +# value; changing it resets everyone's bucket once, which is harmless. +# node -e "console.log(require('crypto').randomBytes(16).toString('hex'))" +#STENION_RATE_LIMIT_SALT= + +# Off switch. Only the exact string `true` disables the limiter, so a stray value +# cannot silently turn protection off. Default false (limiter on). +#STENION_RATE_LIMIT_DISABLED=false + # --- Post-deploy smoke check (scripts/smoke-protocol-404.mjs) --- # # ⚠️ These two are NOT read from this file. The smoke script is standalone — it diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7e608c0..9a998b1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,8 @@ factors using the formulas in `METHODOLOGY.md`, and produces a weighted `safetyS **`@stenion/db`** — the single, typed storage layer, shared by both the indexer (writes) and the dashboard/API (reads) so there's no duplicated connection logic. Exposes a lazy singleton `pg` `Pool` (`getPool`/`closePool`), a `createStore(pool)` factory with all read/write methods, env -loading, and the persisted `RunRecord` type. Two tables: +loading, and the persisted `RunRecord` type. Three tables — two that hold the product, and one that +holds no product data at all: - `protocols` — one row per protocol (slug PK, name, chain, adapter class name) plus its identity: `logo` (a root-relative path into the dashboard's own `public/` tree — we host every mark, never @@ -87,6 +88,13 @@ loading, and the persisted `RunRecord` type. Two tables: ranked, and growing the taxonomy then needs no migration). `methodology_version` records which rulebook produced the score — see below. A DB-level CHECK enforces the `ok`/`failed` discriminated union. +- `api_rate_limits` — one token bucket per public-API client, and the odd one out: it is + infrastructure, not data. It exists in Postgres only because serverless has no shared memory, so + there is nowhere else every instance can see (`createRateLimiter`, deliberately not part of + `Store` — the domain layer has no business knowing about it). The key is a salted hash of the + client IP, never the address, so the table cannot become a log of who reads the API; rows idle for + an hour are pruned. Nothing here is read by any scoring or serving path. See "Caching and rate + limits". **Methodology versioning.** The rulebook is at **v1**, and versioning starts there: every stored row carries `methodology_version = 1`, and no second version exists yet. Development-era history @@ -292,11 +300,13 @@ separate services. Everything runs from the single Next.js app: `app/api/v1/protocol/[id]`). Same `Store` methods, same JSON as the original standalone API — a transport change, not a rewrite. CORS (`access-control-allow-origin: *`) is set on these two 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. + `/api/v1/*` is the only public API surface — see "API versioning" below. Both routes are + CDN-cached and rate limited — 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 `crypto.timingSafeEqual`); if `CRON_SECRET` is unset it refuses to run, so it's never open. No - CORS on this route. + CORS on this route, and **no rate limiting** — it's authenticated and internal, and limiting it + could only ever block a scheduled run. - **Scheduling is external** — a [cron-job.org](https://cron-job.org) job POSTs to the cron route every 5 minutes with `Authorization: Bearer `. The route itself is stateless about cadence: it runs exactly one cycle per request, so the interval is entirely the caller's. @@ -372,6 +382,19 @@ The worked examples: - **`dashboard/app/api/_http.test.ts`** — the response envelope: status, content type, and CORS. These fail only in a third party's browser, never on our own pages (which read the Store in-process), so nothing else would catch a regression. +- **`dashboard/app/api/_cache.test.ts`** — the cache TTL policy, and specifically the invariant that + a cached response can never hide a newer indexer run by more than the 10s floor. That promise is + arithmetic over a clock and is unobservable everywhere we can look: locally there is no cache, and + on Vercel a violation looks like a correct-shaped JSON body that is quietly minutes old. Nothing + goes red, so the bound is asserted here or nowhere. +- **`dashboard/app/api/_rate-limit.test.ts`** — client identity, config parsing, refusal headers, and + the per-instance deny memo. Every branch decides whether to refuse someone, and none of it runs in + normal operation: the first time it matters is either an abuse incident or an integrator's launch, + and a mistake that pools clients together makes the second look like the first. +- **`db/src/rate-limit.test.ts`** — what a token balance _means_, including the wait an integrator is + told to back off by. Production runs this at `tokens = 59.9997` and `tokens = -0.0001`; the + interesting cases are exactly the ones a real request never lands on. The refill itself is + Postgres arithmetic and is not covered here. - **`indexer/src/cycle.test.ts`** — the run loop's error model, against a deliberately throwing adapter and an in-memory `Store`. The contract is that an adapter throws, the indexer records a failed run and continues; `risk_scores` has **never** held a failed row (1,683 rows as of @@ -402,6 +425,127 @@ threshold knobs — `STENION_RETRY_ATTEMPTS`, `STENION_RETRY_BASE_DELAY_MS`, defaults and only need setting to override them; every one is documented in `.env.example`. Locally, every package reads these from a single repo-root `.env` via a walk-up loader. +### Caching and rate limits + +The public API had neither, deliberately, until it was deployed and about to be pitched to wallet +integrators. Both exist for one reason: **the whole system runs on Neon's free tier, and an +aggressive client could exhaust it.** The data behind these routes only changes every ~5 minutes, so +caching is nearly free; rate limiting covers the client that defeats the cache. + +Neither changed the JSON. Existing consumers see the same bodies with a `Cache-Control` header +added, plus a `429` status that did not exist before. + +**What is actually doing the caching:** Vercel's CDN, driven by a `Cache-Control` header the route +handlers set per response. Not an in-process cache — the API is serverless, each invocation is its +own process, so a module-level cache would miss on every cold start and hold a different answer per +warm instance. Being honest about the limits of that: + +- The CDN caches **per edge region**, so the origin sees roughly one request per TTL _per region + with traffic_, not one globally. +- The cache key includes the **query string**, so `?anything=1` is a fresh key and a guaranteed + miss. The cache reduces cost for well-behaved clients; it does not protect the database from a + hostile one. That is the rate limiter's job, and it is why the limiter has to be accurate rather + than decorative. + +**The TTL, and why it is computed per response.** `lastRunAt` / `lastRunStatus` are how a consumer +knows whether our data is stale (see "Staleness model" above). A fixed TTL of _N_ seconds serves a +body claiming "the last run succeeded at T" for up to _N_ seconds after a later run has already +failed — the cache would be lying in exactly the field that exists to stop us lying about freshness. +Shortening _N_ bounds that window; it does not remove it. + +So the TTL is derived from the data in the body (`dashboard/app/api/_cache.ts`): **cache until the +earliest moment the next indexer run could plausibly land, and no further.** + +| Constant | Value | Why | +| -------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `INDEXER_INTERVAL_SECONDS` | 300 | The cron-job.org cadence; observed median `run_at` spacing is 4m59s. | +| `CYCLE_JITTER_SECONDS` | 45 | `run_at` is stamped when a protocol's _turn_ begins, not when cron fires, so its spacing shifts by however much the protocols ahead of it sped up or slowed down — bounded by `STENION_CYCLE_BUDGET_MS` (default 42s). | +| `MAX_TTL_SECONDS` | 45 | Blast radius, not load. See below. | +| `MIN_TTL_SECONDS` | 10 | Floor, so the moment around a landing run isn't an uncached hole every client stampedes through. | + +The deadline is `lastRunAt + 300 − 45 = lastRunAt + 255`, clamped into `[10, 45]`. On a leaderboard +response every protocol's `lastRunAt` counts and the tightest deadline wins, because any of them +landing changes the body. A `null` or unparseable `lastRunAt` collapses to the floor. + +**Why the ceiling is 45 and not 255.** It is not a load number. A continuously-requested route hits +the origin `1/TTL` times per second whatever the traffic is, so 45s is ~1.3 origin requests per +minute per region and 255s would be ~0.24 — a difference of nothing to Neon. It is a blast-radius +number: `INDEXER_INTERVAL_SECONDS` is an assumption about a schedule that lives in the cron-job.org +dashboard and **not in this repo**, so nothing here fails if someone changes the cadence. The +ceiling caps what a wrong assumption costs at 45s of staleness instead of a full cycle's worth. + +**The guarantee this buys.** A cached response can hide a newer run for at most `MIN_TTL_SECONDS` +(10s) — asserted mechanically over every run age in `_cache.test.ts`, because the property is +invisible in every environment we can look at: locally there is no cache, and on Vercel a failure +looks like a correct-shaped JSON body that is quietly a few minutes old. Nothing goes red. On top of +that bound, the CDN's own `Age` header makes the residual window _visible_ rather than merely small: +a consumer that cares can subtract it. + +Two deliberate omissions: + +- **No `stale-while-revalidate`.** It is the standard fix for the stampede at expiry, and it works + by serving a body _past_ its deadline — precisely the masking above. The stampede it would prevent + is bounded by the rate limiter and by this project's traffic; the staleness it would reintroduce + is not bounded by anything. +- **`max-age=0` for private caches.** A copy in someone's browser is one we cannot see, cannot + expire, and gain nothing from — the shared tier already absorbs the load — and it would put a + response's real age beyond what `Age` reports. + +Errors and 404s are `no-store`. A cached 500 outlives the outage that caused it, and a cached 404 +would keep 404ing for a protocol added in the next cycle. + +**The rate limiter.** A **token bucket, one row per client, in Postgres** (`api_rate_limits`, +migration 0005; `db/src/rate-limit.ts`). Postgres and not memory for the reason above: an +in-memory counter is per-instance, so N warm instances would allow N × the intended rate _while +reporting that the limit was enforced_. False confidence is worse than no limiter. + +- **The limits: 60 requests/minute sustained, 60 of burst, per client.** Sized against what is + actually counted — cache **misses**. A wallet polling every 5 seconds produces roughly one miss + per TTL because the CDN serves everything in between, so 60 is an order of magnitude above any + legitimate integrator. What it does bite is the case it is for: a client defeating the cache with + a varying query string, where every request is a database query. That client is capped at ~1 + query/second instead of unbounded. +- **A cache hit does not count**, and this is structural rather than a policy choice: a hit never + invokes the function. It is also the right policy — the limit protects the database, and a hit + costs the database nothing. The consequence, stated plainly: the documented limit is **not** a cap + on total requests. A client polling a cached endpoint can exceed it all day. +- **Per client IP**, taken from `x-real-ip` (Vercel sets it, single-valued) falling back to the first + `x-forwarded-for` hop. **Behind a shared NAT** — a corporate office, a mobile carrier — everyone + shares one bucket. That is survivable here only because of the previous point: NAT'd browser + traffic overwhelmingly hits the CDN, so a thousand users behind one address still generate roughly + one miss per TTL between them. The case that would genuinely break is a thousand NAT'd clients + each cache-busting, which is indistinguishable from the abuse this is meant to stop. +- **Not an IP log.** The stored key is a salted SHA-256 prefix, never the address + (`STENION_RATE_LIMIT_SALT` — set it in production, or the hash is reversible by enumerating IPv4). + Rows idle for an hour are pruned opportunistically on ~1 in 256 served requests, so the table stays + proportional to _active_ clients. +- **A 429 carries `Retry-After` (seconds), `X-RateLimit-Limit`, `X-RateLimit-Remaining`, + `X-RateLimit-Reset` (unix epoch seconds), and `Cache-Control: no-store`.** The last is + load-bearing: the CDN keys on URL, not client, so a cacheable 429 would be replayed to every other + client that asked next — one scraper's limit becoming everyone's outage. Those headers ship on + **refusals only**: a 200 is shared-cached and served to many clients, so an + `X-RateLimit-Remaining` baked into it would be one client's balance, frozen, replayed to + everybody — wrong for every reader including the one it came from. +- **It fails open.** If the limiter's own query throws — table missing because the migration has not + run, pool exhausted, Neon down — the request is allowed and the error is logged. A broken guard + rail must not become a broken API, and it means migration 0005 and the deploy can land in either + order. +- **The cron trigger is not rate limited.** It is secret-gated and internal; limiting it could only + ever block a scheduled run. + +**What this does not protect against.** A distributed attack. The limiter is per-client-key, so a +thousand hosts each staying under the limit are a thousand clients as far as it is concerned. +Stopping that is a network-edge job (Vercel's firewall), not an application one. It also trusts the +platform's proxy headers — true on Vercel, which overwrites them, but a proxy that passed a +client-supplied `x-forwarded-for` through would let a client split itself across unlimited buckets. +That is a property of the deployment, not something this code can detect. + +**What it costs.** One database round trip per cache miss. Deliberate — it is the price of a counter +that is genuinely shared. A per-instance memo short-circuits clients that have _already_ been +refused, so a flood costs one write per block window rather than one per request; that memo may only +ever refuse faster, never allow, which is what makes a per-instance structure sound there when it +isn't for the counter itself. + ### API versioning The public API is versioned in the URL. The documented, canonical paths are: diff --git a/CLAUDE.md b/CLAUDE.md index 8d6a3a0..4800fd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,8 +111,17 @@ in-process (no HTTP hop). The indexer is triggered by a secret-gated cron route this repo** — there is no workflow or `vercel.json` `crons` entry to find. `@stenion/api` is legacy — kept but not deployed. Env vars: `DATABASE_URL` (Neon pooled), `STENION_RPC_URL`, `STENION_HORIZON_URL`, `CRON_SECRET`, plus optional `STENION_ALERT_WEBHOOK_URL` (indexer failure -alerts; unset = off) and the retry/threshold knobs, which all have defaults. Every variable the repo -understands is documented in `.env.example`. +alerts; unset = off), `STENION_RATE_LIMIT_SALT`, and the retry/threshold and rate-limit knobs, which +all have defaults. Every variable the repo understands is documented in `.env.example`. + +The two `/v1` read routes are CDN-cached and rate limited; the cron route is **neither**, and must +stay that way — rate limiting an authenticated internal trigger can only block a scheduled run. +Caching there is meaningless (it's a POST that does work). The rate limiter's counter lives in +Postgres because serverless has no shared memory, and it **fails open**: a limiter that can 429 the +whole API when its own query breaks is worse than no limiter. Policy, limits and the +staleness-vs-cache reasoning live in `ARCHITECTURE.md` "Caching and rate limits" — the load-bearing +rule here is that **caching must never mask `lastRunAt`/`lastRunStatus`**, which is why the TTL is +computed per response from the body rather than being a constant. > **The 60s ceiling is load-bearing.** `maxDuration` is capped at 60 on Vercel's Hobby tier and > cannot be raised. The indexer's retry budget (`STENION_CYCLE_BUDGET_MS`, default 42s, divided per diff --git a/README.md b/README.md index e593c3a..7f19c1f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,26 @@ real scores at `http://localhost:3000`. The public API is served by the dashboar `/api/v1/protocols` and `/api/v1/protocol/:id` — versioned, with the policy in [`ARCHITECTURE.md`](ARCHITECTURE.md#api-versioning). +### Using the public API + +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 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` + and `X-RateLimit-Reset` (unix epoch seconds) come with it. + +Behind a shared NAT you share a bucket with everyone on that address; cached responses don't count, +which is what makes that workable in practice. If you're building something that genuinely needs +more, open an issue — the numbers are policy, not physics. Full reasoning, and what the limiter +does and doesn't protect against, is in +[`ARCHITECTURE.md`](ARCHITECTURE.md#caching-and-rate-limits). + To smoke-test the deployed 404 behaviour for an unknown protocol id, run: ```bash diff --git a/dashboard/app/api/_cache.test.ts b/dashboard/app/api/_cache.test.ts new file mode 100644 index 0000000..2ca1816 --- /dev/null +++ b/dashboard/app/api/_cache.test.ts @@ -0,0 +1,140 @@ +// Tests for the public API's cache TTL policy. +// +// WHY THESE EXIST: the promise this code makes is that caching cannot mask +// `lastRunAt` / `lastRunStatus` — the two fields a consumer uses to decide +// whether to trust our numbers. That promise is a piece of arithmetic over a +// clock, and it is unobservable in every environment we can look at: locally the +// cache does not exist, and on Vercel a failure looks like a correct-shaped JSON +// body that is quietly a few minutes old. Nothing goes red. So the bound gets +// asserted here or it gets asserted nowhere. +// +// The central assertion is `never outlives the earliest plausible next run` +// below. The rest pin the edges it depends on. +// +// Run with: pnpm --filter @stenion/dashboard test + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + CYCLE_JITTER_SECONDS, + INDEXER_INTERVAL_SECONDS, + MAX_TTL_SECONDS, + MIN_TTL_SECONDS, + NO_STORE, + cacheTtlSeconds, + publicCacheControl, +} from './_cache.ts'; + +const NOW = Date.parse('2026-08-19T12:00:00.000Z'); +/** An ISO `lastRunAt` that is `seconds` old relative to NOW. */ +const agedBy = (seconds: number) => new Date(NOW - seconds * 1000).toISOString(); + +describe('cacheTtlSeconds', () => { + it('caches for the ceiling right after a run', () => { + assert.equal(cacheTtlSeconds([agedBy(2)], NOW), MAX_TTL_SECONDS); + }); + + it('shrinks to the floor as the next run approaches', () => { + // Deadline is INTERVAL - JITTER after the run; five seconds short of it, + // there is only the floor left to give. + const deadline = INDEXER_INTERVAL_SECONDS - CYCLE_JITTER_SECONDS; + assert.equal(cacheTtlSeconds([agedBy(deadline - 5)], NOW), MIN_TTL_SECONDS); + }); + + it('can never hide a newer run for longer than the floor', () => { + // THE INVARIANT, and the reason this file exists. + // + // A cached body reports `lastRunAt`/`lastRunStatus` for the run it was built + // from. If a LATER run lands while that body is still being served, the cache + // is answering a freshness question with an answer that has been overtaken — + // lying in the one place we promise not to. This bounds how long that can + // last, for a run of ANY age. + // + // Earliest the next run can land: the deadline after the last one, or right + // now if the indexer is already overdue. Whichever is later. + const deadlineOffset = INDEXER_INTERVAL_SECONDS - CYCLE_JITTER_SECONDS; + for (let age = 0; age <= 900; age += 1) { + const ttl = cacheTtlSeconds([agedBy(age)], NOW); + const expiresInSeconds = ttl; + const earliestNextRunInSeconds = Math.max(deadlineOffset - age, 0); + const maskedFor = expiresInSeconds - earliestNextRunInSeconds; + assert.ok( + maskedFor <= MIN_TTL_SECONDS, + `age=${age}s: a newer run could be masked for ${maskedFor}s, ` + + `beyond the ${MIN_TTL_SECONDS}s floor`, + ); + } + }); + + it('stays within the floor and ceiling for any age, including the absurd', () => { + for (const age of [0, 1, 60, 254, 255, 256, 300, 3600, 86_400 * 30]) { + const ttl = cacheTtlSeconds([agedBy(age)], NOW); + assert.ok(ttl >= MIN_TTL_SECONDS && ttl <= MAX_TTL_SECONDS, `age=${age}s gave ttl=${ttl}`); + } + }); + + it('takes the tightest deadline across a leaderboard', () => { + // Any protocol's run landing changes the body, so the whole response has to + // expire on whichever one is due first. Taking the newest instead would + // cache a stale row for a protocol that has already been re-scored. + const fresh = agedBy(5); + const nearlyDue = agedBy(INDEXER_INTERVAL_SECONDS - CYCLE_JITTER_SECONDS - 5); + assert.equal(cacheTtlSeconds([fresh, nearlyDue], NOW), MIN_TTL_SECONDS); + assert.equal(cacheTtlSeconds([nearlyDue, fresh], NOW), MIN_TTL_SECONDS); + }); + + it('drops to the floor when a protocol has never run', () => { + // A never-run protocol has no deadline to compute from, and it is the entry + // most likely to change next — the first successful run is the thing a + // watching consumer is waiting for. + assert.equal(cacheTtlSeconds([null], NOW), MIN_TTL_SECONDS); + assert.equal(cacheTtlSeconds([agedBy(1), null], NOW), MIN_TTL_SECONDS); + }); + + it('drops to the floor rather than throwing on an unparseable timestamp', () => { + // Defensive: a bad value must degrade to "cache barely at all", never to a + // NaN TTL that would land in a header as `s-maxage=NaN`. + assert.equal(cacheTtlSeconds(['not-a-date'], NOW), MIN_TTL_SECONDS); + }); + + it('treats an empty registry as the floor case', () => { + assert.equal(cacheTtlSeconds([], NOW), MIN_TTL_SECONDS); + }); + + it('is an overdue indexer that shortens the TTL, not lengthens it', () => { + // If the last run is older than a whole interval the indexer is late, and the + // next run could land at any moment — so this is the case that most needs a + // short TTL, even though it is also the case where the data is least likely + // to be changing. + assert.equal(cacheTtlSeconds([agedBy(INDEXER_INTERVAL_SECONDS * 4)], NOW), MIN_TTL_SECONDS); + }); +}); + +describe('publicCacheControl', () => { + it('caches in the shared tier only, never in a browser', () => { + // `max-age=0` is the load-bearing half: a copy in someone's browser is one we + // cannot expire and cannot see, and it would put a response's real age beyond + // what the `Age` header reports. + const header = publicCacheControl(45); + assert.match(header, /(^|,\s*)max-age=0(,|$)/); + assert.match(header, /s-maxage=45/); + assert.match(header, /^public,/); + }); + + it('never offers stale-while-revalidate', () => { + // SWR serves a body past its deadline. That is the exact behaviour the TTL + // arithmetic exists to prevent, so it must not creep back in as a "harmless" + // stampede fix. + assert.doesNotMatch(publicCacheControl(45), /stale-while-revalidate/); + assert.doesNotMatch(publicCacheControl(45), /stale-if-error/); + }); +}); + +describe('NO_STORE', () => { + it('is uncacheable, for responses that are per-client or wrong to repeat', () => { + // The CDN cache key is the URL, not the client. A shareable 429 would hand + // one scraper's refusal to everybody else who asked next. + assert.equal(NO_STORE, 'no-store'); + }); +}); diff --git a/dashboard/app/api/_cache.ts b/dashboard/app/api/_cache.ts new file mode 100644 index 0000000..320eae5 --- /dev/null +++ b/dashboard/app/api/_cache.ts @@ -0,0 +1,159 @@ +// Cache policy for the public API routes. +// +// This module deliberately imports nothing, like ./_http — it is the part of the +// caching decision that is pure arithmetic, and therefore the part worth pinning +// with tests. The wiring (which route sets which header) lives in the routes. +// +// --------------------------------------------------------------------------- +// WHAT IS ACTUALLY DOING THE CACHING +// +// Vercel's CDN, via the `Cache-Control` header these functions build. Not an +// in-process Map: the API runs as serverless functions, each invocation its own +// process, so a module-level cache would be per-instance — a cache that misses +// on every cold start and holds a different answer per warm instance. The CDN is +// a genuinely shared tier in front of the function, and it is the only shared +// cache this project already pays for. +// +// What that buys, honestly: the CDN caches PER EDGE REGION, so the origin sees +// roughly one request per TTL *per region with traffic*, not one globally. And +// the cache key includes the query string, so `?anything=1` is a fresh key and a +// guaranteed miss — the cache reduces cost for well-behaved clients, it does not +// protect the database from a hostile one. That is the rate limiter's job, and +// it is why the limiter has to be accurate rather than decorative. +// +// --------------------------------------------------------------------------- +// WHY THE TTL IS COMPUTED PER RESPONSE INSTEAD OF BEING A CONSTANT +// +// `lastRunAt` / `lastRunStatus` are how a consumer knows whether our data is +// stale (see the staleness model in ARCHITECTURE.md). A fixed TTL of N seconds +// serves a body claiming "last run succeeded at T" for up to N seconds after a +// LATER run has already failed — the cache would be lying in exactly the field +// that exists to stop us lying about freshness. Bounding N doesn't remove that, +// it just shortens it. +// +// So the TTL is derived from the data in the body: cache until the earliest +// moment the next indexer run could plausibly land, and no further. A response +// then expires *before* there is anything newer to hide, and the only residual +// window is the MIN_TTL floor below. +// --------------------------------------------------------------------------- + +/** + * The indexer's cadence, in seconds. The cron-job.org schedule POSTs to + * `/api/cron/run-indexer` every 5 minutes; the observed median spacing between + * `run_at` values is 4m59s. + * + * THIS NUMBER IS AN ASSUMPTION, NOT A FACT THIS REPO CONTROLS. The schedule + * lives in the cron-job.org dashboard, not in version control (see CLAUDE.md), + * so nothing here fails if someone changes it. That is the entire reason for + * MAX_TTL_SECONDS: it caps how much staleness a wrong assumption here can cost. + */ +export const INDEXER_INTERVAL_SECONDS = 300; + +/** + * How much EARLIER than a clean +300s the next run can stamp its `run_at`. + * + * `run_at` is stamped when a protocol's turn in the cycle begins, not when the + * cron fires (indexer/src/cycle.ts), so a protocol's spacing shifts by however + * much the protocols ahead of it sped up or slowed down. That is bounded by the + * cycle budget — `STENION_CYCLE_BUDGET_MS`, default 42s — so 45s covers the + * worst case of "the cycle ahead of me took the whole budget last time and + * finished instantly this time". + * + * Subtracting it means the deadline is the earliest plausible next run rather + * than the expected one. Being early costs a cache miss; being late costs the + * masked-staleness window this whole module exists to close. + */ +export const CYCLE_JITTER_SECONDS = 45; + +/** + * Hard ceiling on the TTL, regardless of what the deadline arithmetic says. + * + * Not a load-shedding number — by the time you are caching for 45s you have + * already removed essentially all repeat load (a continuously-requested route + * hits the origin 1/TTL times per second whatever the traffic is, so 45s is + * ~1.3 origin requests/minute/region and 255s would be ~0.24; the difference is + * nothing to Neon). It is a blast-radius number: INDEXER_INTERVAL_SECONDS is an + * assumption about a schedule stored outside this repo, and this caps what a + * wrong assumption costs at 45s of staleness instead of a full cycle's worth. + */ +export const MAX_TTL_SECONDS = 45; + +/** + * Floor on the TTL, so the moment around a landing run doesn't become an + * uncached hole every client stampedes through. It is also the worst-case window + * in which a cached body can hide a newer run: past the deadline we hold a + * response for at most this long. + */ +export const MIN_TTL_SECONDS = 10; + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +/** + * How long this response may be cached, in whole seconds. + * + * Pass every `lastRunAt` the body reports — one for a detail response, one per + * protocol for the leaderboard. Any of them changing changes the body, so the + * TTL is the tightest deadline across them. + * + * A null or unparseable `lastRunAt` collapses to the floor: "never run" is the + * state with no deadline to compute and the one most likely to change next, and + * it is also the cheapest query in the system, so caching it hard buys nothing. + * An empty list (no protocols at all) is the same case. + */ +export function cacheTtlSeconds( + lastRunAts: readonly (string | null)[], + nowMs: number = Date.now(), +): number { + if (lastRunAts.length === 0) return MIN_TTL_SECONDS; + + const deadlineOffsetMs = (INDEXER_INTERVAL_SECONDS - CYCLE_JITTER_SECONDS) * 1000; + let ttl = MAX_TTL_SECONDS; + + for (const iso of lastRunAts) { + if (iso === null) return MIN_TTL_SECONDS; + const runAtMs = Date.parse(iso); + if (!Number.isFinite(runAtMs)) return MIN_TTL_SECONDS; + ttl = Math.min(ttl, Math.floor((runAtMs + deadlineOffsetMs - nowMs) / 1000)); + } + + return clamp(ttl, MIN_TTL_SECONDS, MAX_TTL_SECONDS); +} + +/** + * `Cache-Control` for a cacheable public response. + * + * `s-maxage` targets the shared CDN tier; `max-age=0` deliberately keeps private + * browser caches out of it. A copy sitting in someone's browser is one we cannot + * see, cannot expire, and gains us nothing — the shared cache already absorbs the + * load — and it would put a response's age beyond what the `Age` header reports. + * + * NO `stale-while-revalidate`, on purpose. SWR is the standard fix for the + * stampede at expiry, and it works by serving a body PAST its deadline, which is + * precisely the masked-staleness this module is built to avoid. The stampede it + * would prevent is bounded by the rate limiter and by this project's traffic; the + * staleness it would reintroduce is not bounded by anything. + */ +export function publicCacheControl(ttlSeconds: number): string { + return `public, max-age=0, s-maxage=${ttlSeconds}`; +} + +/** + * `Cache-Control` for anything that must never be shared. + * + * Errors and 429s. A 429 is per-client but the CDN cache key is not — it is the + * URL — so a cacheable 429 would be served to every client that asked next, + * turning one rate-limited scraper into an outage for everyone else. + */ +export const NO_STORE = 'no-store'; + +/** + * How long a browser may cache the CORS preflight for these routes. + * + * The public routes are read-only and their CORS policy is a constant, so a + * browser re-asking on every request is pure waste — and each preflight is a + * function invocation we pay for. A day is safe because changing the policy is a + * deploy, not a runtime decision. + */ +export const PREFLIGHT_MAX_AGE_SECONDS = 86_400; diff --git a/dashboard/app/api/_http.test.ts b/dashboard/app/api/_http.test.ts index c016f2b..16ee1ba 100644 --- a/dashboard/app/api/_http.test.ts +++ b/dashboard/app/api/_http.test.ts @@ -51,6 +51,17 @@ describe('jsonResponse', () => { assert.equal(await res.text(), 'null'); }); + it('lets a caller state the caching decision without losing CORS', async () => { + // The caching decision is per-response (it is computed from the body's + // lastRunAt values), so it arrives as an extra header. If that spread ever + // clobbered the CORS or content-type entries, every browser client would + // break at exactly the moment caching was turned on. + const res = jsonResponse({ protocols: [] }, 200, { 'cache-control': 'public, s-maxage=45' }); + assert.equal(res.headers.get('cache-control'), 'public, s-maxage=45'); + assert.equal(res.headers.get('access-control-allow-origin'), '*'); + assert.equal(res.headers.get('content-type'), 'application/json; charset=utf-8'); + }); + it('does not mutate the shared CORS header object', async () => { // jsonResponse spreads CORS_HEADERS into a new object; if that ever became // a mutation, one route's status or content-type would leak into every diff --git a/dashboard/app/api/_http.ts b/dashboard/app/api/_http.ts index 4a89681..087a0c0 100644 --- a/dashboard/app/api/_http.ts +++ b/dashboard/app/api/_http.ts @@ -19,10 +19,25 @@ export const CORS_HEADERS: Record = { 'access-control-allow-headers': 'content-type', }; -/** JSON response carrying the CORS headers, matching the shipped API contract. */ -export function jsonResponse(body: unknown, status = 200): Response { +/** + * JSON response carrying the CORS headers, matching the shipped API contract. + * + * `extraHeaders` is spread LAST so a caller can set `cache-control` (and only + * really that — see ./_cache and ./_rate-limit). It comes last on purpose: the + * caching decision is per-response and depends on the body, so a route has to be + * able to state it here rather than have a default silently win. + */ +export function jsonResponse( + body: unknown, + status = 200, + extraHeaders: Record = {}, +): Response { return new Response(JSON.stringify(body), { status, - headers: { 'content-type': 'application/json; charset=utf-8', ...CORS_HEADERS }, + headers: { + 'content-type': 'application/json; charset=utf-8', + ...CORS_HEADERS, + ...extraHeaders, + }, }); } diff --git a/dashboard/app/api/_rate-limit.test.ts b/dashboard/app/api/_rate-limit.test.ts new file mode 100644 index 0000000..03311df --- /dev/null +++ b/dashboard/app/api/_rate-limit.test.ts @@ -0,0 +1,193 @@ +// Tests for the public API's rate-limit policy. +// +// WHY THESE EXIST: every branch here is a decision about whether to REFUSE +// someone, and none of it shows up in normal operation. Stenion's real traffic +// will not hit the limit, so the first time this code runs in anger is either an +// abuse incident or a wallet integrator's launch — and if the client-identity +// derivation is wrong in the direction that pools clients together, the second +// case looks exactly like the first. +// +// The rules being pinned: unidentifiable clients are limited rather than +// exempted; identity is derived from the proxy header we trust, not the one a +// client can pick; a bad env value degrades to the default instead of taking the +// API down; and a refusal always names a wait a client can actually honour. +// +// The distributed counter itself is Postgres arithmetic and is tested in +// db/src/rate-limit.test.ts. +// +// Run with: pnpm --filter @stenion/dashboard test + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DEFAULT_BURST, + DEFAULT_PER_MINUTE, + bucketKey, + clientIp, + createDenyCache, + rateLimitHeaders, + rateLimitedBody, + readRateLimitSettings, +} from './_rate-limit.ts'; + +const NOW = Date.parse('2026-08-19T12:00:00.000Z'); +const SETTINGS = { enabled: true, perMinute: 60, burst: 60, salt: 'pepper' }; + +describe('readRateLimitSettings', () => { + it('is on by default', () => { + // The failure mode: shipping with the limiter quietly off because nobody set + // an env var. Protection has to be the thing you get for doing nothing. + const settings = readRateLimitSettings({}); + assert.equal(settings.enabled, true); + assert.equal(settings.perMinute, DEFAULT_PER_MINUTE); + assert.equal(settings.burst, DEFAULT_BURST); + }); + + it('turns off only for the exact string "true"', () => { + assert.equal(readRateLimitSettings({ STENION_RATE_LIMIT_DISABLED: 'true' }).enabled, false); + assert.equal(readRateLimitSettings({ STENION_RATE_LIMIT_DISABLED: ' TRUE ' }).enabled, false); + for (const value of ['false', '1', 'yes', 'no', '']) { + assert.equal( + readRateLimitSettings({ STENION_RATE_LIMIT_DISABLED: value }).enabled, + true, + `"${value}" must not disable the limiter`, + ); + } + }); + + it('reads overrides', () => { + const settings = readRateLimitSettings({ + STENION_RATE_LIMIT_PER_MIN: '120', + STENION_RATE_LIMIT_BURST: '240', + STENION_RATE_LIMIT_SALT: ' s3cret ', + }); + assert.equal(settings.perMinute, 120); + assert.equal(settings.burst, 240); + assert.equal(settings.salt, 's3cret'); + }); + + it('falls back to defaults on a malformed value instead of throwing', () => { + // A guard rail must not be able to crash the thing it guards. A typo in an + // env var should cost the operator their override, not the public API. + for (const bad of ['', ' ', 'abc', '0', '-5', 'NaN', 'Infinity']) { + const settings = readRateLimitSettings({ + STENION_RATE_LIMIT_PER_MIN: bad, + STENION_RATE_LIMIT_BURST: bad, + }); + assert.equal(settings.perMinute, DEFAULT_PER_MINUTE, `per-min from "${bad}"`); + assert.equal(settings.burst, DEFAULT_BURST, `burst from "${bad}"`); + } + }); +}); + +describe('clientIp', () => { + it('prefers x-real-ip', () => { + const headers = new Headers({ 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '198.51.100.1' }); + assert.equal(clientIp(headers), '203.0.113.7'); + }); + + it('falls back to the first x-forwarded-for hop', () => { + const headers = new Headers({ 'x-forwarded-for': '203.0.113.7, 70.41.3.18, 150.172.238.178' }); + assert.equal(clientIp(headers), '203.0.113.7'); + }); + + it('returns null when the platform reports nothing', () => { + // Not a fabricated identity. Null is handled explicitly by bucketKey, and + // inventing a per-request one here would silently exempt every such client. + assert.equal(clientIp(new Headers()), null); + assert.equal(clientIp(new Headers({ 'x-forwarded-for': ' ' })), null); + }); +}); + +describe('bucketKey', () => { + it('is stable for one client and different across clients', () => { + assert.equal(bucketKey('203.0.113.7', 'pepper'), bucketKey('203.0.113.7', 'pepper')); + assert.notEqual(bucketKey('203.0.113.7', 'pepper'), bucketKey('203.0.113.8', 'pepper')); + }); + + it('never contains the IP it was derived from', () => { + // The rate-limit table must not become a log of who reads the public API. + const key = bucketKey('203.0.113.7', 'pepper'); + assert.doesNotMatch(key, /203\.0\.113\.7/); + }); + + it('changes with the salt, which is what makes the hash worth having', () => { + // Unsalted, an IPv4 hash is reversible by enumerating four billion inputs. + assert.notEqual(bucketKey('203.0.113.7', ''), bucketKey('203.0.113.7', 'pepper')); + }); + + it('pools unidentifiable clients into one bucket rather than exempting them', () => { + // Limited collectively is the safe direction. A per-request key would mean + // "we could not identify you" reads as "you have no limit". + assert.equal(bucketKey(null, 'pepper'), bucketKey(null, 'pepper')); + }); +}); + +describe('rateLimitHeaders', () => { + it('gives a client everything it needs to back off', () => { + const headers = rateLimitHeaders(SETTINGS, 2, NOW); + assert.equal(headers['retry-after'], '2'); + assert.equal(headers['x-ratelimit-limit'], '60'); + assert.equal(headers['x-ratelimit-remaining'], '0'); + assert.equal(headers['x-ratelimit-reset'], String(NOW / 1000 + 2)); + }); + + it('marks the refusal uncacheable', () => { + // THE ONE THAT MATTERS. The CDN cache key is the URL, not the client. A 429 + // that a shared cache is allowed to keep would be replayed to every other + // client that asked next, turning one scraper's limit into everyone's outage. + assert.equal(rateLimitHeaders(SETTINGS, 2, NOW)['cache-control'], 'no-store'); + }); +}); + +describe('rateLimitedBody', () => { + it('keeps the { error } shape the rest of the API already uses', () => { + // A 429 is a new status for this API, not a change to an existing payload — + // consumers of the 200/404/500 bodies see nothing different. Matching the + // established error shape means a client that already handles `error` needs + // no new parsing. + const body = rateLimitedBody(2); + assert.equal(typeof body.error, 'string'); + assert.equal(body.retryAfter, 2); + assert.match(body.error, /Retry-After/); + }); +}); + +describe('createDenyCache', () => { + it('refuses a known-blocked client without asking the database', () => { + const cache = createDenyCache(); + cache.block('ip:abc', 2, NOW); + assert.equal(cache.blockedFor('ip:abc', NOW), 2); + }); + + it('knows nothing about a client it has not refused', () => { + // The safe direction: not-in-the-cache means ask Postgres, never means allow. + assert.equal(createDenyCache().blockedFor('ip:abc', NOW), null); + }); + + it('forgets a block once it expires', () => { + // Otherwise a per-instance memo becomes a per-instance ban, and a client that + // did back off stays refused by whichever instance remembers it. + const cache = createDenyCache(); + cache.block('ip:abc', 2, NOW); + assert.equal(cache.blockedFor('ip:abc', NOW + 2_000), null); + assert.equal(cache.size, 0, 'the expired entry should be dropped, not just ignored'); + }); + + it('counts down, rounding up so it never advertises too short a wait', () => { + const cache = createDenyCache(); + cache.block('ip:abc', 2, NOW); + assert.equal(cache.blockedFor('ip:abc', NOW + 500), 2); + assert.equal(cache.blockedFor('ip:abc', NOW + 1_500), 1); + assert.equal(cache.blockedFor('ip:abc', NOW + 1_999), 1); + }); + + it('stays bounded under a flood of distinct clients', () => { + // This map lives for the life of a warm instance. Unbounded, a botnet with + // many source addresses turns a rate limiter into a memory leak. + const cache = createDenyCache(4); + for (let i = 0; i < 50; i += 1) cache.block(`ip:${i}`, 2, NOW); + assert.ok(cache.size <= 4, `cache grew to ${cache.size}`); + }); +}); diff --git a/dashboard/app/api/_rate-limit.ts b/dashboard/app/api/_rate-limit.ts new file mode 100644 index 0000000..500d9e2 --- /dev/null +++ b/dashboard/app/api/_rate-limit.ts @@ -0,0 +1,242 @@ +// Rate-limit POLICY for the public API: who a client is, what they're allowed, +// and what a refusal looks like on the wire. +// +// Like ./_http and ./_cache this is a leaf — it imports only `node:crypto` — so +// it is testable from a plain Node test. The half that touches Postgres lives in +// @stenion/db (`createRateLimiter`) and is wired up in ./_shared. +// +// --------------------------------------------------------------------------- +// WHERE THE LIMIT IS ENFORCED, AND WHAT THAT MEANS +// +// Inside the serverless function — which the CDN only invokes on a cache MISS. +// So the limiter counts requests that actually cost a database query, not +// requests in general. That is the right unit: the thing being protected is +// Neon's free tier, and a cache hit costs it nothing. It also means the +// documented limit is not a cap on how many requests a client may make; a client +// polling a cached endpoint can exceed it all day and never be refused, because +// the function never sees them. +// +// The counter itself is a row in Postgres, not memory, because there is no +// shared memory here: each invocation is its own process and Vercel runs as many +// as traffic demands. See db/src/rate-limit.ts for the full honesty pass on what +// that does and doesn't guarantee. +// --------------------------------------------------------------------------- + +import { createHash } from 'node:crypto'; + +/** + * Requests per minute per client, sustained. + * + * 60/min = 1/second. Sized against what it is counting: cache MISSES. A wallet + * polling every 5 seconds generates roughly one miss per TTL (~1–6 per minute) + * because everything in between is served by the CDN, so 60 is an order of + * magnitude above any legitimate integrator — including one refreshing several + * protocol pages at once. What it does bite is the case it is for: a client + * defeating the cache with a varying query string, where every request is a miss + * and a database query. That client is capped at ~1 query/second instead of + * unbounded. + */ +export const DEFAULT_PER_MINUTE = 60; + +/** + * Burst allowance: how many requests a client may make back-to-back before the + * sustained rate starts to bind. + * + * Equal to the per-minute rate, i.e. a full minute's worth up front. Bursty is + * what honest clients look like — an integrator opening several protocol pages, + * a backfill on first deploy, a developer poking the API by hand — and refusing + * that while allowing the same volume spread evenly would block real work to no + * benefit. The bucket empties in one burst either way. + */ +export const DEFAULT_BURST = 60; + +export interface RateLimitSettings { + enabled: boolean; + /** Sustained requests per minute per client. */ + perMinute: number; + /** Bucket capacity. */ + burst: number; + /** Salt for the client-key hash; may be empty (see `bucketKey`). */ + salt: string; +} + +export const DEFAULT_RATE_LIMIT_SETTINGS: RateLimitSettings = { + enabled: true, + perMinute: DEFAULT_PER_MINUTE, + burst: DEFAULT_BURST, + salt: '', +}; + +/** A finite positive number from an env string, or undefined if it isn't one. */ +function positiveNumber(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed === '') return undefined; + const value = Number(trimmed); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** + * Read the limiter's configuration from the environment. + * + * Every knob has a default and a malformed value falls back to it rather than + * throwing. A typo in `STENION_RATE_LIMIT_PER_MIN` must not take the public API + * down — the limiter is a guard rail, and a guard rail that can crash the thing + * it guards is a liability. `STENION_RATE_LIMIT_DISABLED` is the deliberate off + * switch, and only the exact string `true` flips it, so a stray value can't + * silently disable protection either. + */ +export function readRateLimitSettings( + env: Record = process.env, +): RateLimitSettings { + return { + enabled: env.STENION_RATE_LIMIT_DISABLED?.trim().toLowerCase() !== 'true', + perMinute: positiveNumber(env.STENION_RATE_LIMIT_PER_MIN) ?? DEFAULT_PER_MINUTE, + burst: positiveNumber(env.STENION_RATE_LIMIT_BURST) ?? DEFAULT_BURST, + salt: env.STENION_RATE_LIMIT_SALT?.trim() ?? '', + }; +} + +/** + * The client's IP, as reported by the platform proxy, or null if we can't tell. + * + * `x-real-ip` first: on Vercel it is set by the platform and is single-valued, + * so it has no "which hop do I trust" problem. `x-forwarded-for`'s first entry is + * the fallback. + * + * THE TRUST ASSUMPTION, stated plainly: this believes the proxy in front of the + * app. That holds on Vercel, which overwrites both headers with the connecting + * IP. It would NOT hold behind a proxy that passes a client-supplied + * `x-forwarded-for` through, where a client could split itself across unlimited + * buckets and evade the limiter entirely. Nothing here can detect that — it is a + * property of the deployment, not of this function. + */ +export function clientIp(headers: Headers): string | null { + const real = headers.get('x-real-ip')?.trim(); + if (real) return real; + const forwarded = headers.get('x-forwarded-for'); + if (forwarded) { + const first = forwarded.split(',')[0]?.trim(); + if (first) return first; + } + return null; +} + +/** + * The bucket identity we store, derived from the client IP. + * + * HASHED, NOT STORED RAW. The limiter only ever needs to know that two requests + * came from the same place; it never needs to know where that is. Hashing means + * the rate-limit table is not a log of who read the public API, so it cannot + * become one by accident later. `STENION_RATE_LIMIT_SALT` is what stops the hash + * being reversible by enumerating the IPv4 space — set it in production; unset, + * the pseudonymity is nominal rather than real. + * + * Truncated to 32 hex characters: still 128 bits, far past any collision concern + * at this scale, and it keeps the primary key small. + * + * A null IP means the platform told us nothing, and every such request shares one + * bucket. That is the safe direction (unidentifiable traffic is limited + * collectively, not exempted) and it is mostly a local-development state — behind + * `next dev` there are no proxy headers at all. + */ +export function bucketKey(ip: string | null, salt: string): string { + const identity = ip ?? 'unknown'; + const digest = createHash('sha256').update(`${salt}:${identity}`).digest('hex').slice(0, 32); + return `ip:${digest}`; +} + +/** The 429 body. Shares the `{ error }` shape the 404 and 500 responses use. */ +export function rateLimitedBody(retryAfterSeconds: number): { error: string; retryAfter: number } { + return { + error: + 'Too many requests. This endpoint is rate limited per client; retry after the wait in the Retry-After header.', + retryAfter: retryAfterSeconds, + }; +} + +/** + * Headers for a 429, so an integrator can back off deliberately rather than + * guess. + * + * - `retry-after` — seconds, the one every HTTP client already understands. + * - `x-ratelimit-limit` — the sustained per-minute allowance. + * - `x-ratelimit-remaining` — always 0 here; this header only ships on refusals. + * - `x-ratelimit-reset` — UNIX EPOCH SECONDS, the GitHub convention. Stated + * because the header is not standardised and the other common reading is a + * delta; `retry-after` carries the delta unambiguously, so the two together + * are readable under either assumption. + * - `cache-control: no-store` — load-bearing. The CDN keys on URL, not client, + * so a cacheable 429 would be handed to every client that asked next. + * + * WHY THESE ARE NOT ON SUCCESSFUL RESPONSES. A 200 from these routes is cached + * in a shared CDN and served to many clients. An `x-ratelimit-remaining` baked + * into it would be one client's balance, frozen, replayed to everybody else — a + * number that is wrong for every reader including the one it came from. A header + * that is confidently wrong is worse than an absent one, so a client learns its + * standing the one time it matters: when it is refused. + */ +export function rateLimitHeaders( + settings: RateLimitSettings, + retryAfterSeconds: number, + nowMs: number = Date.now(), +): Record { + return { + 'retry-after': String(retryAfterSeconds), + 'x-ratelimit-limit': String(settings.perMinute), + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(Math.ceil(nowMs / 1000) + retryAfterSeconds), + 'cache-control': 'no-store', + }; +} + +/** + * A per-instance memo of clients we have already refused. + * + * The point is NOT to enforce the limit — it cannot, it is per-instance, which is + * the whole reason the real counter lives in Postgres. The point is that a client + * flooding one instance should not cost one database write per request while + * being told no. This cache may only ever REFUSE FASTER, never allow: an entry + * that is missing, expired, or lost to a cold start just means the request goes + * to Postgres and gets the authoritative answer. Skewing strictly toward the safe + * direction is what makes a per-instance structure sound here when it would not + * be for the counter itself. + */ +export interface DenyCache { + /** Seconds still to wait, or null if this client is not known to be refused. */ + blockedFor(key: string, nowMs: number): number | null; + block(key: string, retryAfterSeconds: number, nowMs: number): void; + readonly size: number; +} + +/** + * @param maxEntries hard cap, so a flood from many distinct clients cannot turn + * the memo into a memory leak. On overflow the whole map is dropped rather than + * evicted one by one: forgetting costs an extra database round trip, and an LRU + * is machinery to save something we can afford to lose. + */ +export function createDenyCache(maxEntries = 5_000): DenyCache { + const blockedUntil = new Map(); + + return { + blockedFor(key, nowMs) { + const until = blockedUntil.get(key); + if (until === undefined) return null; + if (until <= nowMs) { + blockedUntil.delete(key); + return null; + } + // Round up so we never advertise a wait shorter than the real one. + return Math.max(1, Math.ceil((until - nowMs) / 1000)); + }, + + block(key, retryAfterSeconds, nowMs) { + if (blockedUntil.size >= maxEntries && !blockedUntil.has(key)) blockedUntil.clear(); + blockedUntil.set(key, nowMs + retryAfterSeconds * 1000); + }, + + get size() { + return blockedUntil.size; + }, + }; +} diff --git a/dashboard/app/api/_shared.ts b/dashboard/app/api/_shared.ts index 56a9e35..47996a2 100644 --- a/dashboard/app/api/_shared.ts +++ b/dashboard/app/api/_shared.ts @@ -4,15 +4,36 @@ // (it's a plain module, not a route.ts), so Next never treats it as an endpoint. // It is server-only — it imports @stenion/db (pg). // -// The pure HTTP shaping (CORS_HEADERS, jsonResponse) lives in ./_http and is -// re-exported here so route files keep one import site. It is separate because -// `server-only` below is a bare specifier only Next's bundler resolves, which -// makes this module unimportable from a plain Node test — see _http.test.ts. +// The pure HTTP shaping (CORS_HEADERS, jsonResponse), the cache-TTL policy +// (./_cache) and the rate-limit policy (./_rate-limit) all live in leaf modules +// with no imports, so they can be tested from a plain Node test; they are +// re-exported here so route files keep one import site. They are separate +// because `server-only` below is a bare specifier only Next's bundler resolves, +// which makes this module unimportable from a test — see _http.test.ts. import 'server-only'; -import { createStore, getPool, type Store } from '@stenion/db'; +import { + createRateLimiter, + createStore, + getPool, + loadEnv, + type RateLimiter, + type Store, +} from '@stenion/db'; + +import { jsonResponse } from './_http'; +import { + bucketKey, + clientIp, + createDenyCache, + rateLimitHeaders, + rateLimitedBody, + readRateLimitSettings, + type RateLimitSettings, +} from './_rate-limit'; export { CORS_HEADERS, jsonResponse } from './_http'; +export { NO_STORE, PREFLIGHT_MAX_AGE_SECONDS, cacheTtlSeconds, publicCacheControl } from './_cache'; // One Store per server process, reused across warm invocations (the pg Pool is a // module singleton in @stenion/db). Deliberately never closed — closing would @@ -22,3 +43,82 @@ export function getStore(): Store { if (!store) store = createStore(getPool()); return store; } + +// Same lifecycle as the Store, over the same pool: the limiter's state is a row +// in Postgres, not anything held here, so this object is just the prepared query. +let limiter: RateLimiter | undefined; +function getRateLimiter(): RateLimiter { + if (!limiter) limiter = createRateLimiter(getPool()); + return limiter; +} + +let settings: RateLimitSettings | undefined; +function getSettings(): RateLimitSettings { + if (!settings) { + // Populate process.env from the repo-root .env when running locally; a no-op + // on Vercel, where these come from the project's configured environment. + loadEnv(); + settings = readRateLimitSettings(); + } + return settings; +} + +/** + * Per-instance memo of clients already refused, so a flood does not cost one + * database write per request. It can only ever refuse faster, never allow — see + * ./_rate-limit for why that asymmetry is what makes a per-instance structure + * sound here. + */ +const denyCache = createDenyCache(); + +/** + * Apply the public API's rate limit to one request. + * + * Returns a ready-to-send 429 when the client is over its limit, or `null` when + * the request should proceed. Callers put it first in the handler, before any + * database work — refusing a request that then queries anyway would protect + * nothing. + * + * NOT WIRED INTO `POST /api/cron/run-indexer`, deliberately. That route is + * authenticated with a shared secret and called by our own scheduler; rate + * limiting it could only ever do one thing, which is block a scheduled run. + * + * FAILS OPEN. If the limiter's own query throws — the table is missing because + * the migration has not been applied yet, the pool is exhausted, Neon is down — + * the request is allowed and the error is logged. A limiter outage must not + * become an API outage: the worst case of failing open is the unprotected + * behaviour we had before this existed, and the worst case of failing closed is + * a public API that returns 429 to everybody because a guard rail broke. + */ +export async function enforceRateLimit(req: Request): Promise { + const config = getSettings(); + if (!config.enabled) return null; + + const key = bucketKey(clientIp(req.headers), config.salt); + const now = Date.now(); + + const alreadyBlocked = denyCache.blockedFor(key, now); + if (alreadyBlocked !== null) return refuse(config, alreadyBlocked, now); + + try { + const decision = await getRateLimiter().take(key, { + burst: config.burst, + refillPerSecond: config.perMinute / 60, + }); + if (decision.allowed) return null; + + denyCache.block(key, decision.retryAfterSeconds, now); + return refuse(config, decision.retryAfterSeconds, now); + } catch (err) { + console.error('Rate limit check failed; allowing the request:', err); + return null; + } +} + +function refuse(config: RateLimitSettings, retryAfterSeconds: number, nowMs: number): Response { + return jsonResponse( + rateLimitedBody(retryAfterSeconds), + 429, + rateLimitHeaders(config, retryAfterSeconds, nowMs), + ); +} diff --git a/dashboard/app/api/v1/protocol/[id]/route.ts b/dashboard/app/api/v1/protocol/[id]/route.ts index 3978995..a1fc717 100644 --- a/dashboard/app/api/v1/protocol/[id]/route.ts +++ b/dashboard/app/api/v1/protocol/[id]/route.ts @@ -6,31 +6,69 @@ // // Versioned under /v1 as of the API-versioning change: the JSON contract is // unchanged (URL-only move). See ARCHITECTURE.md "API versioning" for the policy. +// +// Caching and rate limiting were added later and changed NO part of the JSON: +// they are a `Cache-Control` header on the 200 and a new 429 status for clients +// that are over their limit. See ARCHITECTURE.md "Caching and rate limits". -import { CORS_HEADERS, getStore, jsonResponse } from '../../../_shared'; +import { + CORS_HEADERS, + NO_STORE, + PREFLIGHT_MAX_AGE_SECONDS, + cacheTtlSeconds, + enforceRateLimit, + getStore, + jsonResponse, + publicCacheControl, +} from '../../../_shared'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export async function GET( - _req: Request, + req: Request, { params }: { params: Promise<{ id: string }> }, ): Promise { + // Before the database work, and before awaiting params: an unknown id is still + // a query, so id enumeration has to be limited like anything else. + const limited = await enforceRateLimit(req); + if (limited) return limited; + // Next 15: params is async and must be awaited. const { id } = await params; try { const detail = await getStore().getProtocolDetail(id); if (!detail) { - return jsonResponse({ error: 'Protocol not found', id }, 404); + // Not cached. A 404 is the answer for an id that does not exist YET — a + // protocol added in the next cycle would otherwise keep 404ing from a + // shared cache after it went live. Enumeration of unknown ids is the rate + // limiter's problem, not the cache's. + return jsonResponse({ error: 'Protocol not found', id }, 404, { 'cache-control': NO_STORE }); } - return jsonResponse(detail); + // TTL from this protocol's own lastRunAt, so the response expires before its + // next run could contradict the lastRunStatus it is reporting. See ./_cache. + const ttl = cacheTtlSeconds([detail.lastRunAt]); + return jsonResponse(detail, 200, { 'cache-control': publicCacheControl(ttl) }); } catch (err) { console.error(`GET /api/v1/protocol/${id} failed:`, err); - return jsonResponse({ error: 'Internal server error' }, 500); + return jsonResponse({ error: 'Internal server error' }, 500, { 'cache-control': NO_STORE }); } } -/** CORS preflight for cross-origin browser clients. */ +/** + * CORS preflight for cross-origin browser clients. + * + * Cacheable for a day, and not rate limited: the policy is a constant that only + * changes on deploy, and every preflight a browser has to repeat is a function + * invocation that answers a question we already answered. + */ export function OPTIONS(): Response { - return new Response(null, { status: 204, headers: CORS_HEADERS }); + 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/route.ts b/dashboard/app/api/v1/protocols/route.ts index 512dd50..5968183 100644 --- a/dashboard/app/api/v1/protocols/route.ts +++ b/dashboard/app/api/v1/protocols/route.ts @@ -8,27 +8,65 @@ // Versioned under /v1 as of the API-versioning change: the JSON contract is // unchanged (URL-only move). See ARCHITECTURE.md "API versioning" for the policy — // additive changes stay on v1, breaking ones get a v2. +// +// Caching and rate limiting were added later and changed NO part of the JSON: +// they are a `Cache-Control` header on the 200 and a new 429 status for clients +// that are over their limit. See ARCHITECTURE.md "Caching and rate limits". -import { CORS_HEADERS, getStore, jsonResponse } from '../../_shared'; +import { + CORS_HEADERS, + NO_STORE, + PREFLIGHT_MAX_AGE_SECONDS, + cacheTtlSeconds, + enforceRateLimit, + getStore, + jsonResponse, + publicCacheControl, +} from '../../_shared'; -// pg needs the Node.js runtime (not Edge). force-dynamic + no caching so every -// request reflects the freshest stored row, matching the old cache:'no-store'. +// pg needs the Node.js runtime (not Edge). force-dynamic because the response is +// computed per request from the database — including its own TTL. The caching +// that matters is the shared CDN tier in front of this function, driven by the +// `Cache-Control` header below, not Next's route cache. export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; -export async function GET(): Promise { +export async function GET(req: Request): Promise { + // First, before any database work: refusing a client and then querying anyway + // would protect nothing. Returns null when the request may proceed. + const limited = await enforceRateLimit(req); + if (limited) return limited; + try { const protocols = await getStore().listProtocolsWithLatestScore(); - return jsonResponse({ protocols }); + // TTL from the body's own staleness fields, so a cached response expires + // before there is a newer run for it to hide. Every protocol's lastRunAt + // counts — any of them landing changes this body. See ./_cache. + const ttl = cacheTtlSeconds(protocols.map((p) => p.lastRunAt)); + return jsonResponse({ protocols }, 200, { 'cache-control': publicCacheControl(ttl) }); } catch (err) { // Raw DB errors are logged server-side, never leaked to the client — same - // generic 500 the standalone API returned. + // generic 500 the standalone API returned. Never cached: a cached 500 would + // outlive the outage that caused it. console.error('GET /api/v1/protocols failed:', err); - return jsonResponse({ error: 'Internal server error' }, 500); + return jsonResponse({ error: 'Internal server error' }, 500, { 'cache-control': NO_STORE }); } } -/** CORS preflight for cross-origin browser clients. */ +/** + * CORS preflight for cross-origin browser clients. + * + * Cacheable for a day, and not rate limited: the policy is a constant that only + * changes on deploy, and every preflight a browser has to repeat is a function + * invocation that answers a question we already answered. + */ export function OPTIONS(): Response { - return new Response(null, { status: 204, headers: CORS_HEADERS }); + 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/db/migrations/0005_api_rate_limits.sql b/db/migrations/0005_api_rate_limits.sql new file mode 100644 index 0000000..e76fa65 --- /dev/null +++ b/db/migrations/0005_api_rate_limits.sql @@ -0,0 +1,51 @@ +-- Token buckets for the public API's rate limiter. +-- +-- WHY A TABLE AND NOT MEMORY. The API runs as Vercel serverless functions. Each +-- invocation is its own process, and Vercel runs as many concurrent instances as +-- traffic demands, so a module-level counter is per-instance: N warm instances +-- would allow N x the intended rate while reporting that the limit is enforced. +-- False confidence is worse than no limiter, so the counter has to live somewhere +-- every instance can see. The only such place this project already pays for is +-- Neon, and `pg` is already a dependency — hence a table. +-- +-- WHY A TOKEN BUCKET AND NOT A FIXED WINDOW. A fixed window lets a client spend +-- its whole allowance at the end of one window and again at the start of the +-- next, i.e. 2x the documented rate across a boundary, and it needs a row per +-- (client, window) so the table grows with time as well as with clients. A token +-- bucket is one row per client forever, refilled lazily from `updated_at`, and it +-- expresses the thing we actually want to say to an integrator: a sustained rate, +-- plus a burst they can spend up front. +-- +-- One row is one client bucket. `bucket_key` is a SALTED SHA-256 PREFIX OF THE +-- CLIENT IP, never the IP itself: the limiter only ever needs to know that two +-- requests came from the same place, and it never needs to know where that is. +-- Rows for clients that have gone quiet are pruned opportunistically by the +-- limiter (see db/src/rate-limit.ts), so the table stays proportional to *active* +-- clients rather than to every client ever seen. +-- +-- SAFE TO APPLY WHILE `main` IS SERVING. Nothing existing reads or writes this +-- table, and the code that will is written to fail OPEN if the table is missing +-- (a limiter outage must not become an API outage), so the migration and the +-- deploy can land in either order. +CREATE TABLE IF NOT EXISTS api_rate_limits ( + -- Salted SHA-256 prefix of the client identifier. Not reversible to an IP by + -- us or by anyone who gets the table without also getting the salt. + bucket_key text PRIMARY KEY, + + -- Tokens remaining after the request that last touched this row. Fractional, + -- because refill is continuous. May go as low as -1: a request that is refused + -- still spends the token it asked for, bounded at one token of debt, so a + -- client that ignores 429s and keeps hammering does not walk its own bucket + -- back up. Backing off for the advertised `Retry-After` clears it. + tokens double precision NOT NULL, + + -- When `tokens` was last computed. Refill is derived from the gap between this + -- and now(), which is why there is no background job: an idle bucket costs + -- nothing and is correct the moment it is next read. + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Supports only the prune sweep (DELETE ... WHERE updated_at < ...). The hot path +-- is a primary-key upsert and needs no help. +CREATE INDEX IF NOT EXISTS api_rate_limits_updated_at_idx + ON api_rate_limits (updated_at); diff --git a/db/src/index.ts b/db/src/index.ts index 0c52391..4947a31 100644 --- a/db/src/index.ts +++ b/db/src/index.ts @@ -10,3 +10,11 @@ export { type ProtocolDetail, type RecentRun, } from './store'; +export { + createRateLimiter, + decisionFromTokens, + type RateLimiter, + type RateLimitDecision, + type RateLimitPolicy, + type RateLimiterOptions, +} from './rate-limit'; diff --git a/db/src/rate-limit.test.ts b/db/src/rate-limit.test.ts new file mode 100644 index 0000000..248454a --- /dev/null +++ b/db/src/rate-limit.test.ts @@ -0,0 +1,83 @@ +// Tests for the rate limiter's arithmetic. +// +// WHY THESE EXIST: the limiter is the one part of the public API that decides to +// REFUSE a request, and it does so on a number that no test fixture and no live +// traffic will ever show us at the boundary. Production will run this at +// tokens = 59.9997 and tokens = -0.0001; the interesting cases are exactly the +// ones a real request never conveniently lands on. +// +// The refill itself is Postgres arithmetic (see TAKE_SQL) and is not testable +// without a database — which is the point of the split: the SQL decides the +// balance, and this decides what a balance MEANS, including the number an +// integrator is told to back off by. Getting that wrong is either a client that +// retries in a hot loop (Retry-After: 0) or one that sleeps a minute for a +// one-second problem. +// +// Run with: pnpm --filter @stenion/db test + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { decisionFromTokens, type RateLimitPolicy } from './rate-limit.ts'; + +/** The shipped default: 60 requests/minute sustained, 60 of burst. */ +const POLICY: RateLimitPolicy = { burst: 60, refillPerSecond: 1 }; + +describe('decisionFromTokens', () => { + it('allows while the balance covered the spend', () => { + for (const tokens of [59, 12.5, 1, 0.25]) { + assert.equal(decisionFromTokens(tokens, POLICY).allowed, true, `tokens=${tokens}`); + } + }); + + it('treats an exactly-drained bucket as served, not refused', () => { + // tokens === 0 means the request took the last whole token and got it. An + // off-by-one here refuses the 60th request of a 60-burst allowance, i.e. + // the documented limit would be a lie by one on every single client. + const decision = decisionFromTokens(0, POLICY); + assert.equal(decision.allowed, true); + assert.equal(decision.retryAfterSeconds, 0); + }); + + it('refuses once the balance went negative', () => { + assert.equal(decisionFromTokens(-0.001, POLICY).allowed, false); + assert.equal(decisionFromTokens(-1, POLICY).allowed, false); + }); + + it('never advertises Retry-After: 0 on a refusal', () => { + // A refused client told to retry after zero seconds retries immediately, is + // refused again, and has been handed a hot loop by the header that exists to + // prevent one. Every refusal must name a wait a client can actually honour. + for (const tokens of [-0.0001, -0.5, -1]) { + const decision = decisionFromTokens(tokens, POLICY); + assert.equal(decision.allowed, false); + assert.ok(decision.retryAfterSeconds >= 1, `tokens=${tokens}`); + } + }); + + it('scales the wait to the refill rate, not to a constant', () => { + // The bucket must climb from `tokens` back to 1 before a whole token is + // spendable, so the wait is (1 - tokens) / rate, rounded up to whole seconds. + // At one token per second and a full token of debt that is 2s. + assert.equal(decisionFromTokens(-1, POLICY).retryAfterSeconds, 2); + // Ten times slower refill, ten times the wait. + assert.equal(decisionFromTokens(-1, { burst: 60, refillPerSecond: 0.1 }).retryAfterSeconds, 20); + // Ten times faster refill still can't go below the one-second floor. + assert.equal(decisionFromTokens(-1, { burst: 60, refillPerSecond: 10 }).retryAfterSeconds, 1); + }); + + it('rounds the wait up, so honouring it always succeeds', () => { + // 1.5s rounded DOWN to 1 would send a well-behaved client back half a second + // early, get it refused a second time, and make the header look unreliable. + assert.equal(decisionFromTokens(-0.5, POLICY).retryAfterSeconds, 2); + }); + + it('emits a finite wait even if the policy says the bucket never refills', () => { + // Guards a misconfigured STENION_RATE_LIMIT_PER_MIN=0 turning into + // `Retry-After: Infinity`, which is an unparseable header, not a long wait. + const decision = decisionFromTokens(-1, { burst: 60, refillPerSecond: 0 }); + assert.equal(decision.allowed, false); + assert.ok(Number.isFinite(decision.retryAfterSeconds)); + assert.equal(decision.retryAfterSeconds, 60); + }); +}); diff --git a/db/src/rate-limit.ts b/db/src/rate-limit.ts new file mode 100644 index 0000000..ea279bf --- /dev/null +++ b/db/src/rate-limit.ts @@ -0,0 +1,160 @@ +// A distributed token-bucket rate limiter backed by the one thing every +// serverless instance shares: Postgres. +// +// WHAT THIS GUARANTEES, AND WHAT IT DOESN'T. +// +// - It IS shared. Every instance decrements the same row under a row lock, so +// the limit is the limit no matter how many functions Vercel has warm. This +// is the whole reason it isn't an in-memory Map: that would allow N x the +// documented rate while claiming to enforce it. +// - It is NOT a defence against a volumetric or distributed attack. It is +// per-client-key, so a thousand hosts each staying under the limit are a +// thousand clients as far as this is concerned. Stopping that is a network +// -edge job (Vercel's firewall), not an application one. +// - It costs ONE round trip per checked request. That is deliberate and it is +// the price of the guarantee above. The caller's job is to make sure most +// requests never get here: the CDN cache in front of the API means only +// cache MISSES reach the function at all, and callers should short-circuit +// an already-refused client in memory (memory may only ever REFUSE faster, +// never allow, so a per-instance cache is sound for that direction). +// +// This module owns no policy. The numbers — burst, sustained rate — arrive as +// arguments from the caller, which is what keeps the pure half testable without +// a database and the tuning in one documented place. + +import type { Pool } from 'pg'; + +/** How fast a bucket refills and how much it can hold. */ +export interface RateLimitPolicy { + /** Bucket capacity: the largest burst a client can spend in one go. */ + burst: number; + /** Tokens added per second — the sustained rate once the burst is gone. */ + refillPerSecond: number; +} + +/** The answer for one request. */ +export interface RateLimitDecision { + allowed: boolean; + /** + * Whole seconds until this client can expect to be served again. Always 0 + * when allowed, and always at least 1 when refused — a `Retry-After: 0` reads + * as an invitation to retry immediately, which is the opposite of the point. + */ + retryAfterSeconds: number; +} + +export interface RateLimiter { + /** Spend one token from `bucketKey`'s bucket, creating it if it's new. */ + take(bucketKey: string, policy: RateLimitPolicy): Promise; +} + +/** + * Refill-and-spend, as one statement so it is atomic without an explicit + * transaction. `ON CONFLICT DO UPDATE` takes a row lock, so two concurrent + * requests for the same bucket serialise and cannot both read the same balance. + * + * The refill is computed from the row's own `updated_at` rather than from a + * schedule, so an untouched bucket costs nothing and is still correct the + * instant it is read again. + * + * `GREATEST(..., -1)` floors the debt at one token. A refused request still + * spends, which is what stops a client that ignores 429s from hammering its own + * bucket back to zero — but the floor bounds the penalty, so backing off for the + * advertised Retry-After always clears it. See the migration for the long form. + */ +const TAKE_SQL = ` + INSERT INTO api_rate_limits AS rl (bucket_key, tokens, updated_at) + VALUES ($1, $2::float8 - 1, now()) + ON CONFLICT (bucket_key) DO UPDATE + SET tokens = GREATEST( + LEAST( + $2::float8, + rl.tokens + EXTRACT(EPOCH FROM (now() - rl.updated_at)) * $3::float8 + ) - 1, + -1 + ), + updated_at = now() + RETURNING tokens +`; + +/** + * Drop buckets nobody has touched in an hour. A bucket that idle is full by + * definition, so deleting it is indistinguishable from keeping it — the next + * request just inserts a fresh full one. + * + * This is why there is no cron job and no TTL machinery: the table stays + * proportional to *active* clients rather than to every client ever seen, and + * the sweep is an index range scan the caller runs on a small fraction of + * requests. + */ +const PRUNE_SQL = `DELETE FROM api_rate_limits WHERE updated_at < now() - interval '1 hour'`; + +/** + * Post-update balance -> decision. Pure, and exported so the arithmetic that + * decides both "refused" and the number we hand an integrator to back off by can + * be tested without a database. + * + * `tokens` is what the row holds AFTER this request spent one. Non-negative + * means the spend was covered; negative means it wasn't, and the shortfall is + * how long until it would be: the bucket needs to climb from `tokens` back to 1 + * before another whole token can be spent. + */ +export function decisionFromTokens(tokens: number, policy: RateLimitPolicy): RateLimitDecision { + if (tokens >= 0) return { allowed: true, retryAfterSeconds: 0 }; + // A non-positive refill rate would mean "never" — clamp to a minute rather + // than emitting Infinity into a header. Config validation should stop this + // reaching here; this is the belt to that braces. + if (!(policy.refillPerSecond > 0)) return { allowed: false, retryAfterSeconds: 60 }; + const seconds = (1 - tokens) / policy.refillPerSecond; + return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil(seconds)) }; +} + +export interface RateLimiterOptions { + /** + * Probability that a served request also runs the prune sweep. Default 1/256: + * frequent enough that the table can't grow unbounded, rare enough that the + * sweep is invisible in latency. Random rather than a counter because a + * counter is per-instance and resets on every cold start, which would make + * fresh instances sweep on their first request. + */ + pruneChance?: number; + /** Injectable for tests. */ + random?: () => number; +} + +export function createRateLimiter(pool: Pool, options: RateLimiterOptions = {}): RateLimiter { + const pruneChance = options.pruneChance ?? 1 / 256; + const random = options.random ?? Math.random; + + return { + async take(bucketKey, policy) { + const { rows } = await pool.query<{ tokens: number | string }>(TAKE_SQL, [ + bucketKey, + policy.burst, + policy.refillPerSecond, + ]); + + const row = rows[0]; + // RETURNING on an upsert always produces exactly one row. If it somehow + // didn't, throwing is right: the caller fails open, and an API that keeps + // serving beats an API that 429s everyone because a limiter broke. + if (!row) throw new Error('rate limiter: upsert returned no row'); + + const decision = decisionFromTokens(Number(row.tokens), policy); + + // Only sweep on the served path. Under abuse almost everything is refused, + // and that is precisely when the database should be doing less, not more. + if (decision.allowed && random() < pruneChance) { + try { + await pool.query(PRUNE_SQL); + } catch (err) { + // Housekeeping. A failed sweep is a growing table, not a broken + // request — never let it turn a 200 into a 500. + console.error('rate limiter: prune failed:', err); + } + } + + return decision; + }, + }; +}