Skip to content
Merged
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
- name: Lint
run: npm run lint

- name: Type check
run: npm run typecheck

- name: Build
run: npm run build

Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,31 @@ Changelog
All notable changes to the AnchorNet API are documented here.

[Unreleased]
Fixed
Idempotency: the replay cache is no longer a private `Map` closed over by
each `idempotency()` call. All mounts share a process-wide
`MemoryIdempotencyStore` with a hard entry cap (default 1024; expired and
soonest-to-expire eviction), and concurrent same-key requests share one
in-flight execution so only a single handler runs. Replay still returns the
cached JSON body (not a conflict); only `status` + body are stored — no
response headers. Multi-replica sharing still waits on the separate
persistence-layer issue; this change closes the within-process holes without
adding an external dependency.
Added
Security: the metrics endpoints (GET /api/v1/metrics and
/api/v1/metrics/history) are now protected reads. When API_KEY or the new
METRICS_API_KEY is configured they require a matching x-api-key header
(401 otherwise); when neither is set they stay open, matching the existing
write-auth model. METRICS_API_KEY is a read-only credential that unlocks
metrics but not mutating routes, so a monitoring scraper needs no write
key. Metrics reads are now rate-limited per client (METRICS_RATE_LIMIT_MAX,
default 120/min) via a new opt-in limitReads flag on the rate limiter, so
the history endpoint cannot be used as an unlimited load generator.
Snapshot-history retention remains bounded to the most recent 50 entries,
now pinned by a route-level test. src/openapi.ts declares an ApiKeyAuth
security scheme and marks both metrics operations as protected. The
read-limiting is scoped to the metrics mount; global read limiting and a
shared multi-instance store remain owned by the separate rate-limiter issue.
Metrics: GET /api/v1/metrics now reports totalSettledAmount (sum of
settlement amount) and totalFeesCollected (sum of settlement fee),
computed from executed settlements only — pending settlements have
Expand Down
123 changes: 123 additions & 0 deletions PR-config-validation-typecheck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Fail-fast configuration validation + standalone `typecheck` script (#230)

Resolves **AnchorNet-Org/AnchorNet-Backend#230** (GrantFox OSS / Third Campaign).

## Summary

`package.json` had no standalone `typecheck` — types were only checked as a
side effect of `build`. More importantly, configuration failures degraded
silently: `src/middleware/apiKeyAuth.ts` makes the auth middleware a **no-op
(open access)** whenever `API_KEY` is unset, so a missing environment variable
changed the service's security posture instead of refusing to start.

This PR adds a fail-fast configuration contract (`validateConfig`) that runs at
startup **before the port binds**, a `typecheck` script wired into CI as a step
distinct from `build`, the full configuration inventory, and tests for the
required-value failure path.

## Configuration inventory

| Variable | Default | Required? | Absent behaviour |
| --- | --- | --- | --- |
| `PORT` | `3001` | optional | binds to `3001` |
| `FEE_BPS` | `10` | optional | 10 bps; validated `0–10000` |
| `API_KEY` | unset | **required in `production`**; optional in dev/test | dev/test: open access (historical). `production`: **refuses to start** naming `API_KEY` |
| `CORS_ORIGIN` | unset | optional | all origins permitted (historical default) |
| `BODY_LIMIT` | `"100kb"` | optional | 100kb JSON limit |
| `MAINTENANCE_MODE` | `false` | optional | writes allowed |
| `NODE_ENV` | `"development"` | optional | drives env-specific behaviour |
| `METRICS_SNAPSHOT_INTERVAL_MS` | unset | optional | no snapshots |
| `IDEMPOTENCY_TTL_MS` | `86_400_000` | optional | 24h window |
| `RATE_LIMIT_MAX` | `30` | optional | 30/window |
| `RATE_LIMIT_WINDOW_MS` | `60_000` | optional | 60s window |
| `TRUST_PROXY` | `false` | optional | proxy not trusted |

(Full reasoning in `docs/CONFIGURATION.md`.)

## Required-vs-optional classification

- **`API_KEY` → required in `production` only.** Its absence silently disables
auth on every mutating endpoint — a security-relevant fail-open — so it must
be present in production. In `development`/`test` the historical open access
is preserved (no secret needed for local runs).
- **Everything else → optional** with a safe default; none alter a security
control when absent. `FEE_BPS` is range-validated but still optional.

## Environment-sensitivity policy

Requirements are `NODE_ENV`-driven, never an unset variable: `production` ⇒
`API_KEY` mandatory; `development`/`test` ⇒ `API_KEY` optional. The mechanism
is explicit and centralised in `validateConfig`.

## Validation approach

**Hand-written checks in `src/config.ts` — no new dependency.** The service
ships exactly three runtime deps; a schema-validation library would be
unjustified for a twelve-value config that already has parsing helpers.
`validateConfig` is invoked from `loadConfig`, so it runs once at startup
before the server binds a port. Failures are actionable: the thrown
`ConfigValidationError` names the offending variable and explains the fix.

## Deliberate fail-open closure (called out)

The only behaviour change vs. the previous release: a `production` deployment
without `API_KEY` now **refuses to start** instead of running with open
mutating endpoints. No default was changed.

## Coordination with the `apiKeyAuth` issue

This issue owns the **general configuration contract** (fail fast on a missing
required value). The concrete authentication **policy** (when/how `API_KEY` is
enforced on routes) is owned by the separate `apiKeyAuth` issue.

## Evidence — fail-fast at startup

```text
$ NODE_ENV=production node dist/index.js
AnchorNet API failed to start: API_KEY is required when NODE_ENV=production.
Without it, mutating endpoints are open to unauthenticated access
(see src/middleware/apiKeyAuth.ts). Set API_KEY to a secret value, or run
with NODE_ENV=development for local open access.
$ echo $?
1

$ NODE_ENV=production API_KEY=secret node dist/index.js
AnchorNet API listening on http://localhost:3001 # starts normally
```

## What changed

- `src/config.ts` — added `validateConfig()` + `ConfigValidationError`; called
from `loadConfig` so validation runs before the port binds.
- `src/index.ts` — wraps startup so an invalid configuration exits non-zero
with a clear message before binding; keeps the default `app` export for
tests.
- `src/config.test.ts` — added `validateConfig` tests: production-without-API_KEY
throws (`ConfigValidationError`, names `API_KEY`), blank key treated as unset,
dev/test allow missing key, production-with-key passes.
- `package.json` — added `"typecheck": "tsc --noEmit"`.
- `.github/workflows/ci.yml` — added a distinct **Type check** step (runs
before `build`).
- `docs/CONFIGURATION.md` — full inventory, classification, and policy.

## Acceptance criteria (from #230)

- [x] PR contains the full configuration inventory with defaults and absent-value behaviour.
- [x] Each value is classified required/optional, with reasoning.
- [x] Missing required configuration causes a non-zero exit with a message naming the variable, before the port binds.
- [x] A test covers each required-value failure path.
- [x] A `typecheck` script exists and runs in CI as a separate step from `build`.
- [x] No default changed except the deliberate fail-open closure (called out).
- [x] `npm run lint`, `npm run typecheck`, `npm run build` and `npm test` all pass (494 tests, 42 suites).

## Verification

```bash
npm ci
npm run typecheck
npm run lint && npm run build && npm test
NODE_ENV=production node dist/index.js # expect non-zero exit + clear message
NODE_ENV=production API_KEY=secret node dist/index.js # expect it to listen
```

Closes #230.
74 changes: 59 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,33 @@ client-side. Each read also appends a timestamped snapshot to an in-memory
rolling history (last 50 reads).
GET /api/v1/metrics/history – the recorded metrics snapshots, oldest first
({ snapshots: [...] }); each snapshot carries the same fields as
GET /api/v1/metrics plus an ISO-8601 timestamp
GET /api/v1/metrics plus an ISO-8601 timestamp. Retention is bounded to the
most recent 50 snapshots (MAX_HISTORY in src/routes/metrics.ts); older ones
are evicted, so the response can never grow without limit.

Metrics access (protected reads). Unlike the other read endpoints, the two
metrics endpoints expose aggregate operational intelligence — participant
counts, total liquidity, settlement volume and protocol fees earned, sampled
over time. That is useful to an operator and equally useful to someone
profiling the network before targeting it, so exposing it is treated as a
deliberate decision rather than a middleware side effect:

- When neither API_KEY nor METRICS_API_KEY is set, metrics reads are open
(unchanged local/dev behaviour).
- When either key is set, GET /api/v1/metrics and GET /api/v1/metrics/history
require a matching x-api-key header and return 401 otherwise.
- A monitoring scraper should be given METRICS_API_KEY — a read-only
credential accepted for metrics but not for any mutating route — so
monitoring keeps working without handing the write key to the scraper. The
primary API_KEY is also accepted for metrics, so an operator already holding
it needs nothing extra. Example scrape:
`curl -H "x-api-key: $METRICS_API_KEY" http://localhost:3001/api/v1/metrics`
- Metrics reads (both endpoints) are rate-limited per client via
METRICS_RATE_LIMIT_MAX (default 120/min), so the history endpoint cannot be
used as a cheap load generator. This read-path limiting is scoped to the
metrics mount and owned by this change; extending rate limiting to all reads
and to a shared multi-instance store is tracked by the separate
rate-limiter issue.
Errors use a uniform envelope: { "error": { "code", "message" } }, including
malformed JSON (400) and oversized request bodies (413,
PAYLOAD_TOO_LARGE). Every response carries an x-request-id header for
Expand All @@ -150,6 +176,11 @@ from `src/middleware/rateLimiter.ts` unless overridden by configuration. When
`API_KEY` authentication is configured, the presented key identifies the
client; open deployments continue to use the client IP.

> **Operational Note (Multi-Instance Deployments):**
> Rate limiter state lives in a plain `Map` local to the middleware instance. In multi-instance deployments without sticky sessions, clients may receive N× their intended budget (where N is the number of replicas), and limits reset entirely upon instance restart.
>
> A shared distributed store (like Redis) is deliberately deferred until a broader persistence layer is introduced to the service, to avoid bloating operational requirements prematurely. However, memory growth is strictly bounded: the internal `Map` is capped at 5000 entries. When capacity is reached, it lazily prunes expired buckets before evicting the oldest entry to protect against memory-pressure attacks.

`POST /api/v1/quote` is excluded from the global limiter via `skipPaths` and
then receives its own stricter `rateLimiter({ max: 10, windowMs: 60_000 })`
instance in `src/app.ts`. That quote limiter has separate in-memory counters
Expand All @@ -160,11 +191,20 @@ message `rate limit exceeded, try again later`. Clients should treat this as a
retryable response and back off before sending the next mutating request.

Mutating requests may also send an Idempotency-Key header. The first request
for a given key/method/path combination runs normally and its response is cached; any
later request reusing the same key (within 24h) replays the original response
instead of re-running the handler, so retried requests don't double-apply
side effects (e.g. registering the same anchor twice). State is in-memory and
per-process.
for a given key/method/path combination runs normally and its JSON response is
cached; any later request reusing the same key (within the configured TTL)
replays the original response instead of re-running the handler, so retried
requests don't double-apply side effects (e.g. registering the same anchor
twice). Reusing a key with a different body returns `422 IDEMPOTENCY_KEY_REUSE`.

Cache state is a process-wide in-memory store shared by every `idempotency()`
mount (hard-capped; default 1024 entries, soonest-expiry eviction). Concurrent
same-key requests share one in-flight execution. Multi-replica deployments still
need an external shared store once the persistence layer lands — this is the
same sequencing constraint as the rate limiter.

Only status + JSON body are stored for replay; response headers are never
cached.

Walkthrough Example
To verify how the idempotency system behaves, you can perform the following walkthrough using curl.
Expand Down Expand Up @@ -213,27 +253,28 @@ x-request-id: 4a123f52-1623-429b-ba67-3d0d0d5c2eb0
"registeredAt": "2026-07-22T14:17:57.537Z",
"active": true
}
Mismatched body (Known Gap): If you reuse the same Idempotency-Key but change the request payload (e.g., modifying the name field), the server will still return the cached 201 response corresponding to the first payload. Detecting mismatched request bodies (which would ideally return a 422 error) is currently a known gap in this system.
Mismatched body: If you reuse the same Idempotency-Key but change the request
payload, the server returns `422` with code `IDEMPOTENCY_KEY_REUSE` instead of
replaying the original response.

Bash

curl -i -X POST http://localhost:3001/api/v1/anchors
-H "Content-Type: application/json"
-H "Idempotency-Key: register-anchor-xyz"
-d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}'
Response (Replayed from the original cached version):
Response:

http

HTTP/1.1 201 Created
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json; charset=utf-8
x-request-id: 184c8357-3fc3-4e2f-a87c-19042ab804fe

{
"id": "anchor-xyz",
"name": "Anchor XYZ",
"registeredAt": "2026-07-22T14:17:57.537Z",
"active": true
"error": {
"code": "IDEMPOTENCY_KEY_REUSE",
"message": "Idempotency key already used with a different request body"
}
}
The process shuts down gracefully on SIGTERM/SIGINT: it stops accepting
new connections, closes the HTTP server, marks /health/ready unready, and
Expand All @@ -252,7 +293,10 @@ The application is configured using environment variables. Every environment var
Variable Default Valid Range / Format Description
PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default.
FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range.
API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset.
API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. Also accepted for metrics reads.
METRICS_API_KEY (Unset) Any non-empty string Read-only credential for the metrics endpoints. If either this or API_KEY is set, GET /api/v1/metrics and /history require a matching x-api-key header. This key unlocks metrics only — it cannot authorize mutating requests — so a monitoring scraper can read metrics without the write key. Whitespace-only values are treated as unset.
METRICS_RATE_LIMIT_MAX 120 Positive integer Maximum metrics reads allowed per client within the metrics window. Covers reads (unlike the mutating-only global limiter) so the history endpoint is not an unlimited load generator.
METRICS_RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling window for the metrics read rate limit.
CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted.
BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank.
MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally.
Expand Down
7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ Settlement, anchor, and liquidity data are held in process-local in-memory
repositories (src/repositories/*), all extending the shared
InMemoryRepository base class.

Idempotency cache (src/middleware/idempotency.ts) follows the same sequencing:
a process-wide `MemoryIdempotencyStore` (shared across mounts, hard-capped,
in-flight coalescing) closes within-process holes without introducing Redis/DB.
Cross-replica idempotency is intentionally deferred to the persistence-layer
issue — once that store exists, swap the default `IdempotencyStore`
implementation rather than bolting on a second persistence stack here.

Persistence-Swap Risk (read before swapping any repository for a DB)
Several repositories already document that they are "swappable for a
persistent … store later" (e.g. liquidityRepository.ts). This is a forward
Expand Down
Loading
Loading