diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7357c..4ea13e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the AnchorNet API are documented here. [Unreleased] 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 diff --git a/README.md b/README.md index 7708c42..3de07f8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -252,7 +278,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. diff --git a/src/app.ts b/src/app.ts index ef61c44..8452e58 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import { errorHandler, notFoundHandler } from "./middleware/errorHandler"; import { requestLogger } from "./middleware/requestLogger"; import { requestId } from "./middleware/requestId"; import { apiKeyAuth } from "./middleware/apiKeyAuth"; +import { metricsAuth } from "./middleware/metricsAuth"; import { rateLimiter } from "./middleware/rateLimiter"; import { securityHeaders } from "./middleware/securityHeaders"; import { idempotency } from "./middleware/idempotency"; @@ -116,8 +117,24 @@ export function createApp(): Express { app.use("/api/v1/quote", quoteRouter(quotes)); app.use("/api/v1/anchors", anchorRouter(anchors, settlements)); app.use("/api/v1/settlements", settlementRouter(settlements, audit.entries)); + // Metrics expose aggregate operational data (participant counts, liquidity + // totals, settlement volume and fees over time). That is deliberately + // treated as protected rather than public: reads require authentication + // whenever a key is configured, and — unlike the global writes-only limiter + // — are rate-limited via `limitReads` so the unauthenticated-or-not history + // endpoint cannot be used as a cheap load generator. When no key is set the + // guard is a no-op, preserving open access for local/dev deployments. app.use( "/api/v1/metrics", + metricsAuth(config.apiKey, config.metricsApiKey), + rateLimiter( + { + max: config.metricsRateLimitMax, + windowMs: config.metricsRateLimitWindowMs, + limitReads: true, + }, + config.apiKey ?? config.metricsApiKey, + ), metricsRouter({ liquidity, anchors, diff --git a/src/config.test.ts b/src/config.test.ts index 1881b7b..035392e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -150,6 +150,35 @@ describe("loadConfig", () => { expect(config.rateLimitWindowMs).toBe(120000); }); + it("leaves the metrics API key unset by default", () => { + expect(loadConfig({}).metricsApiKey).toBeUndefined(); + }); + + it("reads a configured metrics API key", () => { + expect(loadConfig({ METRICS_API_KEY: "scraper" }).metricsApiKey).toBe( + "scraper", + ); + }); + + it("treats a blank metrics API key as unset", () => { + expect(loadConfig({ METRICS_API_KEY: " " }).metricsApiKey).toBeUndefined(); + }); + + it("defaults the metrics read rate limit", () => { + const config = loadConfig({}); + expect(config.metricsRateLimitMax).toBe(120); + expect(config.metricsRateLimitWindowMs).toBe(60_000); + }); + + it("reads the metrics read rate limit from the environment", () => { + const config = loadConfig({ + METRICS_RATE_LIMIT_MAX: "10", + METRICS_RATE_LIMIT_WINDOW_MS: "5000", + }); + expect(config.metricsRateLimitMax).toBe(10); + expect(config.metricsRateLimitWindowMs).toBe(5000); + }); + describe("TRUST_PROXY", () => { it('parses "true" to boolean true', () => { expect(loadConfig({ TRUST_PROXY: "true" }).trustProxy).toBe(true); diff --git a/src/config.ts b/src/config.ts index 0e34d77..237f989 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,14 @@ export interface Config { feeBps: number; /** Optional API key required for mutating requests (disabled if unset). */ apiKey?: string; + /** + * Optional read-only credential that grants access to the metrics endpoints + * (`GET /api/v1/metrics` and `/history`) without granting the write access + * carried by {@link apiKey}. Lets a monitoring scraper read operational + * metrics with a credential that cannot mutate the network. Whitespace-only + * values are treated as unset. + */ + metricsApiKey?: string; /** * Allowed CORS origins. `undefined` means no allowlist is configured and * every origin is permitted (the historical default behavior). @@ -30,6 +38,15 @@ export interface Config { rateLimitMax: number; /** Length of the rolling window, in milliseconds. */ rateLimitWindowMs: number; + /** + * Maximum metrics **reads** allowed per client within the metrics window. + * Unlike {@link rateLimitMax}, this budget covers the read-only metrics + * endpoints, which are otherwise unlimited. Defaults higher than the + * mutating limit so a polling scraper is not throttled. + */ + metricsRateLimitMax: number; + /** Length of the metrics read rate-limiting window, in milliseconds. */ + metricsRateLimitWindowMs: number; /** * Express `trust proxy` setting. When enabled behind a load balancer, * Express trusts the `X-Forwarded-For` header so `req.ip` reflects the @@ -121,6 +138,7 @@ export function loadConfig( env: Record = process.env, ): Config { const apiKey = env.API_KEY?.trim(); + const metricsApiKey = env.METRICS_API_KEY?.trim(); const feeBps = intFromEnv(env.FEE_BPS, 10); if (feeBps < MIN_FEE_BPS || feeBps > MAX_FEE_BPS) { @@ -133,6 +151,7 @@ export function loadConfig( port: intFromEnv(env.PORT, 3001), feeBps, apiKey: apiKey ? apiKey : undefined, + metricsApiKey: metricsApiKey ? metricsApiKey : undefined, corsOrigins: parseCorsOrigins(env.CORS_ORIGIN), bodyLimit: env.BODY_LIMIT?.trim() || DEFAULT_BODY_LIMIT, maintenanceMode: parseBooleanFlag(env.MAINTENANCE_MODE), @@ -143,6 +162,8 @@ export function loadConfig( idempotencyTtlMs: intFromEnv(env.IDEMPOTENCY_TTL_MS, 86_400_000), rateLimitMax: intFromEnv(env.RATE_LIMIT_MAX, 30), rateLimitWindowMs: intFromEnv(env.RATE_LIMIT_WINDOW_MS, 60_000), + metricsRateLimitMax: intFromEnv(env.METRICS_RATE_LIMIT_MAX, 120), + metricsRateLimitWindowMs: intFromEnv(env.METRICS_RATE_LIMIT_WINDOW_MS, 60_000), trustProxy: parseTrustProxy(env.TRUST_PROXY), }; } diff --git a/src/middleware/metricsAuth.test.ts b/src/middleware/metricsAuth.test.ts new file mode 100644 index 0000000..cf7ea32 --- /dev/null +++ b/src/middleware/metricsAuth.test.ts @@ -0,0 +1,116 @@ +import request from "supertest"; +import { createApp } from "../app"; + +/** + * Metrics reads are protected whenever a credential is configured. These tests + * exercise the three deployment shapes: open (no key), primary-key only, and a + * dedicated read-only metrics key alongside the write key. + */ +describe("metricsAuth", () => { + const originalApiKey = process.env.API_KEY; + const originalMetricsKey = process.env.METRICS_API_KEY; + + afterEach(() => { + if (originalApiKey === undefined) delete process.env.API_KEY; + else process.env.API_KEY = originalApiKey; + if (originalMetricsKey === undefined) delete process.env.METRICS_API_KEY; + else process.env.METRICS_API_KEY = originalMetricsKey; + }); + + describe("open access when no key is configured", () => { + beforeEach(() => { + delete process.env.API_KEY; + delete process.env.METRICS_API_KEY; + }); + + it("serves current metrics without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics"); + expect(res.status).toBe(200); + }); + + it("serves metrics history without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics/history"); + expect(res.status).toBe(200); + }); + }); + + describe("protected by the primary API key", () => { + beforeEach(() => { + process.env.API_KEY = "write-secret"; + delete process.env.METRICS_API_KEY; + }); + + it("rejects metrics reads without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics"); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("UNAUTHORIZED"); + }); + + it("rejects history reads without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics/history"); + expect(res.status).toBe(401); + }); + + it("rejects metrics reads with the wrong key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "nope"); + expect(res.status).toBe(401); + }); + + it("allows metrics reads with the primary key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "write-secret"); + expect(res.status).toBe(200); + expect(res.body.anchors).toBe(0); + }); + + it("does not trigger a snapshot when a read is rejected", async () => { + const app = createApp(); + // Rejected read must not leak data via the history side effect. + await request(app).get("/api/v1/metrics"); + const res = await request(app) + .get("/api/v1/metrics/history") + .set("x-api-key", "write-secret"); + expect(res.status).toBe(200); + expect(res.body.snapshots).toEqual([]); + }); + }); + + describe("dedicated read-only metrics key", () => { + beforeEach(() => { + process.env.API_KEY = "write-secret"; + process.env.METRICS_API_KEY = "read-only-scraper"; + }); + + it("allows metrics reads with the read-only metrics key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "read-only-scraper"); + expect(res.status).toBe(200); + }); + + it("still allows metrics reads with the primary key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "write-secret"); + expect(res.status).toBe(200); + }); + + it("does not let the read-only metrics key authorize writes", async () => { + const res = await request(createApp()) + .post("/api/v1/anchors") + .set("x-api-key", "read-only-scraper") + .send({ id: "anchorA" }); + expect(res.status).toBe(401); + }); + + it("rejects an unknown key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics/history") + .set("x-api-key", "guessed"); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/src/middleware/metricsAuth.ts b/src/middleware/metricsAuth.ts new file mode 100644 index 0000000..2fc507c --- /dev/null +++ b/src/middleware/metricsAuth.ts @@ -0,0 +1,56 @@ +/** + * Read authentication for the metrics endpoints. + * + * The aggregate metrics served by `GET /api/v1/metrics` and + * `GET /api/v1/metrics/history` — anchor and participant counts, total + * liquidity, settlement volume and protocol fees earned, sampled over time — + * describe the operational state of the network. That is business + * intelligence: valuable to an operator, and equally valuable to someone + * profiling the network before targeting it. Exposing it publicly should be a + * deliberate decision, not a side effect of the write-only `apiKeyAuth`. This + * middleware makes metrics reads authenticated by default. + * + * A request is authorized when it presents an `x-api-key` header matching + * **either**: + * - the primary {@link apiKey} (the same credential that authorizes writes), + * so an operator already holding it needs nothing new; or + * - a dedicated, read-only {@link metricsApiKey}, so a monitoring scraper can + * read metrics with a credential that cannot mutate the network. + * + * When neither key is configured the middleware is a no-op (open access), + * matching the "locked only once a key is set" model of `apiKeyAuth` and + * preserving the historical behaviour for local development and deliberately + * open deployments. + */ + +import { NextFunction, Request, Response } from "express"; +import { ApiError } from "../errors/ApiError"; + +/** + * Builds the metrics read-authentication middleware. + * + * @param apiKey Primary API key, if configured. Accepted for metrics + * reads so operators reuse a single credential. + * @param metricsApiKey Dedicated read-only metrics key, if configured. + */ +export function metricsAuth(apiKey?: string, metricsApiKey?: string) { + return (req: Request, _res: Response, next: NextFunction): void => { + // No credential configured anywhere: metrics remain openly readable. + if (!apiKey && !metricsApiKey) { + next(); + return; + } + + const presented = req.header("x-api-key"); + const matchesPrimary = apiKey !== undefined && presented === apiKey; + const matchesMetrics = + metricsApiKey !== undefined && presented === metricsApiKey; + + if (matchesPrimary || matchesMetrics) { + next(); + return; + } + + next(ApiError.unauthorized("missing or invalid API key")); + }; +} diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 6b25d14..64de864 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -41,6 +41,18 @@ export interface RateLimitOptions { * require the exclusion list to account for the mount prefix. */ skipPaths?: string[]; + /** + * When `true`, this limiter also counts read (non-mutating) requests toward + * the per-client budget. Defaults `false`, so the global limiter's + * writes-only behaviour is unchanged. + * + * This flag is enabled only for the metrics mount, whose read endpoints + * (notably `GET /history`) are otherwise unlimited. Extending read limiting + * to every route — and the shared, multi-instance store that would require — + * is deliberately left to the separate rate-limiter issue; this PR owns the + * flag and its use for metrics only. + */ + limitReads?: boolean; } export function rateLimiter( @@ -52,7 +64,7 @@ export function rateLimiter( const buckets = new Map(); return (req: Request, _res: Response, next: NextFunction): void => { - if (!MUTATING_METHODS.has(req.method)) { + if (!MUTATING_METHODS.has(req.method) && !options.limitReads) { next(); return; } diff --git a/src/openapi.test.ts b/src/openapi.test.ts index 936c76e..3236a3c 100644 --- a/src/openapi.test.ts +++ b/src/openapi.test.ts @@ -52,6 +52,35 @@ describe("openapi spec", () => { expect(history.description).toContain("totalFeesCollected"); }); + it("declares the x-api-key security scheme and marks metrics as protected", () => { + const spec = buildOpenApiSpec() as { + components?: { + securitySchemes?: Record< + string, + { type?: string; in?: string; name?: string } + >; + }; + paths: Record; + }; + + const scheme = spec.components?.securitySchemes?.ApiKeyAuth; + expect(scheme).toMatchObject({ + type: "apiKey", + in: "header", + name: "x-api-key", + }); + + expect(spec.paths["/api/v1/metrics"].get.security).toEqual([ + { ApiKeyAuth: [] }, + ]); + expect(spec.paths["/api/v1/metrics/history"].get.security).toEqual([ + { ApiKeyAuth: [] }, + ]); + expect(spec.paths["/api/v1/metrics/history"].get.description).toContain( + "50", + ); + }); + it("documents the dryRun preflight parameter on POST /api/v1/anchors/bulk", () => { const spec = buildOpenApiSpec() as { paths: Record< diff --git a/src/openapi.ts b/src/openapi.ts index f4b50b4..64e65df 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -17,6 +17,19 @@ export function buildOpenApiSpec(): Record { version: PKG_VERSION, description: "Liquidity coordination network for Stellar anchors", }, + components: { + securitySchemes: { + // Sent as the `x-api-key` request header. The same scheme carries both + // the primary write key (`API_KEY`) and the dedicated read-only metrics + // key (`METRICS_API_KEY`); which credential is required depends on the + // operation. + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "x-api-key", + }, + }, + }, paths: { "/health": { get: { summary: "Health check" }, @@ -200,7 +213,12 @@ export function buildOpenApiSpec(): Record { "totalFeesCollected (sum of settlement fee). Both value totals are computed " + "from executed settlements only — pending settlements have merely reserved " + "liquidity and cancelled ones never moved value, so neither contributes. " + - "Each read also appends a timestamped snapshot to the rolling history.", + "Each read also appends a timestamped snapshot to the rolling history. " + + "Protected: when API_KEY or METRICS_API_KEY is configured, callers must " + + "send a matching x-api-key header (a read-only METRICS_API_KEY is accepted " + + "so a scraper never needs the write key); requests are also rate-limited. " + + "When no key is set the endpoint is open, matching the write-auth model.", + security: [{ ApiKeyAuth: [] }], }, }, "/api/v1/metrics/history": { @@ -209,7 +227,10 @@ export function buildOpenApiSpec(): Record { description: "Returns { snapshots: [...] }, where each snapshot carries the same fields as " + "GET /api/v1/metrics (including totalSettledAmount and totalFeesCollected) " + - "plus an ISO-8601 timestamp.", + "plus an ISO-8601 timestamp. Retention is bounded to the most recent 50 " + + "snapshots (older ones are evicted). Same authentication and rate limiting " + + "as GET /api/v1/metrics.", + security: [{ ApiKeyAuth: [] }], }, }, }, diff --git a/src/routes/metrics.test.ts b/src/routes/metrics.test.ts index 2157124..544a0c0 100644 --- a/src/routes/metrics.test.ts +++ b/src/routes/metrics.test.ts @@ -198,6 +198,85 @@ describe("metrics route", () => { }); }); +describe("metrics history retention", () => { + it("caps the retained snapshot history at 50 entries", async () => { + const app = createApp(); + await seed(app); + + // Each read of the current metrics appends one snapshot. Drive well past + // the MAX_HISTORY = 50 bound to prove the oldest entries are evicted + // rather than accumulating without limit. + for (let i = 0; i < 60; i += 1) { + await request(app).get("/api/v1/metrics"); + } + + const res = await request(app).get("/api/v1/metrics/history"); + expect(res.status).toBe(200); + expect(res.body.snapshots).toHaveLength(50); + }); + + it("retains the most recent snapshots, dropping the oldest", async () => { + jest.useFakeTimers(); + try { + const app = createApp(); + await seed(app); + + // Take 51 snapshots at distinct, strictly increasing timestamps so the + // very first one is the single entry that must be evicted at cap. + for (let i = 0; i < 51; i += 1) { + jest.setSystemTime(new Date(2026, 0, 1, 0, 0, i)); + await request(app).get("/api/v1/metrics"); + } + + const res = await request(app).get("/api/v1/metrics/history"); + expect(res.body.snapshots).toHaveLength(50); + // The oldest (second 0) is gone; the window now starts at second 1. + expect(res.body.snapshots[0].timestamp).toBe( + new Date(2026, 0, 1, 0, 0, 1).toISOString(), + ); + expect(res.body.snapshots[49].timestamp).toBe( + new Date(2026, 0, 1, 0, 0, 50).toISOString(), + ); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe("metrics read rate limiting", () => { + const original = process.env.METRICS_RATE_LIMIT_MAX; + + afterEach(() => { + if (original === undefined) delete process.env.METRICS_RATE_LIMIT_MAX; + else process.env.METRICS_RATE_LIMIT_MAX = original; + }); + + it("rejects metrics reads over the per-client budget with 429", async () => { + process.env.METRICS_RATE_LIMIT_MAX = "3"; + const app = createApp(); + + for (let i = 0; i < 3; i += 1) { + const ok = await request(app).get("/api/v1/metrics"); + expect(ok.status).toBe(200); + } + + const blocked = await request(app).get("/api/v1/metrics"); + expect(blocked.status).toBe(429); + expect(blocked.body.error.code).toBe("RATE_LIMITED"); + }); + + it("counts history reads against the same read budget", async () => { + process.env.METRICS_RATE_LIMIT_MAX = "2"; + const app = createApp(); + + expect((await request(app).get("/api/v1/metrics")).status).toBe(200); + expect((await request(app).get("/api/v1/metrics/history")).status).toBe(200); + + const blocked = await request(app).get("/api/v1/metrics/history"); + expect(blocked.status).toBe(429); + }); +}); + describe("metrics settled-value totals", () => { it("reports zero settled amount and fees on a fresh app", async () => { const res = await request(createApp()).get("/api/v1/metrics");