From 2afc18d9a8bf4306e16a5dc6577a836a319436af Mon Sep 17 00:00:00 2001 From: changliuchang777 <1551617642@qq.com> Date: Mon, 6 Jul 2026 00:45:52 +0800 Subject: [PATCH] fix(security): harden rate limiter proxy handling --- README.md | 601 ++--- src/__tests__/rateLimit.test.ts | 447 ++-- src/index.ts | 4198 ++++++++++++++++--------------- 3 files changed, 2679 insertions(+), 2567 deletions(-) diff --git a/README.md b/README.md index 0caac48..6343373 100644 --- a/README.md +++ b/README.md @@ -1,303 +1,304 @@ -# stableroute-backend - -API gateway, routing engine, and pricing service for [StableRoute](https://github.com/your-org/stableroute) — Stellar liquidity routing. - -## What this repo contains - -- **Express** REST API (TypeScript) -- **Health** and **quote** endpoints as a base for the routing engine and pricing service - -## API reference - -See [docs/api.md](docs/api.md) for the complete endpoint and error-code -reference, including request/response shapes and `curl` examples. - -### API-key rotation - -`POST /api/v1/api-keys/:prefix/rotate` rotates a key without downtime. It -locates the key by its 8-char prefix, mints a new `srk_` successor inheriting -the predecessor's `label`, and returns the new raw key exactly once (201 — never -logged). The predecessor is stamped with `rotatedAt` and a `graceExpiresAt` -deadline (`ROTATION_GRACE_MS`, default 1h) so both keys remain valid during the -overlap window, letting callers cut over gracefully. `GET /api/v1/api-keys` -surfaces `rotatedAt` on rotated predecessor records (raw keys are never -returned). An unknown prefix returns `404 not_found`. - -## Architecture & request lifecycle - -See [docs/architecture.md](docs/architecture.md) for the in-memory store model, -the Express middleware chain in execution order (with each layer's purpose and -rationale), a Mermaid request-flow diagram, and the canonical error envelope. - -## Prerequisites - -- Node.js 18+ -- npm - -## Setup (contributors) - -1. Clone the repo and enter the directory: - ```bash - git clone && cd stableroute-backend - ``` -2. Install dependencies: - ```bash - npm install - ``` -3. Build and test: - ```bash - npm run build - npm test - ``` -4. Run locally: - ```bash - npm run dev - ``` - API: `http://localhost:3001` (or `PORT` env var). See - [Configuration](#configuration) for the full list of environment - variables and how to use the `.env.example` template. - -## Configuration - -The backend is configured entirely through environment variables. The -table below lists every variable the code reads — there are no others. - -| Variable | Purpose | Default | Example | -|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------|--------------------------| +# stableroute-backend + +API gateway, routing engine, and pricing service for [StableRoute](https://github.com/your-org/stableroute) — Stellar liquidity routing. + +## What this repo contains + +- **Express** REST API (TypeScript) +- **Health** and **quote** endpoints as a base for the routing engine and pricing service + +## API reference + +See [docs/api.md](docs/api.md) for the complete endpoint and error-code +reference, including request/response shapes and `curl` examples. + +### API-key rotation + +`POST /api/v1/api-keys/:prefix/rotate` rotates a key without downtime. It +locates the key by its 8-char prefix, mints a new `srk_` successor inheriting +the predecessor's `label`, and returns the new raw key exactly once (201 — never +logged). The predecessor is stamped with `rotatedAt` and a `graceExpiresAt` +deadline (`ROTATION_GRACE_MS`, default 1h) so both keys remain valid during the +overlap window, letting callers cut over gracefully. `GET /api/v1/api-keys` +surfaces `rotatedAt` on rotated predecessor records (raw keys are never +returned). An unknown prefix returns `404 not_found`. + +## Architecture & request lifecycle + +See [docs/architecture.md](docs/architecture.md) for the in-memory store model, +the Express middleware chain in execution order (with each layer's purpose and +rationale), a Mermaid request-flow diagram, and the canonical error envelope. + +## Prerequisites + +- Node.js 18+ +- npm + +## Setup (contributors) + +1. Clone the repo and enter the directory: + ```bash + git clone && cd stableroute-backend + ``` +2. Install dependencies: + ```bash + npm install + ``` +3. Build and test: + ```bash + npm run build + npm test + ``` +4. Run locally: + ```bash + npm run dev + ``` + API: `http://localhost:3001` (or `PORT` env var). See + [Configuration](#configuration) for the full list of environment + variables and how to use the `.env.example` template. + +## Configuration + +The backend is configured entirely through environment variables. The +table below lists every variable the code reads — there are no others. + +| Variable | Purpose | Default | Example | +|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------|--------------------------| | `PORT` | TCP port the HTTP server binds to. | `3001` | `8080` | | `NODE_ENV` | Runtime mode. Setting it to `test` disables the rate limiter and per-request logging (used by Jest). | _(unset)_ | `production` | +| `TRUST_PROXY` | Express trust-proxy setting for deployments behind known proxies. Leave unset/`false` for direct deployments so spoofed `X-Forwarded-For` headers are ignored. Supports `true`, hop counts, names like `loopback`, or comma-separated subnets. | `false` | `loopback,10.0.0.0/8` | | `SHUTDOWN_GRACE_MS` | Grace period in milliseconds before the shutdown handler forces `process.exit(1)` when `server.close()` is still draining. Must be a positive integer; invalid values use the default. | `10000` | `30000` | -| `GIT_COMMIT` | Commit SHA surfaced by `GET /api/v1/version`. Injected by the deploy pipeline; falls back to `"unknown"`. | _(unset)_ | `a1b2c3d` | -| `BUILD_TIME` | Build timestamp surfaced by `GET /api/v1/version`. Injected by the deploy pipeline; falls back to `"unknown"`. | _(unset)_ | `2026-01-01T00:00:00Z` | - -### Build/version endpoint - -`GET /api/v1/version` returns lightweight, unauthenticated build identity so -operators can confirm which build is live during an incident: - -```json -{ "name": "stableroute-backend", "version": "0.1.0", "commit": "a1b2c3d", "buildTime": "2026-01-01T00:00:00Z", "node": "v20.0.0" } -``` - -`name`/`version` come from `package.json`; `commit`/`buildTime` come from the -`GIT_COMMIT`/`BUILD_TIME` env vars (each falling back to `"unknown"`); `node` -is `process.version`. No health checks run and no secrets are exposed. - -`.env.example` is the template for these variables. Copy it to `.env` -and edit the values for local development: - -```bash -cp .env.example .env -``` - -`.env` is git-ignored (see `.gitignore`), so your local values are never -committed. The application does not auto-load `.env`; export the -variables into your shell (or use your process manager / `--env-file`) -before starting the server. - -## Scripts - -| Script | Description | -|--------|-------------| -| `npm run build` | Compile TypeScript to `dist/` | -| `npm run start` | Run production server (`dist/index.js`) | -| `npm run dev` | Run with ts-node-dev (watch) | -| `npm test` | Run Jest tests | -| `npm run test:coverage` | Run Jest with coverage (`coverage/` output) | -| `npm run lint` | Run ESLint | - -## CI/CD - -On every push/PR to `main`, GitHub Actions runs: - -- `npm ci` -- `npm run lint` — ESLint 9 flat config (`eslint.config.mjs`) targeting `src/**/*.ts` -- `npm run build` -- `npm test` -- `npm run test:coverage` — enforces Jest coverage thresholds (≥ 90 % statements/lines, ≥ 88 % functions, ≥ 80 % branches) and uploads the HTML + lcov report as a CI artifact (`coverage-report`, retained 14 days). - -Ensure these all pass locally before pushing. To run lint locally: - -```bash -npm run lint -``` - -## Deep readiness probe - -`GET /api/v1/health/deep` is designed as a Kubernetes readiness probe. It reports: - -- **`status`**: `"ok"` if all checks pass and the service is not paused; - `"paused"` if the admin pause has been toggled; `"degraded"` if any required - health check fails. -- **`checks[]`**: An array of `{ name, status, durationMs }` objects, one per - dependency. Current checks: - - `storage` — verifies the in-memory store can write and read back. - - `clock` — verifies the system clock is producing post-epoch timestamps. -- **`uptimeSeconds`**, **`memory`** (rssMb, heapUsedMb), **`pid`**, **`node`** — - kept for backward compatibility. - -When any required check fails, the endpoint returns **503** with -`status: "degraded"`. When the service is paused it returns **200** with -`status: "paused"`. When all checks pass it returns **200** with -`status: "ok"`. - -Checks are time-bounded (5s timeout via `AbortController`) so the probe -never hangs. - -## Read-only maintenance mode - -In addition to the full `paused` kill-switch, the backend supports a softer -**read-only** mode that keeps reads and quotes flowing while freezing other -mutations — useful during a migration. - -- `POST /api/v1/admin/read-only` — enable read-only mode. -- `POST /api/v1/admin/read-write` — disable it (always reachable, so operators - can never be locked out). -- `GET /api/v1/admin/status` returns `{ paused, readOnly }`. - -While read-only is on (and not paused), `GET`/`HEAD`/`OPTIONS` and the quote -endpoints (`/api/v1/quote`, `/api/v1/quote/reverse`, `/api/v1/quote/bulk`) -succeed; every other mutating write is rejected with `503 read_only_mode` using -the canonical error body. `paused` is strictly stronger: when the service is -paused, the existing pause behavior (`503 service_paused`) wins. - -## OpenAPI spec - -The OpenAPI document is the single source of truth in `src/openapi.ts` -(exported as `openApiSpec`). The `GET /api/v1/openapi.json` handler serves it -verbatim instead of an inline literal, so the spec can be imported by tests. - -`src/__tests__/openapi.test.ts` includes a **route-drift guard** that walks the -Express router stack, converts each registered `/api/v1/...` route to its -OpenAPI templated form (`:param` → `{param}`), and asserts every discovered path -appears as a key in `openApiSpec.paths`. This makes it impossible to ship a new -endpoint without documenting it. - -## Storage adapter - -All persistent state is accessed through a pluggable `StorageAdapter` interface -defined in `src/store/adapter.ts`. The active backend is selected at startup via -the `STORAGE_BACKEND` environment variable: - -| `STORAGE_BACKEND` | Adapter | Durability | -|-------------------|-------------------|---------------------------------| -| `memory` (default) | `InMemoryAdapter` | State is lost on process restart. | -| `json-file` | `JsonFileAdapter` | State is written to `STORAGE_FILE` (default `./stableroute-data.json`) and reloaded on startup, so the registry survives restarts. | - -**Example — durable local dev:** - -```bash -STORAGE_BACKEND=json-file STORAGE_FILE=./data/sr.json npm run dev -``` - -The `StorageAdapter` interface covers pairs, pair metadata, API keys, webhooks, -and events. Adding a new durable backend (e.g. SQLite) only requires -implementing the interface and registering the backend in the `createAdapter` -factory. - -## In-memory stores - -All runtime state lives in `src/stores.ts` — a typed module with explicit -accessors and a `resetStores()` helper for test isolation: - -| Store | Type | Purpose | -| ------------------ | --------------------- | ------------------------------------------------- | -| `pairRegistry` | `Set` | Registered `"SOURCE::DEST"` pair keys | -| `pairMeta` | `Map` | Per-pair fee / amount / liquidity metadata | -| `apiKeyStore` | `Map` | Generated API key records | -| `webhookStore` | `Map` | Registered webhook records | -| `eventLog` | `AppEvent[]` | Bounded ring-buffer of application events | -| `rateBuckets` | `Map` | Per-IP sliding-window timestamps (rate limiter) | -| `config` | `Record` | Tunable runtime config (rate limits, bulk caps) | -| `paused` | `boolean` | Service-level pause flag | - -Call `resetStores()` in test `beforeEach` / `afterEach` hooks to prevent -cross-test bleed. This function is not exposed via any HTTP route. - -## Audit events - -`GET /api/v1/events` returns the in-memory audit log. In addition to the pair -lifecycle events (`pair.registered`, `pair.refreshed`, `pair.unregistered`), -the following security-relevant mutations are recorded: - -| Event | Payload (no secrets) | -| ----------------- | --------------------------- | -| `apikey.created` | `{ prefix, label }` | -| `apikey.deleted` | `{ prefix }` | -| `webhook.created` | `{ id, url }` | -| `webhook.deleted` | `{ id }` | -| `admin.paused` | `{}` | -| `admin.unpaused` | `{}` | - -Payloads never include secret material — the raw API key and any webhook -secret are deliberately excluded. The existing `EVENT_LOG_CAP` eviction applies -unchanged. - -## Request correlation (`X-Request-Id`) - -Every request is assigned a correlation id that is echoed in the `X-Request-Id` -response header and included as `requestId` in every JSON error body. - -**Accepted format for inbound `X-Request-Id`:** -- Characters: `A–Z`, `a–z`, `0–9`, `.`, `_`, `-` (allowlist only — no control - characters, spaces, CR, LF, or other non-token bytes). -- Length: 1–200 characters. - -Values that pass this check are echoed back unchanged. Values that fail — including -anything containing CRLF sequences or other injection vectors — are silently -replaced with a freshly generated UUID v4. This prevents header-injection and -log-injection attacks. - -## Error responses - -Handlers use a shared `sendError` helper so 400/404/413/500-style responses keep the canonical `{ error, message, requestId }` shape. The request id is attached before JSON parsing, which keeps body-parser errors correlated with the `X-Request-Id` response header. - -A request body that is not valid JSON is treated as a client error: the final -error handler maps the body-parser parse failure (`entity.parse.failed` / -`SyntaxError`) to `400 invalid_json` with a fixed, non-leaking message -(`request body is not valid JSON`) — the raw parser text and any stack trace are -never echoed. The `413 payload_too_large` mapping still takes precedence, and -genuinely unexpected errors continue to fall through to `500 internal_error`. - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, branch naming, local checks, and PR expectations. - -Quick checklist: - -1. Fork the repo and create a branch from `main`. -2. Install deps, add tests for new behavior, keep `npm run build`, `npm run lint`, and `npm test` passing. -3. Open a PR; CI must be green. - -## Coverage - -Test coverage thresholds are enforced in CI via Jest's `coverageThreshold`. -Current targets: **statements ≥ 90%**, **branches ≥ 80%**, **functions ≥ 88%**, -**lines ≥ 90%**. - -> **Note:** `server.ts` is now refactored into side-effect-free, exported -> functions (`createServer`, `registerSignalHandlers`, `start`) with the actual -> `app.listen` guarded by `require.main === module`. It can therefore be -> imported and exercised by `src/__tests__/server.test.ts` (it starts on an -> ephemeral port, serves `/health`, and closes cleanly) without keeping the -> event loop alive. The signal-handler shutdown body calls `process.exit`, so -> it is deliberately not invoked under test, which is why `server.ts` keeps a -> small amount of uncovered branch. - -Generate a local coverage report: - -```bash -npm run test:coverage -``` - -Coverage reports are uploaded as a CI artifact on every push/PR. - -## Security - -For the vulnerability disclosure process, supported versions, and the gateway -threat model (unauthenticated admin routes, wildcard CORS, webhook SSRF, and -more) see [SECURITY.md](SECURITY.md). - -## License - -MIT +| `GIT_COMMIT` | Commit SHA surfaced by `GET /api/v1/version`. Injected by the deploy pipeline; falls back to `"unknown"`. | _(unset)_ | `a1b2c3d` | +| `BUILD_TIME` | Build timestamp surfaced by `GET /api/v1/version`. Injected by the deploy pipeline; falls back to `"unknown"`. | _(unset)_ | `2026-01-01T00:00:00Z` | + +### Build/version endpoint + +`GET /api/v1/version` returns lightweight, unauthenticated build identity so +operators can confirm which build is live during an incident: + +```json +{ "name": "stableroute-backend", "version": "0.1.0", "commit": "a1b2c3d", "buildTime": "2026-01-01T00:00:00Z", "node": "v20.0.0" } +``` + +`name`/`version` come from `package.json`; `commit`/`buildTime` come from the +`GIT_COMMIT`/`BUILD_TIME` env vars (each falling back to `"unknown"`); `node` +is `process.version`. No health checks run and no secrets are exposed. + +`.env.example` is the template for these variables. Copy it to `.env` +and edit the values for local development: + +```bash +cp .env.example .env +``` + +`.env` is git-ignored (see `.gitignore`), so your local values are never +committed. The application does not auto-load `.env`; export the +variables into your shell (or use your process manager / `--env-file`) +before starting the server. + +## Scripts + +| Script | Description | +|--------|-------------| +| `npm run build` | Compile TypeScript to `dist/` | +| `npm run start` | Run production server (`dist/index.js`) | +| `npm run dev` | Run with ts-node-dev (watch) | +| `npm test` | Run Jest tests | +| `npm run test:coverage` | Run Jest with coverage (`coverage/` output) | +| `npm run lint` | Run ESLint | + +## CI/CD + +On every push/PR to `main`, GitHub Actions runs: + +- `npm ci` +- `npm run lint` — ESLint 9 flat config (`eslint.config.mjs`) targeting `src/**/*.ts` +- `npm run build` +- `npm test` +- `npm run test:coverage` — enforces Jest coverage thresholds (≥ 90 % statements/lines, ≥ 88 % functions, ≥ 80 % branches) and uploads the HTML + lcov report as a CI artifact (`coverage-report`, retained 14 days). + +Ensure these all pass locally before pushing. To run lint locally: + +```bash +npm run lint +``` + +## Deep readiness probe + +`GET /api/v1/health/deep` is designed as a Kubernetes readiness probe. It reports: + +- **`status`**: `"ok"` if all checks pass and the service is not paused; + `"paused"` if the admin pause has been toggled; `"degraded"` if any required + health check fails. +- **`checks[]`**: An array of `{ name, status, durationMs }` objects, one per + dependency. Current checks: + - `storage` — verifies the in-memory store can write and read back. + - `clock` — verifies the system clock is producing post-epoch timestamps. +- **`uptimeSeconds`**, **`memory`** (rssMb, heapUsedMb), **`pid`**, **`node`** — + kept for backward compatibility. + +When any required check fails, the endpoint returns **503** with +`status: "degraded"`. When the service is paused it returns **200** with +`status: "paused"`. When all checks pass it returns **200** with +`status: "ok"`. + +Checks are time-bounded (5s timeout via `AbortController`) so the probe +never hangs. + +## Read-only maintenance mode + +In addition to the full `paused` kill-switch, the backend supports a softer +**read-only** mode that keeps reads and quotes flowing while freezing other +mutations — useful during a migration. + +- `POST /api/v1/admin/read-only` — enable read-only mode. +- `POST /api/v1/admin/read-write` — disable it (always reachable, so operators + can never be locked out). +- `GET /api/v1/admin/status` returns `{ paused, readOnly }`. + +While read-only is on (and not paused), `GET`/`HEAD`/`OPTIONS` and the quote +endpoints (`/api/v1/quote`, `/api/v1/quote/reverse`, `/api/v1/quote/bulk`) +succeed; every other mutating write is rejected with `503 read_only_mode` using +the canonical error body. `paused` is strictly stronger: when the service is +paused, the existing pause behavior (`503 service_paused`) wins. + +## OpenAPI spec + +The OpenAPI document is the single source of truth in `src/openapi.ts` +(exported as `openApiSpec`). The `GET /api/v1/openapi.json` handler serves it +verbatim instead of an inline literal, so the spec can be imported by tests. + +`src/__tests__/openapi.test.ts` includes a **route-drift guard** that walks the +Express router stack, converts each registered `/api/v1/...` route to its +OpenAPI templated form (`:param` → `{param}`), and asserts every discovered path +appears as a key in `openApiSpec.paths`. This makes it impossible to ship a new +endpoint without documenting it. + +## Storage adapter + +All persistent state is accessed through a pluggable `StorageAdapter` interface +defined in `src/store/adapter.ts`. The active backend is selected at startup via +the `STORAGE_BACKEND` environment variable: + +| `STORAGE_BACKEND` | Adapter | Durability | +|-------------------|-------------------|---------------------------------| +| `memory` (default) | `InMemoryAdapter` | State is lost on process restart. | +| `json-file` | `JsonFileAdapter` | State is written to `STORAGE_FILE` (default `./stableroute-data.json`) and reloaded on startup, so the registry survives restarts. | + +**Example — durable local dev:** + +```bash +STORAGE_BACKEND=json-file STORAGE_FILE=./data/sr.json npm run dev +``` + +The `StorageAdapter` interface covers pairs, pair metadata, API keys, webhooks, +and events. Adding a new durable backend (e.g. SQLite) only requires +implementing the interface and registering the backend in the `createAdapter` +factory. + +## In-memory stores + +All runtime state lives in `src/stores.ts` — a typed module with explicit +accessors and a `resetStores()` helper for test isolation: + +| Store | Type | Purpose | +| ------------------ | --------------------- | ------------------------------------------------- | +| `pairRegistry` | `Set` | Registered `"SOURCE::DEST"` pair keys | +| `pairMeta` | `Map` | Per-pair fee / amount / liquidity metadata | +| `apiKeyStore` | `Map` | Generated API key records | +| `webhookStore` | `Map` | Registered webhook records | +| `eventLog` | `AppEvent[]` | Bounded ring-buffer of application events | +| `rateBuckets` | `Map` | Per-IP sliding-window timestamps (rate limiter) | +| `config` | `Record` | Tunable runtime config (rate limits, bulk caps) | +| `paused` | `boolean` | Service-level pause flag | + +Call `resetStores()` in test `beforeEach` / `afterEach` hooks to prevent +cross-test bleed. This function is not exposed via any HTTP route. + +## Audit events + +`GET /api/v1/events` returns the in-memory audit log. In addition to the pair +lifecycle events (`pair.registered`, `pair.refreshed`, `pair.unregistered`), +the following security-relevant mutations are recorded: + +| Event | Payload (no secrets) | +| ----------------- | --------------------------- | +| `apikey.created` | `{ prefix, label }` | +| `apikey.deleted` | `{ prefix }` | +| `webhook.created` | `{ id, url }` | +| `webhook.deleted` | `{ id }` | +| `admin.paused` | `{}` | +| `admin.unpaused` | `{}` | + +Payloads never include secret material — the raw API key and any webhook +secret are deliberately excluded. The existing `EVENT_LOG_CAP` eviction applies +unchanged. + +## Request correlation (`X-Request-Id`) + +Every request is assigned a correlation id that is echoed in the `X-Request-Id` +response header and included as `requestId` in every JSON error body. + +**Accepted format for inbound `X-Request-Id`:** +- Characters: `A–Z`, `a–z`, `0–9`, `.`, `_`, `-` (allowlist only — no control + characters, spaces, CR, LF, or other non-token bytes). +- Length: 1–200 characters. + +Values that pass this check are echoed back unchanged. Values that fail — including +anything containing CRLF sequences or other injection vectors — are silently +replaced with a freshly generated UUID v4. This prevents header-injection and +log-injection attacks. + +## Error responses + +Handlers use a shared `sendError` helper so 400/404/413/500-style responses keep the canonical `{ error, message, requestId }` shape. The request id is attached before JSON parsing, which keeps body-parser errors correlated with the `X-Request-Id` response header. + +A request body that is not valid JSON is treated as a client error: the final +error handler maps the body-parser parse failure (`entity.parse.failed` / +`SyntaxError`) to `400 invalid_json` with a fixed, non-leaking message +(`request body is not valid JSON`) — the raw parser text and any stack trace are +never echoed. The `413 payload_too_large` mapping still takes precedence, and +genuinely unexpected errors continue to fall through to `500 internal_error`. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, branch naming, local checks, and PR expectations. + +Quick checklist: + +1. Fork the repo and create a branch from `main`. +2. Install deps, add tests for new behavior, keep `npm run build`, `npm run lint`, and `npm test` passing. +3. Open a PR; CI must be green. + +## Coverage + +Test coverage thresholds are enforced in CI via Jest's `coverageThreshold`. +Current targets: **statements ≥ 90%**, **branches ≥ 80%**, **functions ≥ 88%**, +**lines ≥ 90%**. + +> **Note:** `server.ts` is now refactored into side-effect-free, exported +> functions (`createServer`, `registerSignalHandlers`, `start`) with the actual +> `app.listen` guarded by `require.main === module`. It can therefore be +> imported and exercised by `src/__tests__/server.test.ts` (it starts on an +> ephemeral port, serves `/health`, and closes cleanly) without keeping the +> event loop alive. The signal-handler shutdown body calls `process.exit`, so +> it is deliberately not invoked under test, which is why `server.ts` keeps a +> small amount of uncovered branch. + +Generate a local coverage report: + +```bash +npm run test:coverage +``` + +Coverage reports are uploaded as a CI artifact on every push/PR. + +## Security + +For the vulnerability disclosure process, supported versions, and the gateway +threat model (unauthenticated admin routes, wildcard CORS, webhook SSRF, and +more) see [SECURITY.md](SECURITY.md). + +## License + +MIT diff --git a/src/__tests__/rateLimit.test.ts b/src/__tests__/rateLimit.test.ts index 8cf5c7a..6bec34e 100644 --- a/src/__tests__/rateLimit.test.ts +++ b/src/__tests__/rateLimit.test.ts @@ -1,176 +1,244 @@ -import request from "supertest"; -import app, { evictRateBuckets } from "../index"; -import { rateBuckets, RATE_BUCKETS_MAX_IPS, resetStores } from "../stores"; - -// Each test advances the clock by 120 s relative to the previous test's -// base so that bucket entries from prior tests are always outside the -// 60 s window and cannot bleed across tests. -const WINDOW_MS = 60_000; -let baseTime = Date.now(); - -function advanceBase() { - baseTime += WINDOW_MS * 2; -} - -beforeEach(() => { - advanceBase(); - jest.spyOn(Date, "now").mockReturnValue(baseTime); -}); - -afterEach(() => { - jest.restoreAllMocks(); -}); - -// The Express rate-limiter middleware is disabled under NODE_ENV=test so the -// test suite can make many requests without hitting the limit. The bucket -// logic is exercised directly via evictRateBuckets. - -describe("rate limiter — HTTP (middleware disabled in test env)", () => { - it("always allows requests when NODE_ENV=test", async () => { - // Send 70 requests — all should succeed because the middleware is off. - for (let i = 0; i < 70; i++) { - const res = await request(app).get("/health"); - expect(res.status).toBe(200); - } - }); -}); - -describe("rate limiter — bucket logic via evictRateBuckets", () => { - const LIMIT = 60; - - beforeEach(() => { - resetStores(); - }); - - afterEach(() => { - resetStores(); - }); - - it("allows up to 60 timestamps in a window without blocking", () => { - const ip = "10.10.0.1"; - const now = baseTime; - for (let i = 0; i < LIMIT; i++) { - const bucket = evictRateBuckets(ip, now, WINDOW_MS); - expect(bucket.length).toBeLessThan(LIMIT); - bucket.push(now); - rateBuckets.set(ip, bucket); - } - const finalBucket = rateBuckets.get(ip)!; - expect(finalBucket).toHaveLength(LIMIT); - }); - - it("bucket reaches the limit on the 61st push", () => { - const ip = "10.10.0.2"; - const now = baseTime; - for (let i = 0; i < LIMIT; i++) { - const b = evictRateBuckets(ip, now, WINDOW_MS); - b.push(now); - rateBuckets.set(ip, b); - } - // The 61st eviction returns a full bucket — caller must reject - const blocked = evictRateBuckets(ip, now, WINDOW_MS); - expect(blocked.length).toBe(LIMIT); - }); - - it("bucket drains to zero after the window expires and key is deleted", () => { - const ip = "10.10.0.3"; - const now = baseTime; - const b = evictRateBuckets(ip, now, WINDOW_MS); - b.push(now); - rateBuckets.set(ip, b); - - // Advance well past the window - const later = now + WINDOW_MS + 1; - evictRateBuckets(ip, later, WINDOW_MS); - expect(rateBuckets.has(ip)).toBe(false); - }); - - it("re-allows a returning IP after its bucket was evicted", () => { - const ip = "10.10.0.4"; - const now = baseTime; - const b = evictRateBuckets(ip, now, WINDOW_MS); - b.push(now); - rateBuckets.set(ip, b); - - // Age out the bucket - const later = now + WINDOW_MS + 1; - evictRateBuckets(ip, later, WINDOW_MS); - expect(rateBuckets.has(ip)).toBe(false); - - // IP returns — fresh empty bucket - const fresh = evictRateBuckets(ip, later + 1000, WINDOW_MS); - expect(fresh).toHaveLength(0); +import request from "supertest"; +import app, { evictRateBuckets, parseTrustProxy, pruneExpiredRateBuckets } from "../index"; +import { rateBuckets, RATE_BUCKETS_MAX_IPS, resetStores } from "../stores"; + +// Each test advances the clock by 120 s relative to the previous test's +// base so that bucket entries from prior tests are always outside the +// 60 s window and cannot bleed across tests. +const WINDOW_MS = 60_000; +let baseTime = Date.now(); + +function advanceBase() { + baseTime += WINDOW_MS * 2; +} + +beforeEach(() => { + advanceBase(); + jest.spyOn(Date, "now").mockReturnValue(baseTime); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// The Express rate-limiter middleware is disabled under NODE_ENV=test so the +// test suite can make many requests without hitting the limit. The bucket +// logic is exercised directly via evictRateBuckets. + +describe("rate limiter — HTTP (middleware disabled in test env)", () => { + it("always allows requests when NODE_ENV=test", async () => { + // Send 70 requests — all should succeed because the middleware is off. + for (let i = 0; i < 70; i++) { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + } + }); +}); + +describe("rate limiter — bucket logic via evictRateBuckets", () => { + const LIMIT = 60; + + beforeEach(() => { + resetStores(); + }); + + afterEach(() => { + resetStores(); + }); + + it("allows up to 60 timestamps in a window without blocking", () => { + const ip = "10.10.0.1"; + const now = baseTime; + for (let i = 0; i < LIMIT; i++) { + const bucket = evictRateBuckets(ip, now, WINDOW_MS); + expect(bucket.length).toBeLessThan(LIMIT); + bucket.push(now); + rateBuckets.set(ip, bucket); + } + const finalBucket = rateBuckets.get(ip)!; + expect(finalBucket).toHaveLength(LIMIT); + }); + + it("bucket reaches the limit on the 61st push", () => { + const ip = "10.10.0.2"; + const now = baseTime; + for (let i = 0; i < LIMIT; i++) { + const b = evictRateBuckets(ip, now, WINDOW_MS); + b.push(now); + rateBuckets.set(ip, b); + } + // The 61st eviction returns a full bucket — caller must reject + const blocked = evictRateBuckets(ip, now, WINDOW_MS); + expect(blocked.length).toBe(LIMIT); + }); + + it("bucket drains to zero after the window expires and key is deleted", () => { + const ip = "10.10.0.3"; + const now = baseTime; + const b = evictRateBuckets(ip, now, WINDOW_MS); + b.push(now); + rateBuckets.set(ip, b); + + // Advance well past the window + const later = now + WINDOW_MS + 1; + evictRateBuckets(ip, later, WINDOW_MS); + expect(rateBuckets.has(ip)).toBe(false); + }); + + it("re-allows a returning IP after its bucket was evicted", () => { + const ip = "10.10.0.4"; + const now = baseTime; + const b = evictRateBuckets(ip, now, WINDOW_MS); + b.push(now); + rateBuckets.set(ip, b); + + // Age out the bucket + const later = now + WINDOW_MS + 1; + evictRateBuckets(ip, later, WINDOW_MS); + expect(rateBuckets.has(ip)).toBe(false); + + // IP returns — fresh empty bucket + const fresh = evictRateBuckets(ip, later + 1000, WINDOW_MS); + expect(fresh).toHaveLength(0); + }); +}); + +describe("evictRateBuckets — idle eviction", () => { + const WINDOW_MS = 60_000; + + beforeEach(() => { + resetStores(); + }); + + afterEach(() => { + resetStores(); + }); + + it("removes the key when all timestamps have aged out of the window", () => { + const ip = "10.0.0.1"; + const oldTime = 1_000_000; + rateBuckets.set(ip, [oldTime]); + + // now is far past oldTime + window + const now = oldTime + WINDOW_MS + 1; + const result = evictRateBuckets(ip, now, WINDOW_MS); + + expect(result).toHaveLength(0); + expect(rateBuckets.has(ip)).toBe(false); + }); + + it("keeps the key when at least one timestamp is still in-window", () => { + const ip = "10.0.0.2"; + const now = 2_000_000; + rateBuckets.set(ip, [now - 1000, now - WINDOW_MS - 1]); + + const result = evictRateBuckets(ip, now, WINDOW_MS); + + expect(result).toHaveLength(1); + expect(rateBuckets.has(ip)).toBe(true); + }); + + it("does not insert a key for a brand-new IP with no timestamps", () => { + const ip = "10.0.0.3"; + const now = 3_000_000; + // IP never seen before — evictRateBuckets returns empty array but does + // NOT write the key (the middleware writes it after the call) + const result = evictRateBuckets(ip, now, WINDOW_MS); + + expect(result).toHaveLength(0); + expect(rateBuckets.has(ip)).toBe(false); + }); + + it("a returning IP after its bucket was evicted starts fresh", () => { + const ip = "10.0.0.4"; + const oldTime = 5_000_000; + rateBuckets.set(ip, [oldTime]); + + // First call ages out the bucket and deletes the key + const now1 = oldTime + WINDOW_MS + 1; + evictRateBuckets(ip, now1, WINDOW_MS); + expect(rateBuckets.has(ip)).toBe(false); + + // Second call — IP is unknown again, returns empty array + const now2 = now1 + 1000; + const result = evictRateBuckets(ip, now2, WINDOW_MS); + expect(result).toHaveLength(0); + expect(rateBuckets.has(ip)).toBe(false); + }); +}); + +describe("evictRateBuckets — ceiling eviction", () => { + const WINDOW_MS = 60_000; + const now = 10_000_000; + + beforeEach(() => { + resetStores(); + }); + + afterEach(() => { + resetStores(); + }); + + it("sheds the oldest entry when the IP ceiling is exceeded", () => { + // Fill the map to exactly the ceiling + for (let i = 0; i < RATE_BUCKETS_MAX_IPS; i++) { + rateBuckets.set(`192.168.${Math.floor(i / 256)}.${i % 256}`, [now]); + } + const firstKey = rateBuckets.keys().next().value as string; + expect(rateBuckets.size).toBe(RATE_BUCKETS_MAX_IPS); + + // Inserting a new IP should evict the oldest one + const newIp = "172.16.0.1"; + evictRateBuckets(newIp, now, WINDOW_MS); + + expect(rateBuckets.size).toBe(RATE_BUCKETS_MAX_IPS - 1); + expect(rateBuckets.has(firstKey)).toBe(false); + }); + + it("does not evict when the map is below the ceiling", () => { + rateBuckets.set("10.1.0.1", [now]); + rateBuckets.set("10.1.0.2", [now]); + expect(rateBuckets.size).toBe(2); + + evictRateBuckets("10.1.0.3", now, WINDOW_MS); + + // The two existing keys must still be present + expect(rateBuckets.has("10.1.0.1")).toBe(true); + expect(rateBuckets.has("10.1.0.2")).toBe(true); + }); + + it("high-cardinality flood cannot grow the map beyond RATE_BUCKETS_MAX_IPS", () => { + // Simulate a spray of unique IPs well beyond the ceiling + const flood = RATE_BUCKETS_MAX_IPS + 1000; + for (let i = 0; i < flood; i++) { + const ip = `1.${Math.floor(i / 65536)}.${Math.floor((i / 256) % 256)}.${i % 256}`; + evictRateBuckets(ip, now, WINDOW_MS); + rateBuckets.set(ip, [now]); + } + expect(rateBuckets.size).toBeLessThanOrEqual(RATE_BUCKETS_MAX_IPS); }); }); -describe("evictRateBuckets — idle eviction", () => { - const WINDOW_MS = 60_000; - - beforeEach(() => { - resetStores(); - }); - - afterEach(() => { - resetStores(); - }); - - it("removes the key when all timestamps have aged out of the window", () => { - const ip = "10.0.0.1"; - const oldTime = 1_000_000; - rateBuckets.set(ip, [oldTime]); - - // now is far past oldTime + window - const now = oldTime + WINDOW_MS + 1; - const result = evictRateBuckets(ip, now, WINDOW_MS); - - expect(result).toHaveLength(0); - expect(rateBuckets.has(ip)).toBe(false); - }); - - it("keeps the key when at least one timestamp is still in-window", () => { - const ip = "10.0.0.2"; - const now = 2_000_000; - rateBuckets.set(ip, [now - 1000, now - WINDOW_MS - 1]); - - const result = evictRateBuckets(ip, now, WINDOW_MS); - - expect(result).toHaveLength(1); - expect(rateBuckets.has(ip)).toBe(true); +describe("rate limiter proxy trust configuration", () => { + it("does not trust forwarded headers by default", () => { + expect(parseTrustProxy(undefined)).toBe(false); + expect(parseTrustProxy("")).toBe(false); + expect(parseTrustProxy("false")).toBe(false); }); - it("does not insert a key for a brand-new IP with no timestamps", () => { - const ip = "10.0.0.3"; - const now = 3_000_000; - // IP never seen before — evictRateBuckets returns empty array but does - // NOT write the key (the middleware writes it after the call) - const result = evictRateBuckets(ip, now, WINDOW_MS); - - expect(result).toHaveLength(0); - expect(rateBuckets.has(ip)).toBe(false); - }); - - it("a returning IP after its bucket was evicted starts fresh", () => { - const ip = "10.0.0.4"; - const oldTime = 5_000_000; - rateBuckets.set(ip, [oldTime]); - - // First call ages out the bucket and deletes the key - const now1 = oldTime + WINDOW_MS + 1; - evictRateBuckets(ip, now1, WINDOW_MS); - expect(rateBuckets.has(ip)).toBe(false); - - // Second call — IP is unknown again, returns empty array - const now2 = now1 + 1000; - const result = evictRateBuckets(ip, now2, WINDOW_MS); - expect(result).toHaveLength(0); - expect(rateBuckets.has(ip)).toBe(false); + it("parses explicit trust proxy settings", () => { + expect(parseTrustProxy("true")).toBe(true); + expect(parseTrustProxy("1")).toBe(1); + expect(parseTrustProxy("loopback")).toBe("loopback"); + expect(parseTrustProxy("loopback, linklocal, uniquelocal")).toEqual([ + "loopback", + "linklocal", + "uniquelocal", + ]); }); }); -describe("evictRateBuckets — ceiling eviction", () => { - const WINDOW_MS = 60_000; - const now = 10_000_000; - +describe("rate limiter lazy GC", () => { beforeEach(() => { resetStores(); }); @@ -179,42 +247,27 @@ describe("evictRateBuckets — ceiling eviction", () => { resetStores(); }); - it("sheds the oldest entry when the IP ceiling is exceeded", () => { - // Fill the map to exactly the ceiling - for (let i = 0; i < RATE_BUCKETS_MAX_IPS; i++) { - rateBuckets.set(`192.168.${Math.floor(i / 256)}.${i % 256}`, [now]); - } - const firstKey = rateBuckets.keys().next().value as string; - expect(rateBuckets.size).toBe(RATE_BUCKETS_MAX_IPS); + it("removes expired buckets for clients that never return", () => { + const now = 12_000_000; + rateBuckets.set("10.0.1.1", [now - WINDOW_MS - 1]); + rateBuckets.set("10.0.1.2", [now - 1000]); + rateBuckets.set("10.0.1.3", [now - WINDOW_MS - 5, now - 500]); - // Inserting a new IP should evict the oldest one - const newIp = "172.16.0.1"; - evictRateBuckets(newIp, now, WINDOW_MS); + const removed = pruneExpiredRateBuckets(now, WINDOW_MS); - expect(rateBuckets.size).toBe(RATE_BUCKETS_MAX_IPS - 1); - expect(rateBuckets.has(firstKey)).toBe(false); + expect(removed).toBe(1); + expect(rateBuckets.has("10.0.1.1")).toBe(false); + expect(rateBuckets.get("10.0.1.2")).toEqual([now - 1000]); + expect(rateBuckets.get("10.0.1.3")).toEqual([now - 500]); }); - it("does not evict when the map is below the ceiling", () => { - rateBuckets.set("10.1.0.1", [now]); - rateBuckets.set("10.1.0.2", [now]); - expect(rateBuckets.size).toBe(2); + it("is rate-limited to avoid sweeping on every request", () => { + const now = 14_000_000; + rateBuckets.set("10.0.2.1", [now - WINDOW_MS - 1]); - evictRateBuckets("10.1.0.3", now, WINDOW_MS); - - // The two existing keys must still be present - expect(rateBuckets.has("10.1.0.1")).toBe(true); - expect(rateBuckets.has("10.1.0.2")).toBe(true); - }); - - it("high-cardinality flood cannot grow the map beyond RATE_BUCKETS_MAX_IPS", () => { - // Simulate a spray of unique IPs well beyond the ceiling - const flood = RATE_BUCKETS_MAX_IPS + 1000; - for (let i = 0; i < flood; i++) { - const ip = `1.${Math.floor(i / 65536)}.${Math.floor((i / 256) % 256)}.${i % 256}`; - evictRateBuckets(ip, now, WINDOW_MS); - rateBuckets.set(ip, [now]); - } - expect(rateBuckets.size).toBeLessThanOrEqual(RATE_BUCKETS_MAX_IPS); + expect(pruneExpiredRateBuckets(now, WINDOW_MS)).toBe(1); + rateBuckets.set("10.0.2.2", [now - WINDOW_MS - 1]); + expect(pruneExpiredRateBuckets(now + 1000, WINDOW_MS)).toBe(0); + expect(rateBuckets.has("10.0.2.2")).toBe(true); }); }); diff --git a/src/index.ts b/src/index.ts index 97d3e92..baf3a6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2096 +1,2154 @@ -import { createHash, randomUUID } from "node:crypto"; -import { createRequire } from "node:module"; -import express, { type NextFunction, type Request, type Response } from "express"; -import cors from "cors"; -import { openApiSpec } from "./openapi"; -import { - paused, - pairRegistry, - pairMeta, - apiKeyStore, - webhookStore, - eventLog, - rateBuckets, - config, - readOnly, - setPaused, - setReadOnly, - pairKey, - defaultMeta, - recordEvent, - trimEventLog, - EVENT_LOG_CAP, - EVENT_LOG_CAP_MAX, - RATE_BUCKETS_MAX_IPS, - HEALTH_PROBE_KEY, - KNOWN_EVENT_TYPES, - HEALTH_PROBE_KEY, - RATE_BUCKETS_MAX_IPS, - type PairMeta, - type AppEvent, - type ApiKeyRecord, - type WebhookRecord, - type EventType, -} from "./stores"; - -/** Sentinel key used by the deep-health probe's scratch storage check. */ -const HEALTH_PROBE_KEY = "\x00health_probe"; - -/** Maximum number of event-type entries per webhook subscription. */ -const WEBHOOK_MAX_EVENTS = 20; - -/** Maximum length of a single event-type name string. */ -const WEBHOOK_MAX_EVENT_LENGTH = 128; - -/** Event name prefixes reserved for internal use. */ -const WEBHOOK_RESERVED_PREFIXES: string[] = []; - -/** - * Evict rate-bucket entries older than the current window, then return the - * (now-pruned) bucket for `ip`. - */ -const evictRateBuckets = (ip: string, now: number, windowMs: number): number[] => { - const existing = rateBuckets.get(ip) ?? []; - const bucket = existing.filter((t) => now - t < windowMs); - rateBuckets.set(ip, bucket); - return bucket; -}; - -/** - * Trim the event log to the given cap, removing the oldest entries first. - */ -const trimEventLog = (cap: number): void => { - while (eventLog.length > cap) eventLog.shift(); -}; - +import { createHash, randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import express, { type NextFunction, type Request, type Response } from "express"; +import cors from "cors"; +import { openApiSpec } from "./openapi"; +import { + paused, + pairRegistry, + pairMeta, + apiKeyStore, + webhookStore, + eventLog, + rateBuckets, + config, + readOnly, + setPaused, + setReadOnly, + pairKey, + defaultMeta, + recordEvent, + trimEventLog, + EVENT_LOG_CAP, + EVENT_LOG_CAP_MAX, + RATE_BUCKETS_MAX_IPS, + HEALTH_PROBE_KEY, + KNOWN_EVENT_TYPES, + HEALTH_PROBE_KEY, + RATE_BUCKETS_MAX_IPS, + type PairMeta, + type AppEvent, + type ApiKeyRecord, + type WebhookRecord, + type EventType, +} from "./stores"; + +/** Sentinel key used by the deep-health probe's scratch storage check. */ +const HEALTH_PROBE_KEY = "\x00health_probe"; + +/** Maximum number of event-type entries per webhook subscription. */ +const WEBHOOK_MAX_EVENTS = 20; + +/** Maximum length of a single event-type name string. */ +const WEBHOOK_MAX_EVENT_LENGTH = 128; + +/** Event name prefixes reserved for internal use. */ +const WEBHOOK_RESERVED_PREFIXES: string[] = []; + +/** + * Evict rate-bucket entries older than the current window, then return the + * (now-pruned) bucket for `ip`. + */ +const evictRateBuckets = (ip: string, now: number, windowMs: number): number[] => { + const existing = rateBuckets.get(ip) ?? []; + const bucket = existing.filter((t) => now - t < windowMs); + rateBuckets.set(ip, bucket); + return bucket; +}; + +/** + * Trim the event log to the given cap, removing the oldest entries first. + */ +const trimEventLog = (cap: number): void => { + while (eventLog.length > cap) eventLog.shift(); +}; + const app = express(); const PORT = process.env.PORT ?? 3001; -app.use(cors()); - -type RequestWithId = Request & { id?: string }; -type ErrorResponseExtra = Record; - -/** Union of all error codes used in API responses. */ -export type ApiErrorCode = - | "not_found" - | "invalid_request" - | "invalid_json" - | "unauthorized" - | "forbidden" - | "rate_limited" - | "service_paused" - | "internal_error" - | "not_acceptable" - | "payload_too_large" - | "conflict" - | "method_not_allowed" - | "read_only_mode" - | "pair_not_registered"; - -/** - * Validates an inbound X-Request-Id value. - * - * Accepted format: 1–200 characters drawn exclusively from the conservative - * token charset `[A-Za-z0-9._-]`. This deliberately excludes control - * characters, CR, LF, and other non-token bytes that could be used for - * header-injection or log-injection attacks. - * - * @param value - The raw header value to validate. - * @returns `true` when the value is safe to echo; `false` otherwise. - */ -export const isValidRequestId = (value: string): boolean => - value.length > 0 && value.length <= 200 && /^[A-Za-z0-9._-]+$/.test(value); - -/** - * Read the request id attached by the correlation middleware. - */ -const getRequestId = (req: Request): string | undefined => (req as RequestWithId).id; +type TrustProxySetting = boolean | number | string | string[]; /** - * Send the canonical API error body used by explicit handlers and middleware. + * Parse the TRUST_PROXY environment variable into an Express trust-proxy + * setting. The default is deliberately false so forged X-Forwarded-For values + * are ignored unless the deployment explicitly declares trusted hops. */ -const sendError = ( - res: Response, - req: Request, - status: number, - error: ApiErrorCode, - message: string, - extra: ErrorResponseExtra = {} -) => res.status(status).json({ error, message, ...extra, requestId: getRequestId(req) }); - -/** TTL for idempotency cache entries (default 24 hours). */ -const IDEMPOTENCY_TTL_MS = Number(process.env.IDEMPOTENCY_TTL_MS ?? 24 * 60 * 60 * 1000); - -/** Maximum number of entries kept in the idempotency cache. */ -const IDEMPOTENCY_CACHE_MAX = 10_000; - -interface IdempotencyCacheEntry { - status: number; - body: unknown; - bodyHash: string; - expiresAt: number; -} - -/** - * In-memory idempotency cache keyed by `"METHOD:path:idempotency-key"`. - * Entries are TTL-expiring and the map is bounded to IDEMPOTENCY_CACHE_MAX - * entries (oldest-first eviction). - */ -const idempotencyCache = new Map(); - -/** - * Evict all expired entries. When the cache is still at capacity after - * expiry-based eviction, drop the oldest insertion-order entry. - */ -const pruneIdempotencyCache = (): void => { - const now = Date.now(); - for (const [k, entry] of idempotencyCache) { - if (entry.expiresAt <= now) idempotencyCache.delete(k); - } - if (idempotencyCache.size >= IDEMPOTENCY_CACHE_MAX) { - const oldest = idempotencyCache.keys().next().value; - if (oldest !== undefined) idempotencyCache.delete(oldest); - } +export const parseTrustProxy = (value: string | undefined): TrustProxySetting => { + if (value === undefined || value.trim() === "") return false; + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + const numeric = Number(normalized); + if (Number.isInteger(numeric) && numeric >= 0) return numeric; + if (value.includes(",")) { + return value + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + } + return value.trim(); }; -/** - * Express middleware that implements Idempotency-Key semantics for create - * (POST) endpoints. - * - * Behaviour: - * - No Idempotency-Key header: passes through unchanged. - * - Key present but outside 1-200 chars: passes through unchanged. - * - First request with a key: executes the handler, captures the response, - * and stores { status, body, bodyHash, expiresAt } in the cache. - * - Repeat request with matching key + matching body hash: replays cached - * response verbatim (no handler invocation). - * - Repeat request with matching key but different body: 409 idempotency_conflict. - * - * Cache entries expire after IDEMPOTENCY_TTL_MS (default 24 h). - */ -const idempotencyGuard = (req: Request, res: Response, next: NextFunction): void => { - const idempotencyKey = req.header("idempotency-key"); - if (!idempotencyKey || idempotencyKey.length < 1 || idempotencyKey.length > 200) { - return next(); - } - - const cacheKey = `${req.method}:${req.path}:${idempotencyKey}`; - const bodyHash = createHash("sha256").update(JSON.stringify(req.body ?? null)).digest("hex"); - - const existing = idempotencyCache.get(cacheKey); - if (existing) { - if (existing.expiresAt > Date.now()) { - if (existing.bodyHash !== bodyHash) { - sendError(res, req, 409, "idempotency_conflict", "Idempotency-Key reused with a different request body"); - return; - } - // Replay cached response verbatim. - res.status(existing.status).json(existing.body); - return; - } - // Expired entry - remove it and fall through to execute the handler. - idempotencyCache.delete(cacheKey); - } +app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY)); - // Wrap res.json to capture the response before it is sent. - const originalJson = res.json.bind(res); - res.json = (body: unknown): Response => { - pruneIdempotencyCache(); - idempotencyCache.set(cacheKey, { - status: res.statusCode, - body, - bodyHash, - expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, - }); - return originalJson(body); - }; - - next(); +app.use(cors()); + +type RequestWithId = Request & { id?: string }; +type ErrorResponseExtra = Record; + +/** Union of all error codes used in API responses. */ +export type ApiErrorCode = + | "not_found" + | "invalid_request" + | "invalid_json" + | "unauthorized" + | "forbidden" + | "rate_limited" + | "service_paused" + | "internal_error" + | "not_acceptable" + | "payload_too_large" + | "conflict" + | "method_not_allowed" + | "read_only_mode" + | "pair_not_registered"; + +/** + * Validates an inbound X-Request-Id value. + * + * Accepted format: 1–200 characters drawn exclusively from the conservative + * token charset `[A-Za-z0-9._-]`. This deliberately excludes control + * characters, CR, LF, and other non-token bytes that could be used for + * header-injection or log-injection attacks. + * + * @param value - The raw header value to validate. + * @returns `true` when the value is safe to echo; `false` otherwise. + */ +export const isValidRequestId = (value: string): boolean => + value.length > 0 && value.length <= 200 && /^[A-Za-z0-9._-]+$/.test(value); + +/** + * Read the request id attached by the correlation middleware. + */ +const getRequestId = (req: Request): string | undefined => (req as RequestWithId).id; + +/** + * Send the canonical API error body used by explicit handlers and middleware. + */ +const sendError = ( + res: Response, + req: Request, + status: number, + error: ApiErrorCode, + message: string, + extra: ErrorResponseExtra = {} +) => res.status(status).json({ error, message, ...extra, requestId: getRequestId(req) }); + +/** TTL for idempotency cache entries (default 24 hours). */ +const IDEMPOTENCY_TTL_MS = Number(process.env.IDEMPOTENCY_TTL_MS ?? 24 * 60 * 60 * 1000); + +/** Maximum number of entries kept in the idempotency cache. */ +const IDEMPOTENCY_CACHE_MAX = 10_000; + +interface IdempotencyCacheEntry { + status: number; + body: unknown; + bodyHash: string; + expiresAt: number; +} + +/** + * In-memory idempotency cache keyed by `"METHOD:path:idempotency-key"`. + * Entries are TTL-expiring and the map is bounded to IDEMPOTENCY_CACHE_MAX + * entries (oldest-first eviction). + */ +const idempotencyCache = new Map(); + +/** + * Evict all expired entries. When the cache is still at capacity after + * expiry-based eviction, drop the oldest insertion-order entry. + */ +const pruneIdempotencyCache = (): void => { + const now = Date.now(); + for (const [k, entry] of idempotencyCache) { + if (entry.expiresAt <= now) idempotencyCache.delete(k); + } + if (idempotencyCache.size >= IDEMPOTENCY_CACHE_MAX) { + const oldest = idempotencyCache.keys().next().value; + if (oldest !== undefined) idempotencyCache.delete(oldest); + } +}; + +/** + * Express middleware that implements Idempotency-Key semantics for create + * (POST) endpoints. + * + * Behaviour: + * - No Idempotency-Key header: passes through unchanged. + * - Key present but outside 1-200 chars: passes through unchanged. + * - First request with a key: executes the handler, captures the response, + * and stores { status, body, bodyHash, expiresAt } in the cache. + * - Repeat request with matching key + matching body hash: replays cached + * response verbatim (no handler invocation). + * - Repeat request with matching key but different body: 409 idempotency_conflict. + * + * Cache entries expire after IDEMPOTENCY_TTL_MS (default 24 h). + */ +const idempotencyGuard = (req: Request, res: Response, next: NextFunction): void => { + const idempotencyKey = req.header("idempotency-key"); + if (!idempotencyKey || idempotencyKey.length < 1 || idempotencyKey.length > 200) { + return next(); + } + + const cacheKey = `${req.method}:${req.path}:${idempotencyKey}`; + const bodyHash = createHash("sha256").update(JSON.stringify(req.body ?? null)).digest("hex"); + + const existing = idempotencyCache.get(cacheKey); + if (existing) { + if (existing.expiresAt > Date.now()) { + if (existing.bodyHash !== bodyHash) { + sendError(res, req, 409, "idempotency_conflict", "Idempotency-Key reused with a different request body"); + return; + } + // Replay cached response verbatim. + res.status(existing.status).json(existing.body); + return; + } + // Expired entry - remove it and fall through to execute the handler. + idempotencyCache.delete(cacheKey); + } + + // Wrap res.json to capture the response before it is sent. + const originalJson = res.json.bind(res); + res.json = (body: unknown): Response => { + pruneIdempotencyCache(); + idempotencyCache.set(cacheKey, { + status: res.statusCode, + body, + bodyHash, + expiresAt: Date.now() + IDEMPOTENCY_TTL_MS, + }); + return originalJson(body); + }; + + next(); +}; + +/** + * Strict body-key guard. + * + * Enforces that a JSON request body contains only keys from `allowed`. When the + * body carries any extra top-level key, a `400 invalid_request` is sent listing + * the offending keys (with the canonical `requestId`) and the function returns + * `true` so the caller can `return` immediately. + * + * An absent or non-object body is treated as having no keys to reject. Own + * enumerable keys are read via `Object.keys`, so inherited / prototype-pollution + * keys like `__proto__` (which arrive as own enumerable keys when present in the + * raw JSON) are surfaced as unknown rather than silently honoured. + * + * @param req - The incoming request (used for the body and request id). + * @param res - The response used to emit the canonical error. + * @param allowed - The exhaustive set of permitted top-level body keys. + * @returns `true` when an error was sent (unknown keys present), else `false`. + */ +const rejectUnknownKeys = (req: Request, res: Response, allowed: string[]): boolean => { + const body = req.body; + if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) { + return false; + } + const allow = new Set(allowed); + const unknown = Object.keys(body).filter((k) => !allow.has(k)); + if (unknown.length > 0) { + sendError(res, req, 400, "invalid_request", `unknown field(s): ${unknown.join(", ")}`, { + unknownKeys: unknown, + }); + return true; + } + return false; +}; + +// Attach an X-Request-Id before body parsing so parser errors can still +// return the canonical error shape with a correlation id. +// Only echo the caller's id when it passes the strict charset + length check +// (isValidRequestId); anything that fails — including values with control +// characters, CR/LF, or other non-token bytes — is silently replaced with a +// freshly generated UUID v4 to prevent header-injection and log-injection. +app.use((req: Request, res: Response, next: NextFunction) => { + const incoming = req.header("x-request-id"); + const id = incoming !== undefined && isValidRequestId(incoming) ? incoming : randomUUID(); + (req as RequestWithId).id = id; + res.setHeader("X-Request-Id", id); + next(); +}); + +app.use(express.json({ limit: "100kb" })); + +// Content-Type guard: POST/PATCH/PUT requests with a body must declare +// Content-Type: application/json. Requests with no body (no Content-Length +// and no Transfer-Encoding) are passed through unchanged. +app.use((req: Request, res: Response, next: NextFunction) => { + const method = req.method.toUpperCase(); + if (method === "GET" || method === "HEAD" || method === "DELETE" || method === "OPTIONS") { + return next(); + } + const hasBody = + req.headers["content-length"] !== undefined || + req.headers["transfer-encoding"] !== undefined; + if (!hasBody) return next(); + const contentType = (req.headers["content-type"] ?? "").toLowerCase(); + if (contentType.includes("application/json")) return next(); + return sendError( + res, + req, + 415, + "unsupported_media_type", + "Content-Type must be application/json" + ); +}); + +// Pause guard: refuses non-idempotent methods with 503 except +// /admin/unpause, so an operator can always recover. +app.use((req: Request, res: Response, next: NextFunction) => { + if (!paused) return next(); + const m = req.method.toUpperCase(); + if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next(); + if (req.path === "/api/v1/admin/unpause") return next(); + sendError(res, req, 503, "service_paused", "StableRoute backend is paused"); +}); + +/** + * Read-only maintenance guard. + * + * When `readOnly` is enabled (and the service is not paused — `paused` is + * strictly stronger and its guard runs first), this middleware keeps reads and + * quotes flowing while rejecting other mutating writes with + * `503 read_only_mode`. + * + * Allowed while read-only: + * - idempotent methods `GET` / `HEAD` / `OPTIONS`; + * - the quote endpoints (`/api/v1/quote`, `/api/v1/quote/reverse`, + * `/api/v1/quote/bulk`), including the POST bulk-quote; + * - `POST /api/v1/admin/read-write`, so an operator can always recover + * (mirroring the unpause carve-out). + * + * All other mutating requests receive the canonical `503 read_only_mode` body. + */ +const QUOTE_PATHS = new Set([ + "/api/v1/quote", + "/api/v1/quote/reverse", + "/api/v1/quote/bulk", +]); +app.use((req: Request, res: Response, next: NextFunction) => { + if (!readOnly) return next(); + const m = req.method.toUpperCase(); + if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next(); + if (QUOTE_PATHS.has(req.path)) return next(); + // Recovery path must always be reachable, like /admin/unpause. + if (req.path === "/api/v1/admin/read-write") return next(); + sendError(res, req, 503, "read_only_mode", "StableRoute backend is in read-only mode"); +}); + +// Per-IP sliding-window rate limiter. +// Reads config.rateLimitPerWindow and config.rateLimitWindowMs at request time +// so PATCH /api/v1/config changes take effect immediately. +// Disabled in test mode so the test suite can make many requests without hitting the limit. +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_BUCKET_GC_INTERVAL_MS = 60_000; +let lastRateBucketGcAt = 0; + +/** Maximum number of event type subscriptions per webhook. */ +const WEBHOOK_MAX_EVENTS = 20; + +/** Maximum character length of a single event type name. */ +const WEBHOOK_MAX_EVENT_LENGTH = 128; + +/** Event name prefixes reserved for internal/system use. */ +const WEBHOOK_RESERVED_PREFIXES = ["internal.", "system.", "admin."]; + +/** + * Evict stale timestamps from the rate-bucket for `ip` and return the live + * (within-window) entries. + * + * Side effects: + * - Removes the map entry for `ip` when all its timestamps are stale. + * - Enforces the IP-count ceiling: when the map holds + * {@link RATE_BUCKETS_MAX_IPS} entries and a new IP is admitted, the + * oldest entry is deleted first so cardinality never exceeds the cap. + */ +export const evictRateBuckets = (ip: string, now: number, windowMs: number): number[] => { + // Enforce IP-count ceiling for new IPs. + if (!rateBuckets.has(ip) && rateBuckets.size >= RATE_BUCKETS_MAX_IPS) { + const oldestKey = rateBuckets.keys().next().value as string; + rateBuckets.delete(oldestKey); + } + const existing = rateBuckets.get(ip) ?? []; + const live = existing.filter((t) => now - t < windowMs); + // Delete empty buckets to keep memory bounded and avoid stale map entries. + if (existing.length > 0 && live.length === 0) { + rateBuckets.delete(ip); + } + return live; }; /** - * Strict body-key guard. - * - * Enforces that a JSON request body contains only keys from `allowed`. When the - * body carries any extra top-level key, a `400 invalid_request` is sent listing - * the offending keys (with the canonical `requestId`) and the function returns - * `true` so the caller can `return` immediately. - * - * An absent or non-object body is treated as having no keys to reject. Own - * enumerable keys are read via `Object.keys`, so inherited / prototype-pollution - * keys like `__proto__` (which arrive as own enumerable keys when present in the - * raw JSON) are surfaced as unknown rather than silently honoured. - * - * @param req - The incoming request (used for the body and request id). - * @param res - The response used to emit the canonical error. - * @param allowed - The exhaustive set of permitted top-level body keys. - * @returns `true` when an error was sent (unknown keys present), else `false`. + * Sweep all expired rate-limit buckets at most once per GC interval. This lazy + * pass bounds memory even when old clients never return after their windows + * expire. */ -const rejectUnknownKeys = (req: Request, res: Response, allowed: string[]): boolean => { - const body = req.body; - if (body === undefined || body === null || typeof body !== "object" || Array.isArray(body)) { - return false; - } - const allow = new Set(allowed); - const unknown = Object.keys(body).filter((k) => !allow.has(k)); - if (unknown.length > 0) { - sendError(res, req, 400, "invalid_request", `unknown field(s): ${unknown.join(", ")}`, { - unknownKeys: unknown, - }); - return true; +export const pruneExpiredRateBuckets = (now: number, windowMs: number): number => { + if (now - lastRateBucketGcAt < RATE_BUCKET_GC_INTERVAL_MS) return 0; + lastRateBucketGcAt = now; + let removed = 0; + for (const [ip, bucket] of rateBuckets) { + const live = bucket.filter((t) => now - t < windowMs); + if (live.length === 0) { + rateBuckets.delete(ip); + removed++; + } else if (live.length !== bucket.length) { + rateBuckets.set(ip, live); + } } - return false; + return removed; }; -// Attach an X-Request-Id before body parsing so parser errors can still -// return the canonical error shape with a correlation id. -// Only echo the caller's id when it passes the strict charset + length check -// (isValidRequestId); anything that fails — including values with control -// characters, CR/LF, or other non-token bytes — is silently replaced with a -// freshly generated UUID v4 to prevent header-injection and log-injection. -app.use((req: Request, res: Response, next: NextFunction) => { - const incoming = req.header("x-request-id"); - const id = incoming !== undefined && isValidRequestId(incoming) ? incoming : randomUUID(); - (req as RequestWithId).id = id; - res.setHeader("X-Request-Id", id); - next(); -}); - -app.use(express.json({ limit: "100kb" })); - -// Content-Type guard: POST/PATCH/PUT requests with a body must declare -// Content-Type: application/json. Requests with no body (no Content-Length -// and no Transfer-Encoding) are passed through unchanged. -app.use((req: Request, res: Response, next: NextFunction) => { - const method = req.method.toUpperCase(); - if (method === "GET" || method === "HEAD" || method === "DELETE" || method === "OPTIONS") { - return next(); - } - const hasBody = - req.headers["content-length"] !== undefined || - req.headers["transfer-encoding"] !== undefined; - if (!hasBody) return next(); - const contentType = (req.headers["content-type"] ?? "").toLowerCase(); - if (contentType.includes("application/json")) return next(); - return sendError( - res, - req, - 415, - "unsupported_media_type", - "Content-Type must be application/json" - ); -}); - -// Pause guard: refuses non-idempotent methods with 503 except -// /admin/unpause, so an operator can always recover. -app.use((req: Request, res: Response, next: NextFunction) => { - if (!paused) return next(); - const m = req.method.toUpperCase(); - if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next(); - if (req.path === "/api/v1/admin/unpause") return next(); - sendError(res, req, 503, "service_paused", "StableRoute backend is paused"); -}); - /** - * Read-only maintenance guard. - * - * When `readOnly` is enabled (and the service is not paused — `paused` is - * strictly stronger and its guard runs first), this middleware keeps reads and - * quotes flowing while rejecting other mutating writes with - * `503 read_only_mode`. - * - * Allowed while read-only: - * - idempotent methods `GET` / `HEAD` / `OPTIONS`; - * - the quote endpoints (`/api/v1/quote`, `/api/v1/quote/reverse`, - * `/api/v1/quote/bulk`), including the POST bulk-quote; - * - `POST /api/v1/admin/read-write`, so an operator can always recover - * (mirroring the unpause carve-out). + * Per-IP sliding-window rate limiter. * - * All other mutating requests receive the canonical `503 read_only_mode` body. + * The bucket key comes from Express' `req.ip`, which only honors + * X-Forwarded-For when `TRUST_PROXY` has enabled trusted proxy hops. This keeps + * spoofed forwarding headers from bypassing the limiter in direct deployments. + * The limiter also lazily prunes expired buckets and enforces the existing + * hard map-size ceiling to keep memory bounded. */ -const QUOTE_PATHS = new Set([ - "/api/v1/quote", - "/api/v1/quote/reverse", - "/api/v1/quote/bulk", -]); -app.use((req: Request, res: Response, next: NextFunction) => { - if (!readOnly) return next(); - const m = req.method.toUpperCase(); - if (m === "GET" || m === "HEAD" || m === "OPTIONS") return next(); - if (QUOTE_PATHS.has(req.path)) return next(); - // Recovery path must always be reachable, like /admin/unpause. - if (req.path === "/api/v1/admin/read-write") return next(); - sendError(res, req, 503, "read_only_mode", "StableRoute backend is in read-only mode"); -}); - -// Per-IP sliding-window rate limiter. -// Reads config.rateLimitPerWindow and config.rateLimitWindowMs at request time -// so PATCH /api/v1/config changes take effect immediately. -// Disabled in test mode so the test suite can make many requests without hitting the limit. -const RATE_LIMIT_WINDOW_MS = 60_000; - -/** Maximum number of event type subscriptions per webhook. */ -const WEBHOOK_MAX_EVENTS = 20; - -/** Maximum character length of a single event type name. */ -const WEBHOOK_MAX_EVENT_LENGTH = 128; - -/** Event name prefixes reserved for internal/system use. */ -const WEBHOOK_RESERVED_PREFIXES = ["internal.", "system.", "admin."]; - -/** - * Evict stale timestamps from the rate-bucket for `ip` and return the live - * (within-window) entries. - * - * Side effects: - * - Removes the map entry for `ip` when all its timestamps are stale. - * - Enforces the IP-count ceiling: when the map holds - * {@link RATE_BUCKETS_MAX_IPS} entries and a new IP is admitted, the - * oldest entry is deleted first so cardinality never exceeds the cap. - */ -export const evictRateBuckets = (ip: string, now: number, windowMs: number): number[] => { - // Enforce IP-count ceiling for new IPs. - if (!rateBuckets.has(ip) && rateBuckets.size >= RATE_BUCKETS_MAX_IPS) { - const oldestKey = rateBuckets.keys().next().value as string; - rateBuckets.delete(oldestKey); - } - const existing = rateBuckets.get(ip) ?? []; - const live = existing.filter((t) => now - t < windowMs); - // Delete empty buckets to keep memory bounded and avoid stale map entries. - if (existing.length > 0 && live.length === 0) { - rateBuckets.delete(ip); - } - return live; -}; - app.use((req: Request, res: Response, next: NextFunction) => { if (process.env.NODE_ENV === "test") return next(); const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; const now = Date.now(); const windowMs = config.rateLimitWindowMs ?? RATE_LIMIT_WINDOW_MS; const limitPerWindow = config.rateLimitPerWindow ?? 60; + pruneExpiredRateBuckets(now, windowMs); const bucket = evictRateBuckets(ip, now, windowMs); - if (bucket.length >= limitPerWindow) { - res.setHeader("Retry-After", String(Math.ceil(windowMs / 1000))); - sendError( - res, - req, - 429, - "rate_limited", - `more than ${limitPerWindow} requests per ${windowMs / 1000}s` - ); - return; - } - bucket.push(now); - rateBuckets.set(ip, bucket); - next(); -}); - -// Request timing — emits a single structured log per finished request -// and sets Server-Timing. -app.use((req: Request, res: Response, next: NextFunction) => { - const startNs = process.hrtime.bigint(); - res.on("finish", () => { - const ms = Number(process.hrtime.bigint() - startNs) / 1_000_000; - if (process.env.NODE_ENV !== "test") { - console.log( - JSON.stringify({ - requestId: getRequestId(req), - method: req.method, - path: req.path, - status: res.statusCode, - durationMs: Math.round(ms * 10) / 10, - }) - ); - } - }); - next(); -}); - -app.use((_req: Request, res: Response, next: NextFunction) => { - res.setHeader("X-Content-Type-Options", "nosniff"); - res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("Referrer-Policy", "no-referrer"); - res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); - next(); -}); - -/** - * Paths that are exempt from the JSON content-negotiation guard. - * - * - `GET /health` — shallow liveness probe (must never require Accept headers). - * - `GET /api/v1/metrics` — Prometheus scrape endpoint that serves `text/plain`. - * - * Both must remain reachable by monitoring systems that send no Accept header - * or that explicitly request `text/plain`. - */ -const ACCEPT_NEGOTIATION_EXEMPT = new Set(["/health", "/api/v1/metrics"]); - -/** - * JSON content-negotiation guard. - * - * Rejects requests whose `Accept` header is present and explicitly excludes - * `application/json` (and wildcards `*\/\*` / `application/*`) with a - * `406 Not Acceptable` response using the canonical `sendError` envelope. - * - * Rules: - * - A missing `Accept` header is treated as acceptable (defaults to JSON). - * - `*\/*` and `application/*` wildcards are accepted. - * - Routes in `ACCEPT_NEGOTIATION_EXEMPT` are always passed through. - * - * Security note: only the Accept header value is examined; the guard does not - * re-evaluate pause or rate-limit state, so those middleware layers remain - * authoritative for their own concerns. - */ -app.use((req: Request, res: Response, next: NextFunction) => { - if (ACCEPT_NEGOTIATION_EXEMPT.has(req.path)) return next(); - - const accept = req.header("accept"); - if (!accept) return next(); - - // Split on comma to get individual media-range tokens; strip quality params. - const types = accept.split(",").map((t) => t.split(";")[0].trim().toLowerCase()); - - const acceptable = types.some( - (t) => t === "*/*" || t === "application/json" || t === "application/*" - ); - - if (!acceptable) { - sendError(res, req, 406, "not_acceptable", "This endpoint only produces application/json"); - return; - } - - next(); -}); - -app.get("/health", (_req: Request, res: Response) => { - res.json({ status: "ok", service: "stableroute-backend" }); -}); - -app.get("/api/v1/openapi.json", (_req: Request, res: Response) => { - res.json(openApiSpec); -}); - -/** - * Reserved key used by the deep health probe's storage check. - * Prefixed with a NUL control character so it can never collide with a real pair key. - */ -const HEALTH_PROBE_KEY = "\x00__health_probe__"; - -/** - * Run all health checks for the deep readiness probe. - * Each check measures its own duration (in milliseconds) and returns - * `{ name, status, durationMs }`. Checks are synchronous and fast so - * the probe never hangs. External dependencies (e.g. storage, clock) - * are tested with a lightweight read/write cycle respectively. - * - * Results are returned as an array of { name, status, durationMs } objects. - */ -const runHealthChecks = (): Array<{ name: string; status: "ok" | "fail"; durationMs: number }> => { - const checks: Array<{ name: string; status: "ok" | "fail"; durationMs: number }> = []; - - // Storage check — verifies that the in-memory store can write and read back. - // Uses the reserved HEALTH_PROBE_KEY sentinel (prefixed with a NUL control - // character) so the scratch entry can never collide with a real pair key. - const storageStart = Date.now(); - try { - const testKey = HEALTH_PROBE_KEY; - pairMeta.set(testKey, defaultMeta()); - const readback = pairMeta.get(testKey); - pairMeta.delete(testKey); - checks.push({ - name: "storage", - status: readback !== undefined ? "ok" : "fail", - durationMs: Date.now() - storageStart, - }); - } catch { - checks.push({ - name: "storage", - status: "fail", - durationMs: Date.now() - storageStart, - }); - } - - // Clock check — verifies the system clock is producing post-epoch timestamps. - const clockStart = Date.now(); - try { - const now = Date.now(); - // A timestamp earlier than 2020-01-01 indicates a broken system clock. - checks.push({ - name: "clock", - status: now > 1577836800000 ? "ok" : "fail", - durationMs: Date.now() - clockStart, - }); - } catch { - checks.push({ - name: "clock", - status: "fail", - durationMs: Date.now() - clockStart, - }); - } - - return checks; -}; - -app.get("/api/v1/health/deep", (req: Request, res: Response) => { - const m = process.memoryUsage(); - - // Checks are synchronous and fast so the probe never hangs. - // When async downstream checks are added, wrap runHealthChecks() in a - // Promise.race with a timeout or pass an AbortSignal. - const checks = runHealthChecks(); - const degraded = checks.some((c) => c.status === "fail"); - const status = paused ? "paused" : degraded ? "degraded" : "ok"; - - const body = { - status, - uptimeSeconds: Math.round(process.uptime()), - memory: { - rssMb: Math.round(m.rss / 1024 / 1024), - heapUsedMb: Math.round(m.heapUsed / 1024 / 1024), - }, - pid: process.pid, - node: process.version, - checks, - }; - - if (degraded) { - res.status(503).json(body); - } else { - res.json(body); - } -}); -/** - * Build/identity metadata read once at module load. - * - * `name` and `version` come from `package.json` (resolved at runtime so the - * value is never hard-coded), while `commit` and `buildTime` are injected by - * the deploy pipeline through the `GIT_COMMIT` / `BUILD_TIME` env vars. Any - * missing env var degrades gracefully to `"unknown"` rather than throwing. - */ -const pkg = createRequire(__filename)("../package.json") as { - name?: string; - version?: string; -}; - -/** - * GET /api/v1/version — lightweight build/version metadata. - * - * Unauthenticated and cheap: runs no health checks and exposes only build - * identity (`name`, `version`, `commit`, `buildTime`, `node`) — never secrets, - * paths, or internal config. Missing `GIT_COMMIT` / `BUILD_TIME` env vars fall - * back to `"unknown"`. - */ -app.get("/api/v1/version", (_req: Request, res: Response) => { - res.json({ - name: pkg.name ?? "unknown", - version: pkg.version ?? "unknown", - commit: process.env.GIT_COMMIT ?? "unknown", - buildTime: process.env.BUILD_TIME ?? "unknown", - node: process.version, - }); -}); - -app.post("/api/v1/admin/pause", (_req: Request, res: Response) => { - setPaused(true); - recordEvent("admin.paused", {}); - res.json({ paused }); -}); -app.post("/api/v1/admin/unpause", (_req: Request, res: Response) => { - setPaused(false); - recordEvent("admin.unpaused", {}); - res.json({ paused }); -}); -app.post("/api/v1/admin/read-only", (_req: Request, res: Response) => { - setReadOnly(true); - res.json({ readOnly }); -}); -app.post("/api/v1/admin/read-write", (_req: Request, res: Response) => { - setReadOnly(false); - res.json({ readOnly }); -}); - -/** - * Parse a single numeric query param into a finite integer. - * - * Only single string values are accepted; an absent value falls back to - * `fallback`, while array-form params (e.g. `?since=1&since=2`, which Express - * surfaces as a string array) and non-numeric strings yield `null` so the - * caller can reject them explicitly instead of silently producing `NaN`. - * - * @param value - The raw `req.query[...]` value (string, string[], or undefined). - * @param fallback - The value to use when the param is absent. - * @returns A finite integer, or `null` when the input is array-form or non-numeric. - */ -const parseIntegerQueryParam = (value: unknown, fallback: number): number | null => { - if (value === undefined) return fallback; - if (typeof value !== "string" || value.trim() === "") return null; - const n = Number(value); - return Number.isFinite(n) && Number.isInteger(n) ? n : null; -}; -/** - * Decode a base64-encoded cursor string into an integer offset. - * - * Returns the decoded offset on success, or `"bad"` when the cursor is - * present but malformed (non-base64, non-integer, or negative). Returns - * `undefined` when no cursor was supplied so the caller can distinguish - * "first page" from an explicit bad value. - * - * @param raw - The raw `req.query.cursor` value (string, string[], or undefined). - * @returns The decoded integer offset, `"bad"` for a malformed cursor, or - * `undefined` when the param is absent. - */ -const parseCursor = (raw: unknown): number | "bad" | undefined => { - if (raw === undefined) return undefined; - if (typeof raw !== "string" || raw.trim() === "") return "bad"; - try { - const decoded = Buffer.from(raw, "base64").toString(); - const n = Number(decoded); - if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return "bad"; - return n; - } catch { - return "bad"; - } -}; - -/** - * Apply limit+cursor pagination to an array of items. - * - * @param items - The full (pre-filtered) array. - * @param limit - Number of items per page (already clamped by caller). - * @param offset - Starting index decoded from the cursor (0 when absent). - * @returns The page slice and a `nextCursor` (base64-encoded next offset, - * or `null` when the collection is exhausted). - */ -const paginate = ( - items: T[], - limit: number, - offset: number -): { page: T[]; nextCursor: string | null } => { - const page = items.slice(offset, offset + limit); - const nextOffset = offset + limit; - const nextCursor = nextOffset < items.length - ? Buffer.from(String(nextOffset)).toString("base64") - : null; - return { page, nextCursor }; -}; - - -/** - * Decode a base64-encoded cursor string into an integer offset. - * - * Returns the decoded offset on success, or `"bad"` when the cursor is - * present but malformed (non-base64, non-integer, or negative). Returns - * `undefined` when no cursor was supplied so the caller can distinguish - * "first page" from an explicit bad value. - * - * @param raw - The raw `req.query.cursor` value (string, string[], or undefined). - * @returns The decoded integer offset, `"bad"` for a malformed cursor, or - * `undefined` when the param is absent. - */ -const parseCursor = (raw: unknown): number | "bad" | undefined => { - if (raw === undefined) return undefined; - if (typeof raw !== "string" || raw.trim() === "") return "bad"; - try { - const decoded = Buffer.from(raw, "base64").toString(); - const n = Number(decoded); - if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return "bad"; - return n; - } catch { - return "bad"; - } -}; - -/** - * Apply limit+cursor pagination to an array of items. - * - * @param items - The full (pre-filtered) array. - * @param limit - Number of items per page (already clamped by caller). - * @param offset - Starting index decoded from the cursor (0 when absent). - * @returns The page slice and a `nextCursor` (base64-encoded next offset, - * or `null` when the collection is exhausted). - */ -const paginate = ( - items: T[], - limit: number, - offset: number -): { page: T[]; nextCursor: string | null } => { - const page = items.slice(offset, offset + limit); - const nextOffset = offset + limit; - const nextCursor = nextOffset < items.length - ? Buffer.from(String(nextOffset)).toString("base64") - : null; - return { page, nextCursor }; -}; - -app.get("/api/v1/events", (req: Request, res: Response) => { - // `since` must be a single, non-negative integer. Array-form or non-numeric - // values are rejected rather than coerced to NaN (which would silently - // return zero events). - const since = parseIntegerQueryParam(req.query.since, 0); - if (since === null || since < 0) { - sendError(res, req, 400, "invalid_request", "since must be a non-negative integer"); - return; - } - - // `limit` must be a single numeric value; it is then clamped to [1, EVENT_LOG_CAP]. - const rawLimit = parseIntegerQueryParam(req.query.limit, 100); - if (rawLimit === null) { - sendError(res, req, 400, "invalid_request", "limit must be a single integer"); - return; - } - const limit = Math.min(EVENT_LOG_CAP, Math.max(1, rawLimit)); - - // Optional type filter: when present, must be one of the known event types. - const typeParam = req.query.type; - if (typeParam !== undefined) { - if ( - typeof typeParam !== "string" || - !(KNOWN_EVENT_TYPES as ReadonlyArray).includes(typeParam) - ) { - sendError( - res, - req, - 400, - "invalid_request", - `type must be one of: ${KNOWN_EVENT_TYPES.join(", ")}` - ); - return; - } - } - - // Parse cursor (base64-encoded offset). - const cursorResult = parseCursor(req.query.cursor); - if (cursorResult === "bad") { - sendError(res, req, 400, "invalid_request", "cursor is invalid"); - return; - } - const offset = cursorResult ?? 0; - - let items = eventLog.filter((e) => e.ts >= since); - if (typeParam !== undefined) { - items = items.filter((e) => e.type === (typeParam as EventType)); - } - const { page, nextCursor } = paginate(items, limit, offset); - res.json({ items: page, nextCursor }); -}); - -/** - * Fixed catalog of authorization scopes an API key may carry. A key's scopes - * are a subset of this set; unknown scope strings are rejected at creation. - */ -const SCOPE_CATALOG = ["pairs:write", "webhooks:write", "keys:admin"] as const; - -/** - * Least-privilege default scope set applied when a key is created without an - * explicit `scopes` array. Read-only: it grants no write scope. - */ -const DEFAULT_SCOPES: readonly string[] = []; - -/** - * Returns `true` when the API key record should be accepted for - * authentication, `false` when it should be rejected. - * - * A key is considered invalid when: - * - Its explicit `expiresAt` deadline has passed (hard expiry set at creation). - * - It is a rotated predecessor whose grace window has expired. - */ -const isKeyValid = (record: ApiKeyRecord): boolean => { - if (record.expiresAt !== undefined && Date.now() > record.expiresAt) return false; - if (record.graceExpiresAt !== undefined && record.rotatedAt !== undefined) { - // Rotated predecessor: still valid until grace window expires - return Date.now() <= record.graceExpiresAt; - } - return true; -}; - -/** - * Express middleware factory asserting that the authenticated API key carries - * the given scope. - * - * The key is resolved from the `Authorization: Bearer ` header. When - * the key is missing or unknown the guard responds `401 unauthorized`; when the - * key exists but lacks `scope` it responds `403 forbidden`, both using the - * canonical `sendError` envelope. On success it calls `next()`. - * - * @param scope - The scope string (from {@link SCOPE_CATALOG}) the route requires. - * @returns An Express request handler enforcing the scope. - */ -const isKeyValid = (record: ApiKeyRecord): boolean => { - if (record.expiresAt !== undefined && Date.now() > record.expiresAt) return false; - if (record.graceExpiresAt !== undefined && record.rotatedAt !== undefined) { - return Date.now() <= record.graceExpiresAt; - } - return true; -}; - -const requireScope = (scope: string) => - (req: Request, res: Response, next: NextFunction): void => { - const auth = req.header("authorization") ?? ""; - const match = /^Bearer\s+(\S+)$/i.exec(auth); - const rawKey = match ? match[1] : undefined; - const record = rawKey ? apiKeyStore.get(rawKey) : undefined; - if (!record) { - sendError(res, req, 401, "unauthorized", "a valid API key is required"); - return; - } - if (!(record.scopes ?? []).includes(scope)) { - sendError(res, req, 403, "forbidden", `this key is missing the required scope: ${scope}`); - return; - } - apiKeyStore.set(rawKey!, { ...record, lastUsedAt: Date.now() }); - next(); - }; - -app.delete("/api/v1/api-keys/:prefix", (req: Request, res: Response) => { - const { prefix } = req.params; - let found: string | undefined; - for (const k of apiKeyStore.keys()) if (k.slice(0, 8) === prefix) { found = k; break; } - if (!found) { - sendError(res, req, 404, "not_found", `no key with prefix ${prefix}`); - return; - } - apiKeyStore.delete(found); - recordEvent("apikey.deleted", { prefix }); - res.status(204).send(); -}); - -app.get("/api/v1/api-keys", (req: Request, res: Response) => { - const rawLimit = parseIntegerQueryParam(req.query.limit, 100); - if (rawLimit === null) { - sendError(res, req, 400, "invalid_request", "limit must be a single integer"); - return; - } - const limit = Math.min(500, Math.max(1, rawLimit)); - - const cursorResult = parseCursor(req.query.cursor); - if (cursorResult === "bad") { - sendError(res, req, 400, "invalid_request", "cursor is invalid"); - return; - } - const offset = cursorResult ?? 0; - - const allItems = Array.from(apiKeyStore.entries()).map(([k, m]) => ({ - prefix: k.slice(0, 8), - label: m.label, - createdAt: m.createdAt, - // Surface rotation metadata for predecessor records (omitted when absent). - ...(m.rotatedAt !== undefined ? { rotatedAt: m.rotatedAt } : {}), - ...(m.expiresAt !== undefined ? { expiresAt: m.expiresAt } : {}), - ...(m.lastUsedAt !== undefined ? { lastUsedAt: m.lastUsedAt } : {}), - })); - const { page, nextCursor } = paginate(allItems, limit, offset); - res.json({ items: page, nextCursor }); -}); - -app.post("/api/v1/api-keys", (req: Request, res: Response) => { - const { label, scopes, expiresInSeconds } = req.body ?? {}; - if (typeof label !== "string" || label.length === 0 || label.length > 64) { - sendError(res, req, 400, "invalid_request", "label must be 1-64 chars"); - return; - } - let grantedScopes: string[] = [...DEFAULT_SCOPES]; - if (scopes !== undefined) { - if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) { - sendError(res, req, 400, "invalid_request", "scopes must be a string array"); - return; - } - const unknown = (scopes as string[]).filter( - (s) => !(SCOPE_CATALOG as ReadonlyArray).includes(s) - ); - if (unknown.length > 0) { - sendError( - res, - req, - 400, - "invalid_request", - `unknown scope(s): ${unknown.join(", ")}. Known scopes: ${SCOPE_CATALOG.join(", ")}` - ); - return; - } - grantedScopes = [...new Set(scopes as string[])]; - } - let expiresAt: number | undefined; - if (expiresInSeconds !== undefined) { - if ( - typeof expiresInSeconds !== "number" || - !Number.isInteger(expiresInSeconds) || - expiresInSeconds <= 0 || - expiresInSeconds > 31_536_000 - ) { - sendError(res, req, 400, "invalid_request", "expiresInSeconds must be a positive integer no greater than 31536000"); - return; - } - expiresAt = Date.now() + expiresInSeconds * 1000; - } - const key = `srk_${randomUUID().replace(/-/g, "")}`; - apiKeyStore.set(key, { label, createdAt: Date.now(), scopes: grantedScopes }); - // Record only the non-sensitive prefix and label — never the raw key. - recordEvent("apikey.created", { prefix: key.slice(0, 8), label }); - res.status(201).json({ key, label, ...(expiresAt !== undefined ? { expiresAt } : {}) }); -}); - -/** - * Grace window (ms) during which a rotated predecessor key remains valid - * alongside its successor, giving in-flight callers time to cut over without - * downtime. Defaults to one hour. - */ -const ROTATION_GRACE_MS = 60 * 60 * 1000; - -/** - * Rotate an API key: mint a successor inheriting the predecessor's label and - * schedule the predecessor for grace expiry. - * - * Locates the key by its 8-char prefix, creates a new `srk_` key with the same - * `label`, and stamps the predecessor with `rotatedAt` (now) and - * `graceExpiresAt` (now + {@link ROTATION_GRACE_MS}) so both keys work during - * the overlap. The new raw key is returned exactly once with `201`; it is never - * logged. Returns `404 not_found` for an unknown prefix. - * - * @route POST /api/v1/api-keys/:prefix/rotate - */ -app.post("/api/v1/api-keys/:prefix/rotate", (req: Request, res: Response) => { - const { prefix } = req.params; - let found: string | undefined; - for (const k of apiKeyStore.keys()) if (k.slice(0, 8) === prefix) { found = k; break; } - const predecessor = found ? apiKeyStore.get(found) : undefined; - if (!found || !predecessor) { - sendError(res, req, 404, "not_found", `no key with prefix ${prefix}`); - return; - } - const now = Date.now(); - // Stamp the predecessor with rotation metadata; it stays valid until grace expiry. - apiKeyStore.set(found, { - ...predecessor, - rotatedAt: now, - graceExpiresAt: now + ROTATION_GRACE_MS, - }); - // Mint the successor, inheriting the label and scopes. - const newKey = `srk_${randomUUID().replace(/-/g, "")}`; - apiKeyStore.set(newKey, { label: predecessor.label, createdAt: now, scopes: predecessor.scopes }); - res.status(201).json({ key: newKey, label: predecessor.label, graceExpiresAt: now + ROTATION_GRACE_MS }); -}); - -app.delete("/api/v1/webhooks/:id", (req: Request, res: Response) => { - const { id } = req.params; - if (!webhookStore.has(id)) { - sendError(res, req, 404, "not_found", `webhook ${id} not found`); - return; - } - webhookStore.delete(id); - recordEvent("webhook.deleted", { id }); - res.status(204).send(); -}); - -app.get("/api/v1/webhooks", (req: Request, res: Response) => { - const rawLimit = parseIntegerQueryParam(req.query.limit, 100); - if (rawLimit === null) { - sendError(res, req, 400, "invalid_request", "limit must be a single integer"); - return; - } - const limit = Math.min(500, Math.max(1, rawLimit)); - - const cursorResult = parseCursor(req.query.cursor); - if (cursorResult === "bad") { - sendError(res, req, 400, "invalid_request", "cursor is invalid"); - return; - } - const offset = cursorResult ?? 0; - - const allItems = Array.from(webhookStore.entries()).map(([id, m]) => ({ id, ...m })); - const { page, nextCursor } = paginate(allItems, limit, offset); - res.json({ items: page, nextCursor }); -}); - -/** Maximum number of event subscriptions per webhook registration. */ -const WEBHOOK_MAX_EVENTS = 20; - -/** Maximum character length of a single event name in a webhook subscription. */ -const WEBHOOK_MAX_EVENT_LENGTH = 128; - -/** - * Event name prefixes reserved for internal system events. - * User-defined webhook subscriptions may not use these prefixes. - */ -const WEBHOOK_RESERVED_PREFIXES = ["internal.", "system.", "admin."]; - -app.post("/api/v1/webhooks", (req: Request, res: Response) => { - if (rejectUnknownKeys(req, res, ["url", "events"])) return; - const { url, events } = req.body ?? {}; - if (typeof url !== "string" || !/^https?:\/\//.test(url) || url.length > 2048) { - sendError(res, req, 400, "invalid_request", "url must be http(s), <=2048 chars"); - return; - } - if (!Array.isArray(events) || events.length === 0 || events.some((e) => typeof e !== "string")) { - sendError(res, req, 400, "invalid_request", "events must be a non-empty string array"); - return; - } - if (events.length > WEBHOOK_MAX_EVENTS) { - sendError(res, req, 400, "invalid_request", `events may contain at most ${WEBHOOK_MAX_EVENTS} entries`); - return; - } - for (const name of events as string[]) { - if (name.trim().length === 0) { - sendError(res, req, 400, "invalid_request", "event names must not be blank or whitespace-only"); - return; - } - if (name.length > WEBHOOK_MAX_EVENT_LENGTH) { - sendError(res, req, 400, "invalid_request", `event names must be <= ${WEBHOOK_MAX_EVENT_LENGTH} chars`); - return; - } - if (WEBHOOK_RESERVED_PREFIXES.some((p) => name.startsWith(p))) { - sendError(res, req, 400, "invalid_request", `event name "${name}" uses a reserved prefix`); - return; - } - // Event names must be either "*" (wildcard) or follow the "namespace.action" - // convention (exactly one dot, alphanumeric segments). Names with multiple - // dots are rejected as they indicate a typo or unsupported format. - if (name !== "*" && !/^[a-zA-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_]+$/.test(name)) { - sendError(res, req, 400, "invalid_request", `event name "${name}" must be "*" or follow "namespace.action" format`); - return; - } - } - // Deduplicate event names before storing - const deduped = [...new Set(events as string[])]; - const id = `wh_${randomUUID().replace(/-/g, "").slice(0, 16)}`; - webhookStore.set(id, { url, events: deduped, createdAt: Date.now() }); - // Record id and url only — never any webhook secret material. - recordEvent("webhook.created", { id, url }); - res.status(201).json({ id, url, events: deduped }); -}); - -/** - * Read a single registered webhook by id. - * - * Returns `{ id, url, events, createdAt }` for a known id, or - * `404 not_found` (with the canonical `requestId` envelope) otherwise. - * - * @route GET /api/v1/webhooks/:id - */ -app.get("/api/v1/webhooks/:id", (req: Request, res: Response) => { - const { id } = req.params; - const record = webhookStore.get(id); - if (!record) { - sendError(res, req, 404, "not_found", `webhook ${id} not found`); - return; - } - res.json({ id, ...record }); -}); - -/** - * Update a registered webhook's subscribed `events` in place. - * - * The `url` is intentionally immutable on PATCH: changing the destination - * should go through delete/recreate so the SSRF-validation provenance of the - * URL is preserved. The new `events` value is validated with the same - * non-empty-string-array rule used by the create handler and deduplicated - * before being stored. Returns the updated webhook, or `404 not_found` when - * the id is unknown. - * - * @route PATCH /api/v1/webhooks/:id - */ -app.patch("/api/v1/webhooks/:id", (req: Request, res: Response) => { - const { id } = req.params; - const record = webhookStore.get(id); - if (!record) { - sendError(res, req, 404, "not_found", `webhook ${id} not found`); - return; - } - const { events } = req.body ?? {}; - if (!Array.isArray(events) || events.length === 0 || events.some((e) => typeof e !== "string")) { - sendError(res, req, 400, "invalid_request", "events must be a non-empty string array"); - return; - } - const deduped = [...new Set(events as string[])]; - // url is preserved; only events are mutated. - const updated = { ...record, events: deduped }; - webhookStore.set(id, updated); - res.json({ id, ...updated }); -}); - -/** - * Resolve the source/destination route params and the computed pair key. - * Returns null and sends a 404 if the pair is not registered. - */ -function resolvePair( - req: Request, - res: Response -): { source: string; destination: string; key: string } | null { - const { source, destination } = req.params; - const key = pairKey(source, destination); - if (!pairRegistry.has(key)) { - sendError(res, req, 404, "not_found", `pair ${source}->${destination} is not registered`); - return null; - } - return { source, destination, key }; -} - -/** - * Normalize the `:source`/`:destination` route params to their canonical asset - * codes via {@link normalizeAsset}. On invalid input a `400 invalid_request` is - * sent and `null` is returned so the caller can `return` immediately. - */ -const normalizePairParams = ( - req: Request, - res: Response -): { source: string; destination: string } | null => { - const source = normalizeAsset(req.params.source); - const destination = normalizeAsset(req.params.destination); - if (source === null || destination === null) { - sendError(res, req, 400, "invalid_request", "source and destination must be 1-12 alphanumeric characters"); - return null; - } - return { source, destination }; -}; - -/** Aggregate read of every per-pair slot in one round-trip. */ -app.get("/api/v1/pairs/:source/:destination/info", (req: Request, res: Response) => { - const normalized = normalizePairParams(req, res); - if (!normalized) return; - const { source, destination } = normalized; - const k = pairKey(source, destination); - res.json({ - source, - destination, - registered: pairRegistry.has(k), - ...(pairMeta.get(k) ?? defaultMeta()), - }); -}); - -/** - * Factory that creates an Express PATCH handler for a single `PairMeta` field. - * - * All four per-pair PATCH routes share the same flow: - * 1. Resolve the pair key from `:source` / `:destination` params. - * 2. Guard with a 404 if the pair is not registered. - * 3. Validate the inbound value with the field-specific `validate` function. - * 4. Mutate exactly the bound `field` on the stored metadata. - * 5. Respond with `{ source, destination, ...meta }`. - * - * Binding the field name at registration time means the handler can never - * accidentally mutate a different field, even if the descriptor table is - * extended in the future. - * - * @param field - The key of `PairMeta` this handler is responsible for. - * @param bodyKey - The request-body property name carrying the incoming value. - * @param validate - Returns `true` when the value is acceptable, `false` to reject. - * @param errorMessage - The `message` string sent in the 400 response body. - */ -const makePairMetaPatch = ( - field: K, - bodyKey: string, - validate: (v: unknown) => boolean, - errorMessage: string, - crossCheck?: (value: unknown, meta: PairMeta) => string | null -) => - (req: Request, res: Response): void => { - const normalized = normalizePairParams(req, res); - if (!normalized) return; - const { source, destination } = normalized; - const k = pairKey(source, destination); - if (!pairRegistry.has(k)) { - sendError(res, req, 404, "not_found", "pair not registered"); - return; - } - if (rejectUnknownKeys(req, res, [bodyKey])) return; - const value = (req.body ?? {})[bodyKey] as unknown; - if (!validate(value)) { - sendError(res, req, 400, "invalid_request", errorMessage); - return; - } - const meta = pairMeta.get(k) ?? defaultMeta(); - // Optional cross-field invariant (e.g. min <= max). Runs after the - // per-field format check so `value` is already known to be a valid - // integer string; comparisons stay in BigInt space (see crossCheck impls). - if (crossCheck) { - const crossError = crossCheck(value, meta); - if (crossError !== null) { - sendError(res, req, 400, "invalid_request", crossError); - return; - } - } - (meta as Record)[field] = value; - pairMeta.set(k, meta); - res.json({ source, destination, ...meta }); - }; - -/** - * Cross-field guard for `PATCH .../min`. - * - * Rejects a new `minAmount` that would exceed the pair's existing **non-zero** - * `maxAmount` (a `maxAmount` of `"0"` is treated as "unset" and never triggers - * the check). Both values are compared as `BigInt` to preserve precision on - * amounts above `Number.MAX_SAFE_INTEGER`; the input is never coerced through - * `Number`. - * - * @returns An error message naming both bounds when inconsistent, else `null`. - */ -const checkMinAgainstMax = (value: unknown, meta: PairMeta): string | null => { - const newMin = BigInt(value as string); - const existingMax = BigInt(meta.maxAmount); - if (existingMax !== 0n && newMin > existingMax) { - return `minAmount (${newMin}) must not exceed the current maxAmount (${existingMax})`; - } - return null; -}; - -/** - * Cross-field guard for `PATCH .../max`. - * - * Rejects a new `maxAmount` that would fall below the pair's existing - * **non-zero** `minAmount` (a `minAmount` of `"0"` is treated as "unset"). - * Comparisons are performed entirely in `BigInt` space. - * - * @returns An error message naming both bounds when inconsistent, else `null`. - */ -const checkMaxAgainstMin = (value: unknown, meta: PairMeta): string | null => { - const newMax = BigInt(value as string); - const existingMin = BigInt(meta.minAmount); - if (existingMin !== 0n && newMax < existingMin) { - return `maxAmount (${newMax}) must not be below the current minAmount (${existingMin})`; - } - return null; -}; - -/** - * Cross-field guard for `PATCH .../liquidity`. - * - * Rejects a new `liquidity` below the pair's existing **non-zero** `minAmount` - * (a `minAmount` of `"0"` is treated as "unset"; a liquidity of `"0"` is also - * treated as "unset" and never triggers the check). - * - * @returns An error message when inconsistent, else `null`. - */ -const checkLiquidityAgainstMin = (value: unknown, meta: PairMeta): string | null => { - const newLiquidity = BigInt(value as string); - if (newLiquidity === 0n) return null; // "0" means unset — always allowed - const existingMin = BigInt(meta.minAmount); - if (existingMin !== 0n && newLiquidity < existingMin) { - return `liquidity (${newLiquidity}) must not be below the current minAmount (${existingMin})`; - } - return null; -}; - -/** - * Cross-field guard for `PATCH .../min` (against liquidity). - * - * Rejects a new `minAmount` above the pair's existing **non-zero** `liquidity` - * (a `liquidity` of `"0"` is treated as "unset" and never triggers the check). - * - * @returns An error message when inconsistent, else `null`. - */ -const checkMinAgainstLiquidity = (value: unknown, meta: PairMeta): string | null => { - const newMin = BigInt(value as string); - const existingLiquidity = BigInt(meta.liquidity); - if (existingLiquidity !== 0n && newMin > existingLiquidity) { - return `minAmount (${newMin}) must not exceed the current liquidity (${existingLiquidity})`; - } - return null; -}; - -/** - * Descriptor table driving the four per-pair PATCH routes. - * Each entry maps a URL suffix to its PairMeta field, body key, validator, and - * error message — the only dimensions that differ between the four handlers. - * Reuses the same regexes / number checks as the original inline handlers. - */ -const pairMetaPatchDescriptors: Array<{ - suffix: string; - field: keyof PairMeta; - bodyKey: string; - validate: (v: unknown) => boolean; - errorMessage: string; - crossCheck?: (value: unknown, meta: PairMeta) => string | null; -}> = [ - { - suffix: "liquidity", - field: "liquidity", - bodyKey: "liquidity", - validate: (v) => typeof v === "string" && /^[0-9]{1,39}$/.test(v), - errorMessage: "liquidity must be a non-negative integer string", - crossCheck: checkLiquidityAgainstMin, - }, - { - suffix: "max", - field: "maxAmount", - bodyKey: "maxAmount", - validate: (v) => typeof v === "string" && /^[1-9][0-9]{0,38}$/.test(v), - errorMessage: "maxAmount must be a positive integer string", - crossCheck: checkMaxAgainstMin, - }, - { - suffix: "min", - field: "minAmount", - bodyKey: "minAmount", - validate: (v) => typeof v === "string" && /^[0-9]{1,39}$/.test(v), - errorMessage: "minAmount must be a non-negative integer string", - crossCheck: (value, meta) => checkMinAgainstMax(value, meta) ?? checkMinAgainstLiquidity(value, meta), - }, - { - suffix: "fee_bps", - field: "feeBps", - bodyKey: "feeBps", - validate: (v) => - typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1000, - errorMessage: "feeBps must be an integer in [0,1000]", - }, - { - suffix: "rate", - field: "rate", - bodyKey: "rate", - validate: (v) => { - if (typeof v !== "string") return false; - if (v.length > 20) return false; // bound precision - return /^[0-9]+(\.[0-9]{1,8})?$/.test(v) && parseFloat(v) > 0; - }, - errorMessage: - 'rate must be a positive decimal string (e.g. "1.0", "0.85"), max 8 decimal places', - }, -]; - -// Register each descriptor as a PATCH route. -for (const { suffix, field, bodyKey, validate, errorMessage, crossCheck } of pairMetaPatchDescriptors) { - app.patch( - `/api/v1/pairs/:source/:destination/${suffix}`, - makePairMetaPatch(field, bodyKey, validate, errorMessage, crossCheck) - ); -} - -/** - * Toggle a pair's enabled flag. - * - * Accepts `{ enabled: boolean }` and emits a `pair.enabled` or `pair.disabled` - * audit event on each toggle. Non-boolean bodies are rejected with 400. - * - * @route PATCH /api/v1/pairs/:source/:destination/enabled - */ -app.patch("/api/v1/pairs/:source/:destination/enabled", (req: Request, res: Response): void => { - const normalized = normalizePairParams(req, res); - if (!normalized) return; - const { source, destination } = normalized; - const k = pairKey(source, destination); - if (!pairRegistry.has(k)) { - sendError(res, req, 404, "not_found", "pair not registered"); - return; - } - if (rejectUnknownKeys(req, res, ["enabled"])) return; - const { enabled } = req.body ?? {}; - if (typeof enabled !== "boolean") { - sendError(res, req, 400, "invalid_request", "enabled must be a boolean"); - return; - } - const meta = pairMeta.get(k) ?? defaultMeta(); - meta.enabled = enabled; - pairMeta.set(k, meta); - recordEvent(enabled ? "pair.enabled" : "pair.disabled", { source, destination }); - res.json({ source, destination, ...meta }); -}); - -/** - * Reset a registered pair's metadata to factory defaults. - * - * Overwrites the pair's `pairMeta` entry with `defaultMeta()`, emits a - * `pair.meta.reset` audit event, and returns the fresh metadata. Blocked - * while the service is paused (non-idempotent POST). Returns 404 when the - * pair is not registered. - * - * @route POST /api/v1/pairs/:source/:destination/reset - */ -app.post("/api/v1/pairs/:source/:destination/reset", (req: Request, res: Response) => { - const { source, destination } = req.params; - const k = pairKey(source, destination); - if (!pairRegistry.has(k)) { - sendError(res, req, 404, "not_found", "pair not registered"); - return; - } - const meta = defaultMeta(); - pairMeta.set(k, meta); - recordEvent("pair.meta.reset", { source, destination }); - res.json({ source, destination, ...meta }); -}); - -/** Unregister a pair. */ -app.delete("/api/v1/pairs/:source/:destination", (req: Request, res: Response) => { - const { source, destination } = req.params; - const k = pairKey(source, destination); - if (!pairRegistry.has(k)) { - sendError(res, req, 404, "not_found", `pair ${source}->${destination} is not registered`); - return; - } - pairRegistry.delete(k); - recordEvent("pair.unregistered", { source, destination }); - res.status(204).send(); -}); - -/** Read a single registered pair. */ -app.get("/api/v1/pairs/:source/:destination", (req: Request, res: Response) => { - const { source, destination } = req.params; - if (!pairRegistry.has(pairKey(source, destination))) { - sendError(res, req, 404, "not_found", `pair ${source}->${destination} is not registered`); - return; - } - res.json({ source, destination, registered: true }); -}); - -app.get("/api/v1/admin/status", (_req: Request, res: Response) => { - res.json({ paused, readOnly }); -}); - -app.get("/api/v1/config", (_req: Request, res: Response) => res.json({ config })); -app.patch("/api/v1/config", (req: Request, res: Response) => { - const allowed = ["rateLimitPerWindow", "rateLimitWindowMs", "bulkMaxItems", "eventLogCap", "quote_ttl_ms"] as const; - if (rejectUnknownKeys(req, res, [...allowed])) return; - for (const k of allowed) { - if (k in (req.body ?? {})) { - const v = req.body[k]; - if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) { - sendError(res, req, 400, "invalid_request", `${k} must be positive integer`); - return; - } - if (k === "bulkMaxItems" && v > BULK_ABSOLUTE_MAX) { - sendError(res, req, 400, "invalid_request", `bulkMaxItems cannot exceed ${BULK_ABSOLUTE_MAX}`); - return; - } - if (k === "eventLogCap" && v > EVENT_LOG_CAP_MAX) { - sendError(res, req, 400, "invalid_request", `eventLogCap cannot exceed ${EVENT_LOG_CAP_MAX}`); - return; - } - config[k] = v; - // Trim the event log immediately when the cap is lowered so that the - // buffer stays within the new bound without waiting for the next write. - if (k === "eventLogCap") trimEventLog(v); - } - } - res.json({ config }); -}); - -/** - * Escape a Prometheus label value per the exposition format rules: - * backslash → \\, double-quote → \", newline → \n. - * - * @param value - Raw label value string. - * @returns Escaped string safe for use inside double-quoted label values. - */ -const escapeLabelValue = (value: string): string => - value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); - -/** - * Aggregate event counts from the in-memory event log. - * - * Returns a Map from event type to count. Only types present in - * {@link KNOWN_EVENT_TYPES} are included so the series set is stable. - * - * @param log - The current event log array. - * @returns Map of event type → count for each known type. - */ -const aggregateEventCounts = (log: AppEvent[]): Map => { - const counts = new Map(KNOWN_EVENT_TYPES.map((t) => [t, 0])); - for (const event of log) { - if (counts.has(event.type)) { - counts.set(event.type, (counts.get(event.type) ?? 0) + 1); - } - } - return counts; -}; - -/** - * Build the label-free store-size and config gauges exposed in `/metrics`. - * - * Each metric is a bounded, constant-cardinality gauge derived from an - * in-memory store size or the active `config`, emitted with its `# HELP` and - * `# TYPE` comment lines in Prometheus text exposition format. No labels are - * used, so scrape cardinality stays constant, and no raw secrets or URLs are - * ever included — only counts and the configured rate limit. - * - * @returns Array of exposition lines (HELP/TYPE/value triples) ready to join. - */ -const buildStoreGaugeLines = (): string[] => [ - "# HELP stableroute_api_keys_total Number of stored API keys.", - "# TYPE stableroute_api_keys_total gauge", - `stableroute_api_keys_total ${apiKeyStore.size}`, - "# HELP stableroute_webhooks_total Number of registered webhooks.", - "# TYPE stableroute_webhooks_total gauge", - `stableroute_webhooks_total ${webhookStore.size}`, - "# HELP stableroute_event_log_size Current number of entries in the event log.", - "# TYPE stableroute_event_log_size gauge", - `stableroute_event_log_size ${eventLog.length}`, - "# HELP stableroute_rate_limit_per_window Configured request limit per rate-limit window.", - "# TYPE stableroute_rate_limit_per_window gauge", - `stableroute_rate_limit_per_window ${config.rateLimitPerWindow ?? 0}`, -]; - -app.get("/api/v1/metrics", (_req: Request, res: Response) => { - const eventCounts = aggregateEventCounts(eventLog); - - const lines = [ - "# HELP stableroute_pairs_total Number of registered pairs.", - "# TYPE stableroute_pairs_total gauge", - `stableroute_pairs_total ${pairRegistry.size}`, - "# HELP stableroute_paused 1 if paused, 0 otherwise.", - "# TYPE stableroute_paused gauge", - `stableroute_paused ${paused ? 1 : 0}`, - "# HELP stableroute_events_total Total number of events in the audit log.", - "# TYPE stableroute_events_total gauge", - `stableroute_events_total ${eventLog.length}`, - "# HELP stableroute_events_by_type Count of events in the audit log per type.", - "# TYPE stableroute_events_by_type gauge", - ...Array.from(eventCounts.entries()).map( - ([type, count]) => - `stableroute_events_by_type{type="${escapeLabelValue(type)}"} ${count}` - ), - ...buildStoreGaugeLines(), - ]; - res.setHeader("Content-Type", "text/plain; version=0.0.4"); - res.send(lines.join("\n") + "\n"); -}); - -/** - * Derive per-pair aggregates from the registry and metadata maps. - * - * Computes, in a single O(n) pass over the registered pairs: - * - `pairsWithFee` — count of pairs whose stored `feeBps > 0`. - * - `distinctAssets` — number of unique asset codes appearing as either the - * source or destination of any registered pair. - * - * Side-effect free: reads only the existing in-memory stores and allocates no - * new persistent state. - * - * @returns `{ pairsWithFee, distinctAssets }`. - */ -const aggregatePairStats = (): { pairsWithFee: number; distinctAssets: number } => { - let pairsWithFee = 0; - const assets = new Set(); - for (const k of pairRegistry) { - const [source, destination] = k.split("::"); - assets.add(source); - assets.add(destination); - if ((pairMeta.get(k)?.feeBps ?? 0) > 0) pairsWithFee += 1; - } - return { pairsWithFee, distinctAssets: assets.size }; -}; - -app.get("/api/v1/stats", (_req: Request, res: Response) => { - const { pairsWithFee, distinctAssets } = aggregatePairStats(); - res.json({ - totalPairs: pairRegistry.size, - paused, - totalApiKeys: apiKeyStore.size, - totalWebhooks: webhookStore.size, - totalEvents: eventLog.length, - pairsWithFee, - distinctAssets, - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Pair registry -// ───────────────────────────────────────────────────────────────────────────── -// -// In-memory mirror of the on-chain DataKey::Pair(source, dest) set the -// router contract maintains. The settlement worker fans out from the -// contract to this Map on startup and on every pair-registration event. -// Process restart resets the map; persistence lands with the database -// adapter. -/** - * Serialize the current pair registry to a JSON string. - * Shared between the GET and HEAD handlers so the two always produce - * byte-identical output and therefore byte-identical ETags. - */ -const serializePairs = (): string => { - const pairs = Array.from(pairRegistry).map((k) => { - const [source, destination] = k.split("::"); - return { source, destination }; - }); - return JSON.stringify({ pairs }); -}; - -/** - * Compute the weak ETag for the pairs list body. - * Uses a base64-truncated SHA-1 digest, identical to the original GET handler. - * - * @param body - the already-serialized JSON string returned by serializePairs() - */ -const pairsEtag = (body: string): string => - `W/"${createHash("sha1").update(body).digest("base64").slice(0, 16)}"`; - -/** - * List every registered (source, destination) pair. - * Response: { pairs: [{ source, destination }, ...] } - */ -app.get("/api/v1/pairs", (req: Request, res: Response) => { - const rawLimit = parseIntegerQueryParam(req.query.limit, 100); - if (rawLimit === null) { - sendError(res, req, 400, "invalid_request", "limit must be a single integer"); - return; - } - const limit = Math.min(500, Math.max(1, rawLimit)); - - const cursorResult = parseCursor(req.query.cursor); - if (cursorResult === "bad") { - sendError(res, req, 400, "invalid_request", "cursor is invalid"); - return; - } - const offset = cursorResult ?? 0; - - const allPairs = Array.from(pairRegistry).map((k) => { - const [source, destination] = k.split("::"); - return { source, destination }; - }); - const { page, nextCursor } = paginate(allPairs, limit, offset); - const body = JSON.stringify({ pairs: page, nextCursor }); - const etag = pairsEtag(body); - if (req.header("if-none-match") === etag) { - res.status(304).end(); - return; - } - res.setHeader("ETag", etag); - res.type("application/json").send(body); -}); - -/** - * HEAD /api/v1/pairs - * - * Returns the same ETag, Content-Type, and Content-Length as GET but with - * no body. A well-behaved cache can use this to learn the current ETag and - * body size without transferring the full pairs list. - * - * Honors If-None-Match: responds 304 when the client's cached ETag matches, - * and 200 (empty body) otherwise. Respects the pause guard identically to GET. - */ -app.head("/api/v1/pairs", (req: Request, res: Response) => { - const body = serializePairs(); - const etag = pairsEtag(body); - if (req.header("if-none-match") === etag) { - res.status(304).end(); - return; - } - res.setHeader("ETag", etag); - res.setHeader("Content-Type", "application/json"); - res.setHeader("Content-Length", Buffer.byteLength(body).toString()); - res.status(200).end(); -}); - -/** - * Register a pair (test-only / operator surface; will move behind an - * admin auth guard once the gateway lands). Body: { source, destination }. - * Returns 201 on first-write, 200 on idempotent re-write. - */ -app.post("/api/v1/pairs", idempotencyGuard, (req: Request, res: Response) => { - const { source: rawSource, destination: rawDestination } = req.body ?? {}; - const source = normalizeAsset(rawSource); - const destination = normalizeAsset(rawDestination); - if (source === null || destination === null) { - return sendError( - res, - req, - 400, - "invalid_request", - "source and destination must be 1-12 alphanumeric characters" - ); - } - if (source === destination) { - return sendError(res, req, 400, "invalid_request", "source and destination must differ"); - } - const key = pairKey(source, destination); - const isNew = !pairRegistry.has(key); - pairRegistry.add(key); - recordEvent(isNew ? "pair.registered" : "pair.refreshed", { source, destination }); - res.status(isNew ? 201 : 200).json({ source, destination, registered: true }); -}); - -/** - * Register many pairs in a single request, returning a per-item outcome. - * - * Body: `{ pairs: [{ source, destination }, ...] }` with 1–`config.bulkMaxItems` - * entries. Each item is validated independently with the same `isAssetCode` and - * same-asset rules as the single-pair endpoint; one bad item never fails the - * whole batch. A `pair.registered` / `pair.refreshed` event is recorded for - * each successfully registered item exactly as the single endpoint does. - * - * Per-item result shape: - * - success: `{ index, ok: true, source, destination, registered: true }` - * - failure: `{ index, ok: false, error }` - * - * Returns `400 invalid_request` only when the `pairs` array itself is missing, - * empty, or exceeds the configured cap. - * - * @route POST /api/v1/pairs/bulk - */ -app.post("/api/v1/pairs/bulk", (req: Request, res: Response) => { - const { pairs } = req.body ?? {}; - const maxItems = config.bulkMaxItems; - if (!Array.isArray(pairs) || pairs.length === 0 || pairs.length > maxItems) { - sendError(res, req, 400, "invalid_request", `pairs must be 1-${maxItems} entries`); - return; - } - const results = pairs.map( - (it: { source?: unknown; destination?: unknown }, index: number) => { - const { source, destination } = it ?? {}; - if (!isAssetCode(source) || !isAssetCode(destination)) { - return { index, ok: false as const, error: "invalid_asset_code" }; - } - if (source === destination) { - return { index, ok: false as const, error: "same_asset" }; - } - const key = pairKey(source, destination); - const isNew = !pairRegistry.has(key); - pairRegistry.add(key); - recordEvent(isNew ? "pair.registered" : "pair.refreshed", { source, destination }); - return { index, ok: true as const, source, destination, registered: true }; - } - ); - res.json({ results }); -}); - -// Asset symbols are short uppercase identifiers (USDC, EURC, XLM, …). -// Cap at 12 chars (Stellar's max alphanumeric asset code) and reject -// anything that is not a single string so an array param can't smuggle -// through as a "truthy" value. -// -// Codes beginning with "__health" are explicitly rejected to prevent a -// caller from registering a pair whose derived pairKey could collide with -// the deep-probe's reserved scratch namespace (HEALTH_PROBE_KEY), which -// would allow a concurrent probe delete to silently drop operator data. -const isAssetCode = (v: unknown): v is string => - typeof v === "string" && - v.length > 0 && - v.length <= 12 && - !v.startsWith("__health"); - -/** - * Canonicalize an asset code so that casing and surrounding whitespace never - * fragment a logical pair. - * - * The input is trimmed of leading/trailing whitespace and upper-cased. After - * normalization the code must be 1–12 characters (Stellar's max alphanumeric - * asset code) drawn exclusively from `[A-Z0-9]` — any internal whitespace, - * control character, or other non-alphanumeric symbol causes a rejection. The - * reserved `__health` probe namespace is also rejected. Because the length is - * checked *after* trimming, padding can never be used to bypass the 12-char cap. - * - * @param v - The raw asset code (from a body field or URL/query param). - * @returns The canonical upper-cased code, or `null` when the input is invalid. - */ -const normalizeAsset = (v: unknown): string | null => { - if (typeof v !== "string") return null; - const code = v.trim().toUpperCase(); - if (!/^[A-Z0-9]{1,12}$/.test(code) || code.startsWith("__HEALTH")) return null; - return code; -}; - -// Quote amount: a base-units integer string. Parsed via BigInt so we -// never lose precision on amounts above Number.MAX_SAFE_INTEGER. -const parseAmount = (v: unknown): bigint | null => { - if (typeof v !== "string" || !/^[1-9][0-9]{0,38}$/.test(v)) return null; - try { - const n = BigInt(v); - return n > 0n ? n : null; - } catch { - return null; - } -}; - -/** - * Compute the fee breakdown for a given amount and fee rate. - * - * Arithmetic is performed entirely with `BigInt` to preserve precision on - * amounts above `Number.MAX_SAFE_INTEGER`. Fees are rounded **down** (in - * the gateway's favour) via integer division. The resulting `netAmount` is - * always non-negative: `netAmount = amount - feeAmount`. - * - * @param amount - The gross amount in base units (must be > 0n). - * @param feeBps - Fee rate in basis points (0–1000, where 10000 bps = 100 %). - * @returns An object with `feeAmount` and `netAmount` as `bigint` values. - */ -/** - * Compute the minimum received amount after applying slippage tolerance. - * - * Uses BigInt arithmetic to preserve precision on amounts above - * Number.MAX_SAFE_INTEGER. The formula is: - * min_received = amount - floor(amount * slippageBps / 10_000) - * - * @param amount - The output amount to apply slippage against (must be > 0n). - * @param slippageBps - Slippage tolerance in basis points (0–1000). - * @returns The minimum guaranteed received amount. - */ -export const applySlippage = (amount: bigint, slippageBps: number): bigint => { - const slippageAmount = (amount * BigInt(slippageBps)) / 10_000n; - return amount - slippageAmount; -}; - -export const applyFee = ( - amount: bigint, - feeBps: number -): { feeAmount: bigint; netAmount: bigint } => { - const feeAmount = (amount * BigInt(feeBps)) / 10_000n; - const netAmount = amount - feeAmount; - return { feeAmount, netAmount }; -}; - -const computeQuoteTimes = (): { quoted_at: number; expires_at: number } => { - const ttl = (config.quote_ttl_ms ?? 30000) as number; - const quoted_at = Date.now(); - return { quoted_at, expires_at: quoted_at + ttl }; -}; - -app.post("/api/v1/quote/bulk", (req: Request, res: Response) => { - const { items } = req.body ?? {}; - const maxItems = config.bulkMaxItems; // driven by config.bulkMaxItems - if (!Array.isArray(items) || items.length === 0 || items.length > maxItems) { - sendError(res, req, 400, "invalid_request", `items must be 1-${maxItems} entries`); - return; - } - /** - * Per-item pair registration check for bulk quotes. - * - * When `ALLOW_UNREGISTERED_QUOTES` is not set to `"true"`, each item whose - * pair is not present in `pairRegistry` returns `ok: false` with - * `error: "pair_not_registered"` instead of failing the whole batch. - * Shape/validation errors (invalid asset code, same asset, bad amount) are - * still returned as `ok: false, error: "invalid_item"` and take precedence. - */ - const allowUnregistered = process.env.ALLOW_UNREGISTERED_QUOTES === "true"; - const results = items.map((it: { source_asset?: unknown; dest_asset?: unknown; amount?: unknown }, i: number) => { - const { source_asset: rawSource, dest_asset: rawDest, amount } = it ?? {}; - const source_asset = normalizeAsset(rawSource); - const dest_asset = normalizeAsset(rawDest); - if (source_asset === null || dest_asset === null || parseAmount(amount) === null || source_asset === dest_asset) { - return { index: i, ok: false as const, error: "invalid_item" }; - } - if (!allowUnregistered && !pairRegistry.has(pairKey(source_asset, dest_asset))) { - return { index: i, ok: false as const, error: "pair_not_registered" }; - } - const bulkKey = pairKey(source_asset, dest_asset); - const bulkMeta = pairMeta.get(bulkKey) ?? defaultMeta(); - if (pairRegistry.has(bulkKey) && bulkMeta.enabled === false) { - return { index: i, ok: false as const, error: "pair_disabled" }; - } - return { - index: i, - ok: true as const, - source_asset, - dest_asset, - amount: String(amount), - estimated_rate: "1.0", - slippage_bps, - min_received: min_received.toString(), - }; - }); - res.json({ results }); -}); - -app.get("/api/v1/quote", (req: Request, res: Response) => { - const { source_asset: rawSource, dest_asset: rawDest, amount, slippage_bps: rawSlippage } = req.query; - - if (!rawSource || !rawDest || !amount) { - return sendError( - res, - req, - 400, - "invalid_request", - "Missing required query params: source_asset, dest_asset, amount" - ); - } - const source_asset = normalizeAsset(rawSource); - const dest_asset = normalizeAsset(rawDest); - if (source_asset === null || dest_asset === null) { - return sendError( - res, - req, - 400, - "invalid_request", - "source_asset and dest_asset must be 1-12 alphanumeric characters" - ); - } - if (source_asset === dest_asset) { - return sendError(res, req, 400, "invalid_request", "source_asset and dest_asset must differ"); - } - const parsedAmount = parseAmount(amount); - if (parsedAmount === null) { - return sendError( - res, - req, - 400, - "invalid_request", - "amount must be a positive integer string with no leading zero" - ); - } - - /** - * Pair registration guard. - * - * Looks up the pair key in `pairRegistry` after all shape/validation checks - * pass. When the pair is not registered, responds 404 `pair_not_registered` - * with the canonical `{ error, message, requestId }` body and echoes back the - * offending `source_asset`/`dest_asset`. - * - * Set `ALLOW_UNREGISTERED_QUOTES=true` in the environment to bypass this check - * (e.g. for demos that quote arbitrary pairs). The flag defaults to off, which - * is the safe/production behavior. It can only be set at process startup and - * cannot be toggled per-request. - */ - const allowUnregistered = process.env.ALLOW_UNREGISTERED_QUOTES === "true"; - if (!allowUnregistered && !pairRegistry.has(pairKey(source_asset, dest_asset))) { - return sendError(res, req, 404, "pair_not_registered", - `pair ${source_asset}->${dest_asset} is not registered`, - { source_asset, dest_asset } - ); - } - - const meta = pairMeta.get(pairKey(source_asset, dest_asset)) ?? defaultMeta(); - const { feeAmount, netAmount } = applyFee(parsedAmount, meta.feeBps); - const min_received = applySlippage(netAmount, slippage_bps); - - res.json({ - source_asset, - dest_asset, - amount: parsedAmount.toString(), - estimated_rate: meta.rate, - route: [source_asset, dest_asset], - feeBps: meta.feeBps, - feeAmount: feeAmount.toString(), - netAmount: netAmount.toString(), - slippage_bps, - min_received: min_received.toString(), - }); -}); - -/** - * Invert the fee formula to solve for the gross input required to deliver - * exactly `output` base units after the gateway fee is deducted. - * - * The forward fee formula is: - * fee = floor(gross * feeBps / 10_000) - * output = gross - fee - * - * Rearranging: - * gross = ceil(output * 10_000 / (10_000 - feeBps)) - * - * The result is rounded **up** (ceiling division) so that applying the - * forward fee to `requiredInput` always yields at least `output` — the - * recipient is never short-changed. - * - * @param output - Target delivered amount in base units (must be > 0). - * @param feeBps - Fee in basis points in [0, 10000). - * @returns Object with `requiredInput` (gross, rounded up) and `feeAmount`. - * @throws {RangeError} if feeBps >= 10000 (100% fee leaves nothing to deliver). - */ -export const invertFee = ( - output: bigint, - feeBps: number -): { requiredInput: bigint; feeAmount: bigint } => { - if (feeBps < 0 || feeBps >= 10_000) { - throw new RangeError("feeBps must be in [0, 9999]"); - } - const denominator = BigInt(10_000 - feeBps); - const numerator = output * 10_000n; - // Ceiling division: (a + b - 1) / b - const requiredInput = (numerator + denominator - 1n) / denominator; - const feeAmount = requiredInput - output; - return { requiredInput, feeAmount }; -}; - -app.get("/api/v1/quote/reverse", (req: Request, res: Response) => { - const { source_asset, dest_asset, target_amount, output } = req.query; - - // Accept target_amount (canonical) or output (legacy alias) - const targetAmountRaw = target_amount ?? output; - - if (!source_asset || !dest_asset || !targetAmountRaw) { - return sendError( - res, - req, - 400, - "invalid_request", - "Missing required query params: source_asset, dest_asset, target_amount" - ); - } - if (!isAssetCode(source_asset) || !isAssetCode(dest_asset)) { - return sendError( - res, - req, - 400, - "invalid_request", - "source_asset and dest_asset must be 1-12 character strings" - ); - } - if (source_asset === dest_asset) { - return sendError(res, req, 400, "invalid_request", "source_asset and dest_asset must differ"); - } - const parsedOutput = parseAmount(targetAmountRaw); - if (parsedOutput === null) { - return sendError( - res, - req, - 400, - "invalid_request", - "target_amount must be a positive integer string with no leading zero" - ); - } - - const k = pairKey(source_asset, dest_asset); - const meta = pairMeta.get(k) ?? defaultMeta(); - const feeBps = meta.feeBps; - - const { requiredInput, feeAmount } = invertFee(parsedOutput, feeBps); - - res.json({ - source_asset, - dest_asset, - target_amount: parsedOutput.toString(), - required_input: requiredInput.toString(), - estimated_rate: "1.0", - route: [source_asset, dest_asset], - // legacy fields kept for backward compatibility - output: parsedOutput.toString(), - requiredInput: requiredInput.toString(), - feeAmount: feeAmount.toString(), - feeBps, - }); -}); - -// Unknown route: structured 404 echoing the request id. -app.use((req: Request, res: Response) => { - sendError(res, req, 404, "not_found", `No route for ${req.method} ${req.path}`); -}); - -// Final 4-arg error handler. Any handler that throws or calls next(err) -// lands here; the response shape is the same canonical -// { error, message, requestId } as the explicit 400 / 404 bodies so -// clients can branch on `error` uniformly. -app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => { - if (err && typeof err === "object" && "type" in err && (err as { type: string }).type === "entity.too.large") { - sendError(res, req, 413, "payload_too_large", "request body exceeds the 100 KiB limit"); - return; - } - // Malformed JSON body. express.json() raises a SyntaxError tagged with - // `type: "entity.parse.failed"`; map it to a canonical 400 client error - // instead of letting it fall through to the generic 500. The message is - // fixed so the raw parser text (which can echo fragments of the input) is - // never leaked back to the caller. - if ( - err && - typeof err === "object" && - (("type" in err && (err as { type: string }).type === "entity.parse.failed") || - err instanceof SyntaxError) - ) { - sendError(res, req, 400, "invalid_json", "request body is not valid JSON"); - return; - } - const isProduction = process.env.NODE_ENV === "production"; - if (!isProduction && err instanceof Error) { - console.error(err); - } - const message = isProduction - ? "An unexpected error occurred" - : err instanceof Error - ? err.message - : "Unexpected server error"; - sendError(res, req, 500, "internal_error", message, { - method: req.method, - path: req.path, - }); -}); - -export default app; + if (bucket.length >= limitPerWindow) { + res.setHeader("Retry-After", String(Math.ceil(windowMs / 1000))); + sendError( + res, + req, + 429, + "rate_limited", + `more than ${limitPerWindow} requests per ${windowMs / 1000}s` + ); + return; + } + bucket.push(now); + rateBuckets.set(ip, bucket); + next(); +}); + +// Request timing — emits a single structured log per finished request +// and sets Server-Timing. +app.use((req: Request, res: Response, next: NextFunction) => { + const startNs = process.hrtime.bigint(); + res.on("finish", () => { + const ms = Number(process.hrtime.bigint() - startNs) / 1_000_000; + if (process.env.NODE_ENV !== "test") { + console.log( + JSON.stringify({ + requestId: getRequestId(req), + method: req.method, + path: req.path, + status: res.statusCode, + durationMs: Math.round(ms * 10) / 10, + }) + ); + } + }); + next(); +}); + +app.use((_req: Request, res: Response, next: NextFunction) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + next(); +}); + +/** + * Paths that are exempt from the JSON content-negotiation guard. + * + * - `GET /health` — shallow liveness probe (must never require Accept headers). + * - `GET /api/v1/metrics` — Prometheus scrape endpoint that serves `text/plain`. + * + * Both must remain reachable by monitoring systems that send no Accept header + * or that explicitly request `text/plain`. + */ +const ACCEPT_NEGOTIATION_EXEMPT = new Set(["/health", "/api/v1/metrics"]); + +/** + * JSON content-negotiation guard. + * + * Rejects requests whose `Accept` header is present and explicitly excludes + * `application/json` (and wildcards `*\/\*` / `application/*`) with a + * `406 Not Acceptable` response using the canonical `sendError` envelope. + * + * Rules: + * - A missing `Accept` header is treated as acceptable (defaults to JSON). + * - `*\/*` and `application/*` wildcards are accepted. + * - Routes in `ACCEPT_NEGOTIATION_EXEMPT` are always passed through. + * + * Security note: only the Accept header value is examined; the guard does not + * re-evaluate pause or rate-limit state, so those middleware layers remain + * authoritative for their own concerns. + */ +app.use((req: Request, res: Response, next: NextFunction) => { + if (ACCEPT_NEGOTIATION_EXEMPT.has(req.path)) return next(); + + const accept = req.header("accept"); + if (!accept) return next(); + + // Split on comma to get individual media-range tokens; strip quality params. + const types = accept.split(",").map((t) => t.split(";")[0].trim().toLowerCase()); + + const acceptable = types.some( + (t) => t === "*/*" || t === "application/json" || t === "application/*" + ); + + if (!acceptable) { + sendError(res, req, 406, "not_acceptable", "This endpoint only produces application/json"); + return; + } + + next(); +}); + +app.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok", service: "stableroute-backend" }); +}); + +app.get("/api/v1/openapi.json", (_req: Request, res: Response) => { + res.json(openApiSpec); +}); + +/** + * Reserved key used by the deep health probe's storage check. + * Prefixed with a NUL control character so it can never collide with a real pair key. + */ +const HEALTH_PROBE_KEY = "\x00__health_probe__"; + +/** + * Run all health checks for the deep readiness probe. + * Each check measures its own duration (in milliseconds) and returns + * `{ name, status, durationMs }`. Checks are synchronous and fast so + * the probe never hangs. External dependencies (e.g. storage, clock) + * are tested with a lightweight read/write cycle respectively. + * + * Results are returned as an array of { name, status, durationMs } objects. + */ +const runHealthChecks = (): Array<{ name: string; status: "ok" | "fail"; durationMs: number }> => { + const checks: Array<{ name: string; status: "ok" | "fail"; durationMs: number }> = []; + + // Storage check — verifies that the in-memory store can write and read back. + // Uses the reserved HEALTH_PROBE_KEY sentinel (prefixed with a NUL control + // character) so the scratch entry can never collide with a real pair key. + const storageStart = Date.now(); + try { + const testKey = HEALTH_PROBE_KEY; + pairMeta.set(testKey, defaultMeta()); + const readback = pairMeta.get(testKey); + pairMeta.delete(testKey); + checks.push({ + name: "storage", + status: readback !== undefined ? "ok" : "fail", + durationMs: Date.now() - storageStart, + }); + } catch { + checks.push({ + name: "storage", + status: "fail", + durationMs: Date.now() - storageStart, + }); + } + + // Clock check — verifies the system clock is producing post-epoch timestamps. + const clockStart = Date.now(); + try { + const now = Date.now(); + // A timestamp earlier than 2020-01-01 indicates a broken system clock. + checks.push({ + name: "clock", + status: now > 1577836800000 ? "ok" : "fail", + durationMs: Date.now() - clockStart, + }); + } catch { + checks.push({ + name: "clock", + status: "fail", + durationMs: Date.now() - clockStart, + }); + } + + return checks; +}; + +app.get("/api/v1/health/deep", (req: Request, res: Response) => { + const m = process.memoryUsage(); + + // Checks are synchronous and fast so the probe never hangs. + // When async downstream checks are added, wrap runHealthChecks() in a + // Promise.race with a timeout or pass an AbortSignal. + const checks = runHealthChecks(); + const degraded = checks.some((c) => c.status === "fail"); + const status = paused ? "paused" : degraded ? "degraded" : "ok"; + + const body = { + status, + uptimeSeconds: Math.round(process.uptime()), + memory: { + rssMb: Math.round(m.rss / 1024 / 1024), + heapUsedMb: Math.round(m.heapUsed / 1024 / 1024), + }, + pid: process.pid, + node: process.version, + checks, + }; + + if (degraded) { + res.status(503).json(body); + } else { + res.json(body); + } +}); +/** + * Build/identity metadata read once at module load. + * + * `name` and `version` come from `package.json` (resolved at runtime so the + * value is never hard-coded), while `commit` and `buildTime` are injected by + * the deploy pipeline through the `GIT_COMMIT` / `BUILD_TIME` env vars. Any + * missing env var degrades gracefully to `"unknown"` rather than throwing. + */ +const pkg = createRequire(__filename)("../package.json") as { + name?: string; + version?: string; +}; + +/** + * GET /api/v1/version — lightweight build/version metadata. + * + * Unauthenticated and cheap: runs no health checks and exposes only build + * identity (`name`, `version`, `commit`, `buildTime`, `node`) — never secrets, + * paths, or internal config. Missing `GIT_COMMIT` / `BUILD_TIME` env vars fall + * back to `"unknown"`. + */ +app.get("/api/v1/version", (_req: Request, res: Response) => { + res.json({ + name: pkg.name ?? "unknown", + version: pkg.version ?? "unknown", + commit: process.env.GIT_COMMIT ?? "unknown", + buildTime: process.env.BUILD_TIME ?? "unknown", + node: process.version, + }); +}); + +app.post("/api/v1/admin/pause", (_req: Request, res: Response) => { + setPaused(true); + recordEvent("admin.paused", {}); + res.json({ paused }); +}); +app.post("/api/v1/admin/unpause", (_req: Request, res: Response) => { + setPaused(false); + recordEvent("admin.unpaused", {}); + res.json({ paused }); +}); +app.post("/api/v1/admin/read-only", (_req: Request, res: Response) => { + setReadOnly(true); + res.json({ readOnly }); +}); +app.post("/api/v1/admin/read-write", (_req: Request, res: Response) => { + setReadOnly(false); + res.json({ readOnly }); +}); + +/** + * Parse a single numeric query param into a finite integer. + * + * Only single string values are accepted; an absent value falls back to + * `fallback`, while array-form params (e.g. `?since=1&since=2`, which Express + * surfaces as a string array) and non-numeric strings yield `null` so the + * caller can reject them explicitly instead of silently producing `NaN`. + * + * @param value - The raw `req.query[...]` value (string, string[], or undefined). + * @param fallback - The value to use when the param is absent. + * @returns A finite integer, or `null` when the input is array-form or non-numeric. + */ +const parseIntegerQueryParam = (value: unknown, fallback: number): number | null => { + if (value === undefined) return fallback; + if (typeof value !== "string" || value.trim() === "") return null; + const n = Number(value); + return Number.isFinite(n) && Number.isInteger(n) ? n : null; +}; +/** + * Decode a base64-encoded cursor string into an integer offset. + * + * Returns the decoded offset on success, or `"bad"` when the cursor is + * present but malformed (non-base64, non-integer, or negative). Returns + * `undefined` when no cursor was supplied so the caller can distinguish + * "first page" from an explicit bad value. + * + * @param raw - The raw `req.query.cursor` value (string, string[], or undefined). + * @returns The decoded integer offset, `"bad"` for a malformed cursor, or + * `undefined` when the param is absent. + */ +const parseCursor = (raw: unknown): number | "bad" | undefined => { + if (raw === undefined) return undefined; + if (typeof raw !== "string" || raw.trim() === "") return "bad"; + try { + const decoded = Buffer.from(raw, "base64").toString(); + const n = Number(decoded); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return "bad"; + return n; + } catch { + return "bad"; + } +}; + +/** + * Apply limit+cursor pagination to an array of items. + * + * @param items - The full (pre-filtered) array. + * @param limit - Number of items per page (already clamped by caller). + * @param offset - Starting index decoded from the cursor (0 when absent). + * @returns The page slice and a `nextCursor` (base64-encoded next offset, + * or `null` when the collection is exhausted). + */ +const paginate = ( + items: T[], + limit: number, + offset: number +): { page: T[]; nextCursor: string | null } => { + const page = items.slice(offset, offset + limit); + const nextOffset = offset + limit; + const nextCursor = nextOffset < items.length + ? Buffer.from(String(nextOffset)).toString("base64") + : null; + return { page, nextCursor }; +}; + + +/** + * Decode a base64-encoded cursor string into an integer offset. + * + * Returns the decoded offset on success, or `"bad"` when the cursor is + * present but malformed (non-base64, non-integer, or negative). Returns + * `undefined` when no cursor was supplied so the caller can distinguish + * "first page" from an explicit bad value. + * + * @param raw - The raw `req.query.cursor` value (string, string[], or undefined). + * @returns The decoded integer offset, `"bad"` for a malformed cursor, or + * `undefined` when the param is absent. + */ +const parseCursor = (raw: unknown): number | "bad" | undefined => { + if (raw === undefined) return undefined; + if (typeof raw !== "string" || raw.trim() === "") return "bad"; + try { + const decoded = Buffer.from(raw, "base64").toString(); + const n = Number(decoded); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return "bad"; + return n; + } catch { + return "bad"; + } +}; + +/** + * Apply limit+cursor pagination to an array of items. + * + * @param items - The full (pre-filtered) array. + * @param limit - Number of items per page (already clamped by caller). + * @param offset - Starting index decoded from the cursor (0 when absent). + * @returns The page slice and a `nextCursor` (base64-encoded next offset, + * or `null` when the collection is exhausted). + */ +const paginate = ( + items: T[], + limit: number, + offset: number +): { page: T[]; nextCursor: string | null } => { + const page = items.slice(offset, offset + limit); + const nextOffset = offset + limit; + const nextCursor = nextOffset < items.length + ? Buffer.from(String(nextOffset)).toString("base64") + : null; + return { page, nextCursor }; +}; + +app.get("/api/v1/events", (req: Request, res: Response) => { + // `since` must be a single, non-negative integer. Array-form or non-numeric + // values are rejected rather than coerced to NaN (which would silently + // return zero events). + const since = parseIntegerQueryParam(req.query.since, 0); + if (since === null || since < 0) { + sendError(res, req, 400, "invalid_request", "since must be a non-negative integer"); + return; + } + + // `limit` must be a single numeric value; it is then clamped to [1, EVENT_LOG_CAP]. + const rawLimit = parseIntegerQueryParam(req.query.limit, 100); + if (rawLimit === null) { + sendError(res, req, 400, "invalid_request", "limit must be a single integer"); + return; + } + const limit = Math.min(EVENT_LOG_CAP, Math.max(1, rawLimit)); + + // Optional type filter: when present, must be one of the known event types. + const typeParam = req.query.type; + if (typeParam !== undefined) { + if ( + typeof typeParam !== "string" || + !(KNOWN_EVENT_TYPES as ReadonlyArray).includes(typeParam) + ) { + sendError( + res, + req, + 400, + "invalid_request", + `type must be one of: ${KNOWN_EVENT_TYPES.join(", ")}` + ); + return; + } + } + + // Parse cursor (base64-encoded offset). + const cursorResult = parseCursor(req.query.cursor); + if (cursorResult === "bad") { + sendError(res, req, 400, "invalid_request", "cursor is invalid"); + return; + } + const offset = cursorResult ?? 0; + + let items = eventLog.filter((e) => e.ts >= since); + if (typeParam !== undefined) { + items = items.filter((e) => e.type === (typeParam as EventType)); + } + const { page, nextCursor } = paginate(items, limit, offset); + res.json({ items: page, nextCursor }); +}); + +/** + * Fixed catalog of authorization scopes an API key may carry. A key's scopes + * are a subset of this set; unknown scope strings are rejected at creation. + */ +const SCOPE_CATALOG = ["pairs:write", "webhooks:write", "keys:admin"] as const; + +/** + * Least-privilege default scope set applied when a key is created without an + * explicit `scopes` array. Read-only: it grants no write scope. + */ +const DEFAULT_SCOPES: readonly string[] = []; + +/** + * Returns `true` when the API key record should be accepted for + * authentication, `false` when it should be rejected. + * + * A key is considered invalid when: + * - Its explicit `expiresAt` deadline has passed (hard expiry set at creation). + * - It is a rotated predecessor whose grace window has expired. + */ +const isKeyValid = (record: ApiKeyRecord): boolean => { + if (record.expiresAt !== undefined && Date.now() > record.expiresAt) return false; + if (record.graceExpiresAt !== undefined && record.rotatedAt !== undefined) { + // Rotated predecessor: still valid until grace window expires + return Date.now() <= record.graceExpiresAt; + } + return true; +}; + +/** + * Express middleware factory asserting that the authenticated API key carries + * the given scope. + * + * The key is resolved from the `Authorization: Bearer ` header. When + * the key is missing or unknown the guard responds `401 unauthorized`; when the + * key exists but lacks `scope` it responds `403 forbidden`, both using the + * canonical `sendError` envelope. On success it calls `next()`. + * + * @param scope - The scope string (from {@link SCOPE_CATALOG}) the route requires. + * @returns An Express request handler enforcing the scope. + */ +const isKeyValid = (record: ApiKeyRecord): boolean => { + if (record.expiresAt !== undefined && Date.now() > record.expiresAt) return false; + if (record.graceExpiresAt !== undefined && record.rotatedAt !== undefined) { + return Date.now() <= record.graceExpiresAt; + } + return true; +}; + +const requireScope = (scope: string) => + (req: Request, res: Response, next: NextFunction): void => { + const auth = req.header("authorization") ?? ""; + const match = /^Bearer\s+(\S+)$/i.exec(auth); + const rawKey = match ? match[1] : undefined; + const record = rawKey ? apiKeyStore.get(rawKey) : undefined; + if (!record) { + sendError(res, req, 401, "unauthorized", "a valid API key is required"); + return; + } + if (!(record.scopes ?? []).includes(scope)) { + sendError(res, req, 403, "forbidden", `this key is missing the required scope: ${scope}`); + return; + } + apiKeyStore.set(rawKey!, { ...record, lastUsedAt: Date.now() }); + next(); + }; + +app.delete("/api/v1/api-keys/:prefix", (req: Request, res: Response) => { + const { prefix } = req.params; + let found: string | undefined; + for (const k of apiKeyStore.keys()) if (k.slice(0, 8) === prefix) { found = k; break; } + if (!found) { + sendError(res, req, 404, "not_found", `no key with prefix ${prefix}`); + return; + } + apiKeyStore.delete(found); + recordEvent("apikey.deleted", { prefix }); + res.status(204).send(); +}); + +app.get("/api/v1/api-keys", (req: Request, res: Response) => { + const rawLimit = parseIntegerQueryParam(req.query.limit, 100); + if (rawLimit === null) { + sendError(res, req, 400, "invalid_request", "limit must be a single integer"); + return; + } + const limit = Math.min(500, Math.max(1, rawLimit)); + + const cursorResult = parseCursor(req.query.cursor); + if (cursorResult === "bad") { + sendError(res, req, 400, "invalid_request", "cursor is invalid"); + return; + } + const offset = cursorResult ?? 0; + + const allItems = Array.from(apiKeyStore.entries()).map(([k, m]) => ({ + prefix: k.slice(0, 8), + label: m.label, + createdAt: m.createdAt, + // Surface rotation metadata for predecessor records (omitted when absent). + ...(m.rotatedAt !== undefined ? { rotatedAt: m.rotatedAt } : {}), + ...(m.expiresAt !== undefined ? { expiresAt: m.expiresAt } : {}), + ...(m.lastUsedAt !== undefined ? { lastUsedAt: m.lastUsedAt } : {}), + })); + const { page, nextCursor } = paginate(allItems, limit, offset); + res.json({ items: page, nextCursor }); +}); + +app.post("/api/v1/api-keys", (req: Request, res: Response) => { + const { label, scopes, expiresInSeconds } = req.body ?? {}; + if (typeof label !== "string" || label.length === 0 || label.length > 64) { + sendError(res, req, 400, "invalid_request", "label must be 1-64 chars"); + return; + } + let grantedScopes: string[] = [...DEFAULT_SCOPES]; + if (scopes !== undefined) { + if (!Array.isArray(scopes) || scopes.some((s) => typeof s !== "string")) { + sendError(res, req, 400, "invalid_request", "scopes must be a string array"); + return; + } + const unknown = (scopes as string[]).filter( + (s) => !(SCOPE_CATALOG as ReadonlyArray).includes(s) + ); + if (unknown.length > 0) { + sendError( + res, + req, + 400, + "invalid_request", + `unknown scope(s): ${unknown.join(", ")}. Known scopes: ${SCOPE_CATALOG.join(", ")}` + ); + return; + } + grantedScopes = [...new Set(scopes as string[])]; + } + let expiresAt: number | undefined; + if (expiresInSeconds !== undefined) { + if ( + typeof expiresInSeconds !== "number" || + !Number.isInteger(expiresInSeconds) || + expiresInSeconds <= 0 || + expiresInSeconds > 31_536_000 + ) { + sendError(res, req, 400, "invalid_request", "expiresInSeconds must be a positive integer no greater than 31536000"); + return; + } + expiresAt = Date.now() + expiresInSeconds * 1000; + } + const key = `srk_${randomUUID().replace(/-/g, "")}`; + apiKeyStore.set(key, { label, createdAt: Date.now(), scopes: grantedScopes }); + // Record only the non-sensitive prefix and label — never the raw key. + recordEvent("apikey.created", { prefix: key.slice(0, 8), label }); + res.status(201).json({ key, label, ...(expiresAt !== undefined ? { expiresAt } : {}) }); +}); + +/** + * Grace window (ms) during which a rotated predecessor key remains valid + * alongside its successor, giving in-flight callers time to cut over without + * downtime. Defaults to one hour. + */ +const ROTATION_GRACE_MS = 60 * 60 * 1000; + +/** + * Rotate an API key: mint a successor inheriting the predecessor's label and + * schedule the predecessor for grace expiry. + * + * Locates the key by its 8-char prefix, creates a new `srk_` key with the same + * `label`, and stamps the predecessor with `rotatedAt` (now) and + * `graceExpiresAt` (now + {@link ROTATION_GRACE_MS}) so both keys work during + * the overlap. The new raw key is returned exactly once with `201`; it is never + * logged. Returns `404 not_found` for an unknown prefix. + * + * @route POST /api/v1/api-keys/:prefix/rotate + */ +app.post("/api/v1/api-keys/:prefix/rotate", (req: Request, res: Response) => { + const { prefix } = req.params; + let found: string | undefined; + for (const k of apiKeyStore.keys()) if (k.slice(0, 8) === prefix) { found = k; break; } + const predecessor = found ? apiKeyStore.get(found) : undefined; + if (!found || !predecessor) { + sendError(res, req, 404, "not_found", `no key with prefix ${prefix}`); + return; + } + const now = Date.now(); + // Stamp the predecessor with rotation metadata; it stays valid until grace expiry. + apiKeyStore.set(found, { + ...predecessor, + rotatedAt: now, + graceExpiresAt: now + ROTATION_GRACE_MS, + }); + // Mint the successor, inheriting the label and scopes. + const newKey = `srk_${randomUUID().replace(/-/g, "")}`; + apiKeyStore.set(newKey, { label: predecessor.label, createdAt: now, scopes: predecessor.scopes }); + res.status(201).json({ key: newKey, label: predecessor.label, graceExpiresAt: now + ROTATION_GRACE_MS }); +}); + +app.delete("/api/v1/webhooks/:id", (req: Request, res: Response) => { + const { id } = req.params; + if (!webhookStore.has(id)) { + sendError(res, req, 404, "not_found", `webhook ${id} not found`); + return; + } + webhookStore.delete(id); + recordEvent("webhook.deleted", { id }); + res.status(204).send(); +}); + +app.get("/api/v1/webhooks", (req: Request, res: Response) => { + const rawLimit = parseIntegerQueryParam(req.query.limit, 100); + if (rawLimit === null) { + sendError(res, req, 400, "invalid_request", "limit must be a single integer"); + return; + } + const limit = Math.min(500, Math.max(1, rawLimit)); + + const cursorResult = parseCursor(req.query.cursor); + if (cursorResult === "bad") { + sendError(res, req, 400, "invalid_request", "cursor is invalid"); + return; + } + const offset = cursorResult ?? 0; + + const allItems = Array.from(webhookStore.entries()).map(([id, m]) => ({ id, ...m })); + const { page, nextCursor } = paginate(allItems, limit, offset); + res.json({ items: page, nextCursor }); +}); + +/** Maximum number of event subscriptions per webhook registration. */ +const WEBHOOK_MAX_EVENTS = 20; + +/** Maximum character length of a single event name in a webhook subscription. */ +const WEBHOOK_MAX_EVENT_LENGTH = 128; + +/** + * Event name prefixes reserved for internal system events. + * User-defined webhook subscriptions may not use these prefixes. + */ +const WEBHOOK_RESERVED_PREFIXES = ["internal.", "system.", "admin."]; + +app.post("/api/v1/webhooks", (req: Request, res: Response) => { + if (rejectUnknownKeys(req, res, ["url", "events"])) return; + const { url, events } = req.body ?? {}; + if (typeof url !== "string" || !/^https?:\/\//.test(url) || url.length > 2048) { + sendError(res, req, 400, "invalid_request", "url must be http(s), <=2048 chars"); + return; + } + if (!Array.isArray(events) || events.length === 0 || events.some((e) => typeof e !== "string")) { + sendError(res, req, 400, "invalid_request", "events must be a non-empty string array"); + return; + } + if (events.length > WEBHOOK_MAX_EVENTS) { + sendError(res, req, 400, "invalid_request", `events may contain at most ${WEBHOOK_MAX_EVENTS} entries`); + return; + } + for (const name of events as string[]) { + if (name.trim().length === 0) { + sendError(res, req, 400, "invalid_request", "event names must not be blank or whitespace-only"); + return; + } + if (name.length > WEBHOOK_MAX_EVENT_LENGTH) { + sendError(res, req, 400, "invalid_request", `event names must be <= ${WEBHOOK_MAX_EVENT_LENGTH} chars`); + return; + } + if (WEBHOOK_RESERVED_PREFIXES.some((p) => name.startsWith(p))) { + sendError(res, req, 400, "invalid_request", `event name "${name}" uses a reserved prefix`); + return; + } + // Event names must be either "*" (wildcard) or follow the "namespace.action" + // convention (exactly one dot, alphanumeric segments). Names with multiple + // dots are rejected as they indicate a typo or unsupported format. + if (name !== "*" && !/^[a-zA-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_]+$/.test(name)) { + sendError(res, req, 400, "invalid_request", `event name "${name}" must be "*" or follow "namespace.action" format`); + return; + } + } + // Deduplicate event names before storing + const deduped = [...new Set(events as string[])]; + const id = `wh_${randomUUID().replace(/-/g, "").slice(0, 16)}`; + webhookStore.set(id, { url, events: deduped, createdAt: Date.now() }); + // Record id and url only — never any webhook secret material. + recordEvent("webhook.created", { id, url }); + res.status(201).json({ id, url, events: deduped }); +}); + +/** + * Read a single registered webhook by id. + * + * Returns `{ id, url, events, createdAt }` for a known id, or + * `404 not_found` (with the canonical `requestId` envelope) otherwise. + * + * @route GET /api/v1/webhooks/:id + */ +app.get("/api/v1/webhooks/:id", (req: Request, res: Response) => { + const { id } = req.params; + const record = webhookStore.get(id); + if (!record) { + sendError(res, req, 404, "not_found", `webhook ${id} not found`); + return; + } + res.json({ id, ...record }); +}); + +/** + * Update a registered webhook's subscribed `events` in place. + * + * The `url` is intentionally immutable on PATCH: changing the destination + * should go through delete/recreate so the SSRF-validation provenance of the + * URL is preserved. The new `events` value is validated with the same + * non-empty-string-array rule used by the create handler and deduplicated + * before being stored. Returns the updated webhook, or `404 not_found` when + * the id is unknown. + * + * @route PATCH /api/v1/webhooks/:id + */ +app.patch("/api/v1/webhooks/:id", (req: Request, res: Response) => { + const { id } = req.params; + const record = webhookStore.get(id); + if (!record) { + sendError(res, req, 404, "not_found", `webhook ${id} not found`); + return; + } + const { events } = req.body ?? {}; + if (!Array.isArray(events) || events.length === 0 || events.some((e) => typeof e !== "string")) { + sendError(res, req, 400, "invalid_request", "events must be a non-empty string array"); + return; + } + const deduped = [...new Set(events as string[])]; + // url is preserved; only events are mutated. + const updated = { ...record, events: deduped }; + webhookStore.set(id, updated); + res.json({ id, ...updated }); +}); + +/** + * Resolve the source/destination route params and the computed pair key. + * Returns null and sends a 404 if the pair is not registered. + */ +function resolvePair( + req: Request, + res: Response +): { source: string; destination: string; key: string } | null { + const { source, destination } = req.params; + const key = pairKey(source, destination); + if (!pairRegistry.has(key)) { + sendError(res, req, 404, "not_found", `pair ${source}->${destination} is not registered`); + return null; + } + return { source, destination, key }; +} + +/** + * Normalize the `:source`/`:destination` route params to their canonical asset + * codes via {@link normalizeAsset}. On invalid input a `400 invalid_request` is + * sent and `null` is returned so the caller can `return` immediately. + */ +const normalizePairParams = ( + req: Request, + res: Response +): { source: string; destination: string } | null => { + const source = normalizeAsset(req.params.source); + const destination = normalizeAsset(req.params.destination); + if (source === null || destination === null) { + sendError(res, req, 400, "invalid_request", "source and destination must be 1-12 alphanumeric characters"); + return null; + } + return { source, destination }; +}; + +/** Aggregate read of every per-pair slot in one round-trip. */ +app.get("/api/v1/pairs/:source/:destination/info", (req: Request, res: Response) => { + const normalized = normalizePairParams(req, res); + if (!normalized) return; + const { source, destination } = normalized; + const k = pairKey(source, destination); + res.json({ + source, + destination, + registered: pairRegistry.has(k), + ...(pairMeta.get(k) ?? defaultMeta()), + }); +}); + +/** + * Factory that creates an Express PATCH handler for a single `PairMeta` field. + * + * All four per-pair PATCH routes share the same flow: + * 1. Resolve the pair key from `:source` / `:destination` params. + * 2. Guard with a 404 if the pair is not registered. + * 3. Validate the inbound value with the field-specific `validate` function. + * 4. Mutate exactly the bound `field` on the stored metadata. + * 5. Respond with `{ source, destination, ...meta }`. + * + * Binding the field name at registration time means the handler can never + * accidentally mutate a different field, even if the descriptor table is + * extended in the future. + * + * @param field - The key of `PairMeta` this handler is responsible for. + * @param bodyKey - The request-body property name carrying the incoming value. + * @param validate - Returns `true` when the value is acceptable, `false` to reject. + * @param errorMessage - The `message` string sent in the 400 response body. + */ +const makePairMetaPatch = ( + field: K, + bodyKey: string, + validate: (v: unknown) => boolean, + errorMessage: string, + crossCheck?: (value: unknown, meta: PairMeta) => string | null +) => + (req: Request, res: Response): void => { + const normalized = normalizePairParams(req, res); + if (!normalized) return; + const { source, destination } = normalized; + const k = pairKey(source, destination); + if (!pairRegistry.has(k)) { + sendError(res, req, 404, "not_found", "pair not registered"); + return; + } + if (rejectUnknownKeys(req, res, [bodyKey])) return; + const value = (req.body ?? {})[bodyKey] as unknown; + if (!validate(value)) { + sendError(res, req, 400, "invalid_request", errorMessage); + return; + } + const meta = pairMeta.get(k) ?? defaultMeta(); + // Optional cross-field invariant (e.g. min <= max). Runs after the + // per-field format check so `value` is already known to be a valid + // integer string; comparisons stay in BigInt space (see crossCheck impls). + if (crossCheck) { + const crossError = crossCheck(value, meta); + if (crossError !== null) { + sendError(res, req, 400, "invalid_request", crossError); + return; + } + } + (meta as Record)[field] = value; + pairMeta.set(k, meta); + res.json({ source, destination, ...meta }); + }; + +/** + * Cross-field guard for `PATCH .../min`. + * + * Rejects a new `minAmount` that would exceed the pair's existing **non-zero** + * `maxAmount` (a `maxAmount` of `"0"` is treated as "unset" and never triggers + * the check). Both values are compared as `BigInt` to preserve precision on + * amounts above `Number.MAX_SAFE_INTEGER`; the input is never coerced through + * `Number`. + * + * @returns An error message naming both bounds when inconsistent, else `null`. + */ +const checkMinAgainstMax = (value: unknown, meta: PairMeta): string | null => { + const newMin = BigInt(value as string); + const existingMax = BigInt(meta.maxAmount); + if (existingMax !== 0n && newMin > existingMax) { + return `minAmount (${newMin}) must not exceed the current maxAmount (${existingMax})`; + } + return null; +}; + +/** + * Cross-field guard for `PATCH .../max`. + * + * Rejects a new `maxAmount` that would fall below the pair's existing + * **non-zero** `minAmount` (a `minAmount` of `"0"` is treated as "unset"). + * Comparisons are performed entirely in `BigInt` space. + * + * @returns An error message naming both bounds when inconsistent, else `null`. + */ +const checkMaxAgainstMin = (value: unknown, meta: PairMeta): string | null => { + const newMax = BigInt(value as string); + const existingMin = BigInt(meta.minAmount); + if (existingMin !== 0n && newMax < existingMin) { + return `maxAmount (${newMax}) must not be below the current minAmount (${existingMin})`; + } + return null; +}; + +/** + * Cross-field guard for `PATCH .../liquidity`. + * + * Rejects a new `liquidity` below the pair's existing **non-zero** `minAmount` + * (a `minAmount` of `"0"` is treated as "unset"; a liquidity of `"0"` is also + * treated as "unset" and never triggers the check). + * + * @returns An error message when inconsistent, else `null`. + */ +const checkLiquidityAgainstMin = (value: unknown, meta: PairMeta): string | null => { + const newLiquidity = BigInt(value as string); + if (newLiquidity === 0n) return null; // "0" means unset — always allowed + const existingMin = BigInt(meta.minAmount); + if (existingMin !== 0n && newLiquidity < existingMin) { + return `liquidity (${newLiquidity}) must not be below the current minAmount (${existingMin})`; + } + return null; +}; + +/** + * Cross-field guard for `PATCH .../min` (against liquidity). + * + * Rejects a new `minAmount` above the pair's existing **non-zero** `liquidity` + * (a `liquidity` of `"0"` is treated as "unset" and never triggers the check). + * + * @returns An error message when inconsistent, else `null`. + */ +const checkMinAgainstLiquidity = (value: unknown, meta: PairMeta): string | null => { + const newMin = BigInt(value as string); + const existingLiquidity = BigInt(meta.liquidity); + if (existingLiquidity !== 0n && newMin > existingLiquidity) { + return `minAmount (${newMin}) must not exceed the current liquidity (${existingLiquidity})`; + } + return null; +}; + +/** + * Descriptor table driving the four per-pair PATCH routes. + * Each entry maps a URL suffix to its PairMeta field, body key, validator, and + * error message — the only dimensions that differ between the four handlers. + * Reuses the same regexes / number checks as the original inline handlers. + */ +const pairMetaPatchDescriptors: Array<{ + suffix: string; + field: keyof PairMeta; + bodyKey: string; + validate: (v: unknown) => boolean; + errorMessage: string; + crossCheck?: (value: unknown, meta: PairMeta) => string | null; +}> = [ + { + suffix: "liquidity", + field: "liquidity", + bodyKey: "liquidity", + validate: (v) => typeof v === "string" && /^[0-9]{1,39}$/.test(v), + errorMessage: "liquidity must be a non-negative integer string", + crossCheck: checkLiquidityAgainstMin, + }, + { + suffix: "max", + field: "maxAmount", + bodyKey: "maxAmount", + validate: (v) => typeof v === "string" && /^[1-9][0-9]{0,38}$/.test(v), + errorMessage: "maxAmount must be a positive integer string", + crossCheck: checkMaxAgainstMin, + }, + { + suffix: "min", + field: "minAmount", + bodyKey: "minAmount", + validate: (v) => typeof v === "string" && /^[0-9]{1,39}$/.test(v), + errorMessage: "minAmount must be a non-negative integer string", + crossCheck: (value, meta) => checkMinAgainstMax(value, meta) ?? checkMinAgainstLiquidity(value, meta), + }, + { + suffix: "fee_bps", + field: "feeBps", + bodyKey: "feeBps", + validate: (v) => + typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1000, + errorMessage: "feeBps must be an integer in [0,1000]", + }, + { + suffix: "rate", + field: "rate", + bodyKey: "rate", + validate: (v) => { + if (typeof v !== "string") return false; + if (v.length > 20) return false; // bound precision + return /^[0-9]+(\.[0-9]{1,8})?$/.test(v) && parseFloat(v) > 0; + }, + errorMessage: + 'rate must be a positive decimal string (e.g. "1.0", "0.85"), max 8 decimal places', + }, +]; + +// Register each descriptor as a PATCH route. +for (const { suffix, field, bodyKey, validate, errorMessage, crossCheck } of pairMetaPatchDescriptors) { + app.patch( + `/api/v1/pairs/:source/:destination/${suffix}`, + makePairMetaPatch(field, bodyKey, validate, errorMessage, crossCheck) + ); +} + +/** + * Toggle a pair's enabled flag. + * + * Accepts `{ enabled: boolean }` and emits a `pair.enabled` or `pair.disabled` + * audit event on each toggle. Non-boolean bodies are rejected with 400. + * + * @route PATCH /api/v1/pairs/:source/:destination/enabled + */ +app.patch("/api/v1/pairs/:source/:destination/enabled", (req: Request, res: Response): void => { + const normalized = normalizePairParams(req, res); + if (!normalized) return; + const { source, destination } = normalized; + const k = pairKey(source, destination); + if (!pairRegistry.has(k)) { + sendError(res, req, 404, "not_found", "pair not registered"); + return; + } + if (rejectUnknownKeys(req, res, ["enabled"])) return; + const { enabled } = req.body ?? {}; + if (typeof enabled !== "boolean") { + sendError(res, req, 400, "invalid_request", "enabled must be a boolean"); + return; + } + const meta = pairMeta.get(k) ?? defaultMeta(); + meta.enabled = enabled; + pairMeta.set(k, meta); + recordEvent(enabled ? "pair.enabled" : "pair.disabled", { source, destination }); + res.json({ source, destination, ...meta }); +}); + +/** + * Reset a registered pair's metadata to factory defaults. + * + * Overwrites the pair's `pairMeta` entry with `defaultMeta()`, emits a + * `pair.meta.reset` audit event, and returns the fresh metadata. Blocked + * while the service is paused (non-idempotent POST). Returns 404 when the + * pair is not registered. + * + * @route POST /api/v1/pairs/:source/:destination/reset + */ +app.post("/api/v1/pairs/:source/:destination/reset", (req: Request, res: Response) => { + const { source, destination } = req.params; + const k = pairKey(source, destination); + if (!pairRegistry.has(k)) { + sendError(res, req, 404, "not_found", "pair not registered"); + return; + } + const meta = defaultMeta(); + pairMeta.set(k, meta); + recordEvent("pair.meta.reset", { source, destination }); + res.json({ source, destination, ...meta }); +}); + +/** Unregister a pair. */ +app.delete("/api/v1/pairs/:source/:destination", (req: Request, res: Response) => { + const { source, destination } = req.params; + const k = pairKey(source, destination); + if (!pairRegistry.has(k)) { + sendError(res, req, 404, "not_found", `pair ${source}->${destination} is not registered`); + return; + } + pairRegistry.delete(k); + recordEvent("pair.unregistered", { source, destination }); + res.status(204).send(); +}); + +/** Read a single registered pair. */ +app.get("/api/v1/pairs/:source/:destination", (req: Request, res: Response) => { + const { source, destination } = req.params; + if (!pairRegistry.has(pairKey(source, destination))) { + sendError(res, req, 404, "not_found", `pair ${source}->${destination} is not registered`); + return; + } + res.json({ source, destination, registered: true }); +}); + +app.get("/api/v1/admin/status", (_req: Request, res: Response) => { + res.json({ paused, readOnly }); +}); + +app.get("/api/v1/config", (_req: Request, res: Response) => res.json({ config })); +app.patch("/api/v1/config", (req: Request, res: Response) => { + const allowed = ["rateLimitPerWindow", "rateLimitWindowMs", "bulkMaxItems", "eventLogCap", "quote_ttl_ms"] as const; + if (rejectUnknownKeys(req, res, [...allowed])) return; + for (const k of allowed) { + if (k in (req.body ?? {})) { + const v = req.body[k]; + if (typeof v !== "number" || !Number.isInteger(v) || v <= 0) { + sendError(res, req, 400, "invalid_request", `${k} must be positive integer`); + return; + } + if (k === "bulkMaxItems" && v > BULK_ABSOLUTE_MAX) { + sendError(res, req, 400, "invalid_request", `bulkMaxItems cannot exceed ${BULK_ABSOLUTE_MAX}`); + return; + } + if (k === "eventLogCap" && v > EVENT_LOG_CAP_MAX) { + sendError(res, req, 400, "invalid_request", `eventLogCap cannot exceed ${EVENT_LOG_CAP_MAX}`); + return; + } + config[k] = v; + // Trim the event log immediately when the cap is lowered so that the + // buffer stays within the new bound without waiting for the next write. + if (k === "eventLogCap") trimEventLog(v); + } + } + res.json({ config }); +}); + +/** + * Escape a Prometheus label value per the exposition format rules: + * backslash → \\, double-quote → \", newline → \n. + * + * @param value - Raw label value string. + * @returns Escaped string safe for use inside double-quoted label values. + */ +const escapeLabelValue = (value: string): string => + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n"); + +/** + * Aggregate event counts from the in-memory event log. + * + * Returns a Map from event type to count. Only types present in + * {@link KNOWN_EVENT_TYPES} are included so the series set is stable. + * + * @param log - The current event log array. + * @returns Map of event type → count for each known type. + */ +const aggregateEventCounts = (log: AppEvent[]): Map => { + const counts = new Map(KNOWN_EVENT_TYPES.map((t) => [t, 0])); + for (const event of log) { + if (counts.has(event.type)) { + counts.set(event.type, (counts.get(event.type) ?? 0) + 1); + } + } + return counts; +}; + +/** + * Build the label-free store-size and config gauges exposed in `/metrics`. + * + * Each metric is a bounded, constant-cardinality gauge derived from an + * in-memory store size or the active `config`, emitted with its `# HELP` and + * `# TYPE` comment lines in Prometheus text exposition format. No labels are + * used, so scrape cardinality stays constant, and no raw secrets or URLs are + * ever included — only counts and the configured rate limit. + * + * @returns Array of exposition lines (HELP/TYPE/value triples) ready to join. + */ +const buildStoreGaugeLines = (): string[] => [ + "# HELP stableroute_api_keys_total Number of stored API keys.", + "# TYPE stableroute_api_keys_total gauge", + `stableroute_api_keys_total ${apiKeyStore.size}`, + "# HELP stableroute_webhooks_total Number of registered webhooks.", + "# TYPE stableroute_webhooks_total gauge", + `stableroute_webhooks_total ${webhookStore.size}`, + "# HELP stableroute_event_log_size Current number of entries in the event log.", + "# TYPE stableroute_event_log_size gauge", + `stableroute_event_log_size ${eventLog.length}`, + "# HELP stableroute_rate_limit_per_window Configured request limit per rate-limit window.", + "# TYPE stableroute_rate_limit_per_window gauge", + `stableroute_rate_limit_per_window ${config.rateLimitPerWindow ?? 0}`, +]; + +app.get("/api/v1/metrics", (_req: Request, res: Response) => { + const eventCounts = aggregateEventCounts(eventLog); + + const lines = [ + "# HELP stableroute_pairs_total Number of registered pairs.", + "# TYPE stableroute_pairs_total gauge", + `stableroute_pairs_total ${pairRegistry.size}`, + "# HELP stableroute_paused 1 if paused, 0 otherwise.", + "# TYPE stableroute_paused gauge", + `stableroute_paused ${paused ? 1 : 0}`, + "# HELP stableroute_events_total Total number of events in the audit log.", + "# TYPE stableroute_events_total gauge", + `stableroute_events_total ${eventLog.length}`, + "# HELP stableroute_events_by_type Count of events in the audit log per type.", + "# TYPE stableroute_events_by_type gauge", + ...Array.from(eventCounts.entries()).map( + ([type, count]) => + `stableroute_events_by_type{type="${escapeLabelValue(type)}"} ${count}` + ), + ...buildStoreGaugeLines(), + ]; + res.setHeader("Content-Type", "text/plain; version=0.0.4"); + res.send(lines.join("\n") + "\n"); +}); + +/** + * Derive per-pair aggregates from the registry and metadata maps. + * + * Computes, in a single O(n) pass over the registered pairs: + * - `pairsWithFee` — count of pairs whose stored `feeBps > 0`. + * - `distinctAssets` — number of unique asset codes appearing as either the + * source or destination of any registered pair. + * + * Side-effect free: reads only the existing in-memory stores and allocates no + * new persistent state. + * + * @returns `{ pairsWithFee, distinctAssets }`. + */ +const aggregatePairStats = (): { pairsWithFee: number; distinctAssets: number } => { + let pairsWithFee = 0; + const assets = new Set(); + for (const k of pairRegistry) { + const [source, destination] = k.split("::"); + assets.add(source); + assets.add(destination); + if ((pairMeta.get(k)?.feeBps ?? 0) > 0) pairsWithFee += 1; + } + return { pairsWithFee, distinctAssets: assets.size }; +}; + +app.get("/api/v1/stats", (_req: Request, res: Response) => { + const { pairsWithFee, distinctAssets } = aggregatePairStats(); + res.json({ + totalPairs: pairRegistry.size, + paused, + totalApiKeys: apiKeyStore.size, + totalWebhooks: webhookStore.size, + totalEvents: eventLog.length, + pairsWithFee, + distinctAssets, + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Pair registry +// ───────────────────────────────────────────────────────────────────────────── +// +// In-memory mirror of the on-chain DataKey::Pair(source, dest) set the +// router contract maintains. The settlement worker fans out from the +// contract to this Map on startup and on every pair-registration event. +// Process restart resets the map; persistence lands with the database +// adapter. +/** + * Serialize the current pair registry to a JSON string. + * Shared between the GET and HEAD handlers so the two always produce + * byte-identical output and therefore byte-identical ETags. + */ +const serializePairs = (): string => { + const pairs = Array.from(pairRegistry).map((k) => { + const [source, destination] = k.split("::"); + return { source, destination }; + }); + return JSON.stringify({ pairs }); +}; + +/** + * Compute the weak ETag for the pairs list body. + * Uses a base64-truncated SHA-1 digest, identical to the original GET handler. + * + * @param body - the already-serialized JSON string returned by serializePairs() + */ +const pairsEtag = (body: string): string => + `W/"${createHash("sha1").update(body).digest("base64").slice(0, 16)}"`; + +/** + * List every registered (source, destination) pair. + * Response: { pairs: [{ source, destination }, ...] } + */ +app.get("/api/v1/pairs", (req: Request, res: Response) => { + const rawLimit = parseIntegerQueryParam(req.query.limit, 100); + if (rawLimit === null) { + sendError(res, req, 400, "invalid_request", "limit must be a single integer"); + return; + } + const limit = Math.min(500, Math.max(1, rawLimit)); + + const cursorResult = parseCursor(req.query.cursor); + if (cursorResult === "bad") { + sendError(res, req, 400, "invalid_request", "cursor is invalid"); + return; + } + const offset = cursorResult ?? 0; + + const allPairs = Array.from(pairRegistry).map((k) => { + const [source, destination] = k.split("::"); + return { source, destination }; + }); + const { page, nextCursor } = paginate(allPairs, limit, offset); + const body = JSON.stringify({ pairs: page, nextCursor }); + const etag = pairsEtag(body); + if (req.header("if-none-match") === etag) { + res.status(304).end(); + return; + } + res.setHeader("ETag", etag); + res.type("application/json").send(body); +}); + +/** + * HEAD /api/v1/pairs + * + * Returns the same ETag, Content-Type, and Content-Length as GET but with + * no body. A well-behaved cache can use this to learn the current ETag and + * body size without transferring the full pairs list. + * + * Honors If-None-Match: responds 304 when the client's cached ETag matches, + * and 200 (empty body) otherwise. Respects the pause guard identically to GET. + */ +app.head("/api/v1/pairs", (req: Request, res: Response) => { + const body = serializePairs(); + const etag = pairsEtag(body); + if (req.header("if-none-match") === etag) { + res.status(304).end(); + return; + } + res.setHeader("ETag", etag); + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Length", Buffer.byteLength(body).toString()); + res.status(200).end(); +}); + +/** + * Register a pair (test-only / operator surface; will move behind an + * admin auth guard once the gateway lands). Body: { source, destination }. + * Returns 201 on first-write, 200 on idempotent re-write. + */ +app.post("/api/v1/pairs", idempotencyGuard, (req: Request, res: Response) => { + const { source: rawSource, destination: rawDestination } = req.body ?? {}; + const source = normalizeAsset(rawSource); + const destination = normalizeAsset(rawDestination); + if (source === null || destination === null) { + return sendError( + res, + req, + 400, + "invalid_request", + "source and destination must be 1-12 alphanumeric characters" + ); + } + if (source === destination) { + return sendError(res, req, 400, "invalid_request", "source and destination must differ"); + } + const key = pairKey(source, destination); + const isNew = !pairRegistry.has(key); + pairRegistry.add(key); + recordEvent(isNew ? "pair.registered" : "pair.refreshed", { source, destination }); + res.status(isNew ? 201 : 200).json({ source, destination, registered: true }); +}); + +/** + * Register many pairs in a single request, returning a per-item outcome. + * + * Body: `{ pairs: [{ source, destination }, ...] }` with 1–`config.bulkMaxItems` + * entries. Each item is validated independently with the same `isAssetCode` and + * same-asset rules as the single-pair endpoint; one bad item never fails the + * whole batch. A `pair.registered` / `pair.refreshed` event is recorded for + * each successfully registered item exactly as the single endpoint does. + * + * Per-item result shape: + * - success: `{ index, ok: true, source, destination, registered: true }` + * - failure: `{ index, ok: false, error }` + * + * Returns `400 invalid_request` only when the `pairs` array itself is missing, + * empty, or exceeds the configured cap. + * + * @route POST /api/v1/pairs/bulk + */ +app.post("/api/v1/pairs/bulk", (req: Request, res: Response) => { + const { pairs } = req.body ?? {}; + const maxItems = config.bulkMaxItems; + if (!Array.isArray(pairs) || pairs.length === 0 || pairs.length > maxItems) { + sendError(res, req, 400, "invalid_request", `pairs must be 1-${maxItems} entries`); + return; + } + const results = pairs.map( + (it: { source?: unknown; destination?: unknown }, index: number) => { + const { source, destination } = it ?? {}; + if (!isAssetCode(source) || !isAssetCode(destination)) { + return { index, ok: false as const, error: "invalid_asset_code" }; + } + if (source === destination) { + return { index, ok: false as const, error: "same_asset" }; + } + const key = pairKey(source, destination); + const isNew = !pairRegistry.has(key); + pairRegistry.add(key); + recordEvent(isNew ? "pair.registered" : "pair.refreshed", { source, destination }); + return { index, ok: true as const, source, destination, registered: true }; + } + ); + res.json({ results }); +}); + +// Asset symbols are short uppercase identifiers (USDC, EURC, XLM, …). +// Cap at 12 chars (Stellar's max alphanumeric asset code) and reject +// anything that is not a single string so an array param can't smuggle +// through as a "truthy" value. +// +// Codes beginning with "__health" are explicitly rejected to prevent a +// caller from registering a pair whose derived pairKey could collide with +// the deep-probe's reserved scratch namespace (HEALTH_PROBE_KEY), which +// would allow a concurrent probe delete to silently drop operator data. +const isAssetCode = (v: unknown): v is string => + typeof v === "string" && + v.length > 0 && + v.length <= 12 && + !v.startsWith("__health"); + +/** + * Canonicalize an asset code so that casing and surrounding whitespace never + * fragment a logical pair. + * + * The input is trimmed of leading/trailing whitespace and upper-cased. After + * normalization the code must be 1–12 characters (Stellar's max alphanumeric + * asset code) drawn exclusively from `[A-Z0-9]` — any internal whitespace, + * control character, or other non-alphanumeric symbol causes a rejection. The + * reserved `__health` probe namespace is also rejected. Because the length is + * checked *after* trimming, padding can never be used to bypass the 12-char cap. + * + * @param v - The raw asset code (from a body field or URL/query param). + * @returns The canonical upper-cased code, or `null` when the input is invalid. + */ +const normalizeAsset = (v: unknown): string | null => { + if (typeof v !== "string") return null; + const code = v.trim().toUpperCase(); + if (!/^[A-Z0-9]{1,12}$/.test(code) || code.startsWith("__HEALTH")) return null; + return code; +}; + +// Quote amount: a base-units integer string. Parsed via BigInt so we +// never lose precision on amounts above Number.MAX_SAFE_INTEGER. +const parseAmount = (v: unknown): bigint | null => { + if (typeof v !== "string" || !/^[1-9][0-9]{0,38}$/.test(v)) return null; + try { + const n = BigInt(v); + return n > 0n ? n : null; + } catch { + return null; + } +}; + +/** + * Compute the fee breakdown for a given amount and fee rate. + * + * Arithmetic is performed entirely with `BigInt` to preserve precision on + * amounts above `Number.MAX_SAFE_INTEGER`. Fees are rounded **down** (in + * the gateway's favour) via integer division. The resulting `netAmount` is + * always non-negative: `netAmount = amount - feeAmount`. + * + * @param amount - The gross amount in base units (must be > 0n). + * @param feeBps - Fee rate in basis points (0–1000, where 10000 bps = 100 %). + * @returns An object with `feeAmount` and `netAmount` as `bigint` values. + */ +/** + * Compute the minimum received amount after applying slippage tolerance. + * + * Uses BigInt arithmetic to preserve precision on amounts above + * Number.MAX_SAFE_INTEGER. The formula is: + * min_received = amount - floor(amount * slippageBps / 10_000) + * + * @param amount - The output amount to apply slippage against (must be > 0n). + * @param slippageBps - Slippage tolerance in basis points (0–1000). + * @returns The minimum guaranteed received amount. + */ +export const applySlippage = (amount: bigint, slippageBps: number): bigint => { + const slippageAmount = (amount * BigInt(slippageBps)) / 10_000n; + return amount - slippageAmount; +}; + +export const applyFee = ( + amount: bigint, + feeBps: number +): { feeAmount: bigint; netAmount: bigint } => { + const feeAmount = (amount * BigInt(feeBps)) / 10_000n; + const netAmount = amount - feeAmount; + return { feeAmount, netAmount }; +}; + +const computeQuoteTimes = (): { quoted_at: number; expires_at: number } => { + const ttl = (config.quote_ttl_ms ?? 30000) as number; + const quoted_at = Date.now(); + return { quoted_at, expires_at: quoted_at + ttl }; +}; + +app.post("/api/v1/quote/bulk", (req: Request, res: Response) => { + const { items } = req.body ?? {}; + const maxItems = config.bulkMaxItems; // driven by config.bulkMaxItems + if (!Array.isArray(items) || items.length === 0 || items.length > maxItems) { + sendError(res, req, 400, "invalid_request", `items must be 1-${maxItems} entries`); + return; + } + /** + * Per-item pair registration check for bulk quotes. + * + * When `ALLOW_UNREGISTERED_QUOTES` is not set to `"true"`, each item whose + * pair is not present in `pairRegistry` returns `ok: false` with + * `error: "pair_not_registered"` instead of failing the whole batch. + * Shape/validation errors (invalid asset code, same asset, bad amount) are + * still returned as `ok: false, error: "invalid_item"` and take precedence. + */ + const allowUnregistered = process.env.ALLOW_UNREGISTERED_QUOTES === "true"; + const results = items.map((it: { source_asset?: unknown; dest_asset?: unknown; amount?: unknown }, i: number) => { + const { source_asset: rawSource, dest_asset: rawDest, amount } = it ?? {}; + const source_asset = normalizeAsset(rawSource); + const dest_asset = normalizeAsset(rawDest); + if (source_asset === null || dest_asset === null || parseAmount(amount) === null || source_asset === dest_asset) { + return { index: i, ok: false as const, error: "invalid_item" }; + } + if (!allowUnregistered && !pairRegistry.has(pairKey(source_asset, dest_asset))) { + return { index: i, ok: false as const, error: "pair_not_registered" }; + } + const bulkKey = pairKey(source_asset, dest_asset); + const bulkMeta = pairMeta.get(bulkKey) ?? defaultMeta(); + if (pairRegistry.has(bulkKey) && bulkMeta.enabled === false) { + return { index: i, ok: false as const, error: "pair_disabled" }; + } + return { + index: i, + ok: true as const, + source_asset, + dest_asset, + amount: String(amount), + estimated_rate: "1.0", + slippage_bps, + min_received: min_received.toString(), + }; + }); + res.json({ results }); +}); + +app.get("/api/v1/quote", (req: Request, res: Response) => { + const { source_asset: rawSource, dest_asset: rawDest, amount, slippage_bps: rawSlippage } = req.query; + + if (!rawSource || !rawDest || !amount) { + return sendError( + res, + req, + 400, + "invalid_request", + "Missing required query params: source_asset, dest_asset, amount" + ); + } + const source_asset = normalizeAsset(rawSource); + const dest_asset = normalizeAsset(rawDest); + if (source_asset === null || dest_asset === null) { + return sendError( + res, + req, + 400, + "invalid_request", + "source_asset and dest_asset must be 1-12 alphanumeric characters" + ); + } + if (source_asset === dest_asset) { + return sendError(res, req, 400, "invalid_request", "source_asset and dest_asset must differ"); + } + const parsedAmount = parseAmount(amount); + if (parsedAmount === null) { + return sendError( + res, + req, + 400, + "invalid_request", + "amount must be a positive integer string with no leading zero" + ); + } + + /** + * Pair registration guard. + * + * Looks up the pair key in `pairRegistry` after all shape/validation checks + * pass. When the pair is not registered, responds 404 `pair_not_registered` + * with the canonical `{ error, message, requestId }` body and echoes back the + * offending `source_asset`/`dest_asset`. + * + * Set `ALLOW_UNREGISTERED_QUOTES=true` in the environment to bypass this check + * (e.g. for demos that quote arbitrary pairs). The flag defaults to off, which + * is the safe/production behavior. It can only be set at process startup and + * cannot be toggled per-request. + */ + const allowUnregistered = process.env.ALLOW_UNREGISTERED_QUOTES === "true"; + if (!allowUnregistered && !pairRegistry.has(pairKey(source_asset, dest_asset))) { + return sendError(res, req, 404, "pair_not_registered", + `pair ${source_asset}->${dest_asset} is not registered`, + { source_asset, dest_asset } + ); + } + + const meta = pairMeta.get(pairKey(source_asset, dest_asset)) ?? defaultMeta(); + const { feeAmount, netAmount } = applyFee(parsedAmount, meta.feeBps); + const min_received = applySlippage(netAmount, slippage_bps); + + res.json({ + source_asset, + dest_asset, + amount: parsedAmount.toString(), + estimated_rate: meta.rate, + route: [source_asset, dest_asset], + feeBps: meta.feeBps, + feeAmount: feeAmount.toString(), + netAmount: netAmount.toString(), + slippage_bps, + min_received: min_received.toString(), + }); +}); + +/** + * Invert the fee formula to solve for the gross input required to deliver + * exactly `output` base units after the gateway fee is deducted. + * + * The forward fee formula is: + * fee = floor(gross * feeBps / 10_000) + * output = gross - fee + * + * Rearranging: + * gross = ceil(output * 10_000 / (10_000 - feeBps)) + * + * The result is rounded **up** (ceiling division) so that applying the + * forward fee to `requiredInput` always yields at least `output` — the + * recipient is never short-changed. + * + * @param output - Target delivered amount in base units (must be > 0). + * @param feeBps - Fee in basis points in [0, 10000). + * @returns Object with `requiredInput` (gross, rounded up) and `feeAmount`. + * @throws {RangeError} if feeBps >= 10000 (100% fee leaves nothing to deliver). + */ +export const invertFee = ( + output: bigint, + feeBps: number +): { requiredInput: bigint; feeAmount: bigint } => { + if (feeBps < 0 || feeBps >= 10_000) { + throw new RangeError("feeBps must be in [0, 9999]"); + } + const denominator = BigInt(10_000 - feeBps); + const numerator = output * 10_000n; + // Ceiling division: (a + b - 1) / b + const requiredInput = (numerator + denominator - 1n) / denominator; + const feeAmount = requiredInput - output; + return { requiredInput, feeAmount }; +}; + +app.get("/api/v1/quote/reverse", (req: Request, res: Response) => { + const { source_asset, dest_asset, target_amount, output } = req.query; + + // Accept target_amount (canonical) or output (legacy alias) + const targetAmountRaw = target_amount ?? output; + + if (!source_asset || !dest_asset || !targetAmountRaw) { + return sendError( + res, + req, + 400, + "invalid_request", + "Missing required query params: source_asset, dest_asset, target_amount" + ); + } + if (!isAssetCode(source_asset) || !isAssetCode(dest_asset)) { + return sendError( + res, + req, + 400, + "invalid_request", + "source_asset and dest_asset must be 1-12 character strings" + ); + } + if (source_asset === dest_asset) { + return sendError(res, req, 400, "invalid_request", "source_asset and dest_asset must differ"); + } + const parsedOutput = parseAmount(targetAmountRaw); + if (parsedOutput === null) { + return sendError( + res, + req, + 400, + "invalid_request", + "target_amount must be a positive integer string with no leading zero" + ); + } + + const k = pairKey(source_asset, dest_asset); + const meta = pairMeta.get(k) ?? defaultMeta(); + const feeBps = meta.feeBps; + + const { requiredInput, feeAmount } = invertFee(parsedOutput, feeBps); + + res.json({ + source_asset, + dest_asset, + target_amount: parsedOutput.toString(), + required_input: requiredInput.toString(), + estimated_rate: "1.0", + route: [source_asset, dest_asset], + // legacy fields kept for backward compatibility + output: parsedOutput.toString(), + requiredInput: requiredInput.toString(), + feeAmount: feeAmount.toString(), + feeBps, + }); +}); + +// Unknown route: structured 404 echoing the request id. +app.use((req: Request, res: Response) => { + sendError(res, req, 404, "not_found", `No route for ${req.method} ${req.path}`); +}); + +// Final 4-arg error handler. Any handler that throws or calls next(err) +// lands here; the response shape is the same canonical +// { error, message, requestId } as the explicit 400 / 404 bodies so +// clients can branch on `error` uniformly. +app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => { + if (err && typeof err === "object" && "type" in err && (err as { type: string }).type === "entity.too.large") { + sendError(res, req, 413, "payload_too_large", "request body exceeds the 100 KiB limit"); + return; + } + // Malformed JSON body. express.json() raises a SyntaxError tagged with + // `type: "entity.parse.failed"`; map it to a canonical 400 client error + // instead of letting it fall through to the generic 500. The message is + // fixed so the raw parser text (which can echo fragments of the input) is + // never leaked back to the caller. + if ( + err && + typeof err === "object" && + (("type" in err && (err as { type: string }).type === "entity.parse.failed") || + err instanceof SyntaxError) + ) { + sendError(res, req, 400, "invalid_json", "request body is not valid JSON"); + return; + } + const isProduction = process.env.NODE_ENV === "production"; + if (!isProduction && err instanceof Error) { + console.error(err); + } + const message = isProduction + ? "An unexpected error occurred" + : err instanceof Error + ? err.message + : "Unexpected server error"; + sendError(res, req, 500, "internal_error", message, { + method: req.method, + path: req.path, + }); +}); + +export default app;