diff --git a/.changeset/api-cursor-pagination.md b/.changeset/api-cursor-pagination.md new file mode 100644 index 00000000..5e07cfa5 --- /dev/null +++ b/.changeset/api-cursor-pagination.md @@ -0,0 +1,22 @@ +--- +'lens': minor +--- + +Add cursor-and-limit pagination to every list route — `GET /pools`, +`GET /pairs`, `GET /candles/:assetA/:assetB` and `GET /prices/history` — matching +the convention `GET /screener` already used. Each accepts `?cursor=` and +`?limit=` (absent limit → the route default, out-of-range values clamped, a +non-numeric limit is a 400) and now returns `nextCursor` + `hasMore` beside its +own key (`pools`, `pairs`, `candles`, `points`), so an unpaged caller still gets +a sensible first page. `GET /prices/history?format=csv` carries the same +envelope in the `X-Next-Cursor` / `X-Has-More` response headers, keeping the CSV +body the byte-stable `ts,price,volume` contract. + +Pagination is keyset-based, not offset-based: the cursor is an opaque base64 +`[sortValue, id]` and the predicate is `(sort, id) > (value, id)` (or `<` for +descending), so a row inserted while a client is paging can never shift a page +boundary and cause a duplicate or a skipped row. The shared helpers live in +`src/pagination.ts`; the per-route default/maximum page sizes are 50/200 +(`/pools`, `/pairs`), 500/5000 (`/candles`), 1000/10000 (`/prices/history`) and +20/100 (`/screener`), documented in `openapi.yaml` and exercised by a +mid-page-insert regression test. diff --git a/openapi.json b/openapi.json index 4ac5fa7c..cdcfa358 100644 --- a/openapi.json +++ b/openapi.json @@ -917,13 +917,18 @@ "$ref": "#/components/parameters/NetworkQuery" }, { - "$ref": "#/components/parameters/CursorQuery" + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned as `nextCursor` by the previous page.", + "schema": { + "type": "string" + } }, { "name": "limit", "in": "query", "required": false, - "description": "Page size. Out-of-range values are clamped into [1, 200].", "schema": { "type": "integer", "minimum": 1, @@ -1006,7 +1011,8 @@ }, "nextCursor": { "type": "string", - "nullable": true + "nullable": true, + "description": "Cursor to pass as `?cursor=` for the next page, or null on the last page." }, "hasMore": { "type": "boolean" @@ -1095,13 +1101,18 @@ } }, { - "$ref": "#/components/parameters/CursorQuery" + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned as `nextCursor` by the previous page.", + "schema": { + "type": "string" + } }, { "name": "limit", "in": "query", "required": false, - "description": "Page size. Out-of-range values are clamped into [1, 5000].", "schema": { "type": "integer", "minimum": 1, @@ -1175,7 +1186,8 @@ }, "nextCursor": { "type": "string", - "nullable": true + "nullable": true, + "description": "Cursor to pass as `?cursor=` for the next page, or null on the last page." }, "hasMore": { "type": "boolean" @@ -1291,6 +1303,26 @@ }, { "$ref": "#/components/parameters/NetworkQuery" + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned as `nextCursor` by the previous page. When `format=csv`, the cursor is returned in the `X-Next-Cursor` response header instead.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 1000 + } } ], "responses": { @@ -1390,7 +1422,8 @@ }, "nextCursor": { "type": "string", - "nullable": true + "nullable": true, + "description": "Cursor to pass as `?cursor=` for the next page, or null on the last page." }, "hasMore": { "type": "boolean" @@ -1423,13 +1456,18 @@ "$ref": "#/components/parameters/NetworkQuery" }, { - "$ref": "#/components/parameters/CursorQuery" + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor returned as `nextCursor` by the previous page.", + "schema": { + "type": "string" + } }, { "name": "limit", "in": "query", "required": false, - "description": "Page size. Out-of-range values are clamped into [1, 200].", "schema": { "type": "integer", "minimum": 1, @@ -1484,7 +1522,8 @@ }, "nextCursor": { "type": "string", - "nullable": true + "nullable": true, + "description": "Cursor to pass as `?cursor=` for the next page, or null on the last page." }, "hasMore": { "type": "boolean" @@ -3816,6 +3855,16 @@ } }, "responses": { + "Error": { + "description": "Standard error envelope", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "PaymentRequired": { "description": "Payment Required", "content": { @@ -3849,6 +3898,9 @@ "error": { "type": "string" }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, "description": { "type": "string" } @@ -3884,6 +3936,9 @@ "error": { "type": "string" }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, "message": { "type": "string" }, @@ -3934,331 +3989,43 @@ } }, "schemas": { - "LendingRateEntry": { - "type": "object", - "required": [ - "id", - "venue", - "source", - "network", - "poolId", - "asset", - "supplyApy", - "borrowApy", - "timestamp" - ], - "properties": { - "id": { - "type": "string" - }, - "venue": { - "type": "string", - "example": "blend" - }, - "source": { - "type": "string", - "example": "blend" - }, - "network": { - "type": "string", - "example": "mainnet" - }, - "poolId": { - "type": "string" - }, - "poolName": { - "type": "string", - "nullable": true - }, - "asset": { - "type": "string", - "example": "USDC" - }, - "assetAddress": { - "type": "string", - "nullable": true - }, - "supplyApy": { - "type": "number" - }, - "borrowApy": { - "type": "number" - }, - "supplyApr": { - "type": "number", - "nullable": true - }, - "borrowApr": { - "type": "number", - "nullable": true - }, - "utilization": { - "type": "number", - "nullable": true, - "description": "Borrowed / supplied as a fraction. Null for venues that publish rates only." - }, - "totalSupply": { - "type": "number", - "nullable": true - }, - "totalBorrowed": { - "type": "number", - "nullable": true - }, - "reserves": { - "type": "number", - "nullable": true - }, - "ledger": { - "type": "integer" - }, - "timestamp": { - "type": "string", - "format": "date-time" - } - } - }, - "LendingRatesResponse": { - "type": "object", - "required": [ - "network", - "rates" - ], - "properties": { - "network": { - "type": "string" - }, - "rates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LendingRateEntry" - } - } - } - }, - "InternalErrorResponse": { - "type": "object", - "required": [ - "code", - "message", - "correlationId" - ], - "properties": { - "code": { - "type": "string", - "enum": [ - "internal_error" - ], - "example": "internal_error" - }, - "message": { - "type": "string", - "example": "An internal server error occurred." - }, - "correlationId": { - "type": "string", - "example": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" - } - } - }, - "DependencyCheck": { - "type": "object", - "description": "Result of one dependency probe. `reason` is a fixed phrase, never the raw error: connection errors carry the host and port and this is a public endpoint.", - "required": [ - "status", - "latencyMs", - "reason" - ], - "properties": { - "status": { - "type": "string", - "enum": [ - "up", - "down", - "skipped" - ] - }, - "latencyMs": { - "type": "integer", - "description": "Round-trip time of the probe." - }, - "reason": { - "type": "string", - "nullable": true, - "example": "unreachable or not migrated" - } - } + "ErrorCode": { + "type": "string", + "description": "Stable machine-readable error code. Branch on this, never on `error`, which is human-readable.", + "enum": [ + "missing_parameter", + "invalid_parameter", + "bad_network", + "bad_interval", + "bad_window", + "unsupported", + "unknown_pair", + "unwatched_pair", + "pair_already_watched", + "unauthorized", + "not_found", + "upstream_unavailable", + "internal_error", + "rate_limited", + "payment_required", + "payment_invalid", + "payment_verification_failed", + "quota_exceeded" + ] }, - "VenuesResponse": { + "Error": { "type": "object", "required": [ - "network", - "window", - "excludedVenues", - "venues", - "totalVolume", - "marketShares", - "periodOverPeriodPctChange" + "error", + "code" ], - "additionalProperties": false, "properties": { - "network": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ] - }, - "window": { + "error": { "type": "string", - "enum": [ - "24h", - "7d", - "30d" - ] + "description": "Human-readable error message. Do not branch on it." }, - "excludedVenues": { - "type": "array", - "items": { - "type": "string" - } - }, - "venues": { - "type": "array", - "items": { - "type": "object", - "required": [ - "venue", - "baseVolume", - "counterVolume", - "samples", - "pairs", - "marketShare", - "periodOverPeriodPctChange", - "lastUpdated", - "ageSeconds" - ], - "additionalProperties": false, - "properties": { - "venue": { - "type": "string" - }, - "baseVolume": { - "type": "number" - }, - "counterVolume": { - "type": "number" - }, - "samples": { - "type": "integer" - }, - "pairs": { - "type": "integer" - }, - "marketShare": { - "type": "number" - }, - "periodOverPeriodPctChange": { - "type": "number" - }, - "lastUpdated": { - "type": [ - "string", - "null" - ] - }, - "ageSeconds": { - "type": [ - "integer", - "null" - ] - } - } - } - }, - "totalVolume": { - "type": "object", - "required": [ - "value", - "unit", - "reason" - ], - "additionalProperties": false, - "properties": { - "value": { - "type": [ - "number", - "null" - ] - }, - "unit": { - "type": [ - "object", - "null" - ] - }, - "reason": { - "type": [ - "string", - "null" - ] - } - } - }, - "marketShares": { - "type": "object", - "additionalProperties": false, - "properties": { - "total": { - "type": "number" - }, - "variance": { - "type": "number" - } - } - }, - "periodOverPeriodPctChange": { - "type": "number" - } - } - }, - "ReadinessReport": { - "type": "object", - "description": "Readiness of one instance. Identical on 200 and 503; `ok` and the HTTP status together say whether traffic should be routed here.", - "required": [ - "ok", - "booted", - "checks", - "checkedAt" - ], - "properties": { - "ok": { - "type": "boolean", - "description": "True only when boot finished and every checked dependency is up." - }, - "booted": { - "type": "boolean", - "description": "False while the boot sequence is still running." - }, - "checks": { - "type": "object", - "required": [ - "postgres", - "redis" - ], - "properties": { - "postgres": { - "$ref": "#/components/schemas/DependencyCheck" - }, - "redis": { - "$ref": "#/components/schemas/DependencyCheck" - } - } - }, - "checkedAt": { - "type": "string", - "format": "date-time", - "description": "When the underlying probes ran (answers may be briefly cached)." + "code": { + "$ref": "#/components/schemas/ErrorCode" } } }, diff --git a/openapi.yaml b/openapi.yaml index 2e67f48e..8b124a16 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -652,11 +652,15 @@ paths: - x402: [] parameters: - $ref: '#/components/parameters/NetworkQuery' - - $ref: '#/components/parameters/CursorQuery' + - name: cursor + in: query + required: false + description: Opaque cursor returned as `nextCursor` by the previous page. + schema: + type: string - name: limit in: query required: false - description: Page size. Out-of-range values are clamped into [1, 200]. schema: type: integer minimum: 1 @@ -705,10 +709,9 @@ paths: nextCursor: type: string nullable: true + description: Cursor to pass as `?cursor=` for the next page, or null on the last page. hasMore: type: boolean - '400': - description: Invalid limit or cursor '402': $ref: '#/components/responses/PaymentRequired' '429': @@ -772,11 +775,15 @@ paths: schema: type: string format: date-time - - $ref: '#/components/parameters/CursorQuery' + - name: cursor + in: query + required: false + description: Opaque cursor returned as `nextCursor` by the previous page. + schema: + type: string - name: limit in: query required: false - description: Page size. Out-of-range values are clamped into [1, 5000]. schema: type: integer minimum: 1 @@ -825,6 +832,7 @@ paths: nextCursor: type: string nullable: true + description: Cursor to pass as `?cursor=` for the next page, or null on the last page. hasMore: type: boolean incomplete: @@ -920,6 +928,20 @@ paths: maximum: 10000 default: 1000 - $ref: '#/components/parameters/NetworkQuery' + - name: cursor + in: query + required: false + description: Opaque cursor returned as `nextCursor` by the previous page. When `format=csv`, the cursor is returned in the `X-Next-Cursor` response header instead. + schema: + type: string + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 10000 + default: 1000 responses: '200': description: >- @@ -983,6 +1005,7 @@ paths: nextCursor: type: string nullable: true + description: Cursor to pass as `?cursor=` for the next page, or null on the last page. hasMore: type: boolean '400': @@ -1003,11 +1026,15 @@ paths: security: [] parameters: - $ref: '#/components/parameters/NetworkQuery' - - $ref: '#/components/parameters/CursorQuery' + - name: cursor + in: query + required: false + description: Opaque cursor returned as `nextCursor` by the previous page. + schema: + type: string - name: limit in: query required: false - description: Page size. Out-of-range values are clamped into [1, 200]. schema: type: integer minimum: 1 @@ -1046,10 +1073,9 @@ paths: nextCursor: type: string nullable: true + description: Cursor to pass as `?cursor=` for the next page, or null on the last page. hasMore: type: boolean - '400': - description: Invalid limit or cursor '429': $ref: '#/components/responses/TooManyRequests' post: @@ -2610,6 +2636,12 @@ components: type: string responses: + Error: + description: Standard error envelope + content: + application/json: + schema: + $ref: '#/components/schemas/Error' PaymentRequired: description: Payment Required content: @@ -2634,6 +2666,8 @@ components: type: string error: type: string + code: + $ref: '#/components/schemas/ErrorCode' description: type: string TooManyRequests: @@ -2656,6 +2690,8 @@ components: type: integer error: type: string + code: + $ref: '#/components/schemas/ErrorCode' message: type: string retryAfter: @@ -2691,194 +2727,37 @@ components: example: 'public, max-age=300' schemas: - LendingRateEntry: - type: object - required: [id, venue, source, network, poolId, asset, supplyApy, borrowApy, timestamp] - properties: - id: - type: string - venue: - type: string - example: blend - source: - type: string - example: blend - network: - type: string - example: mainnet - poolId: - type: string - poolName: - type: string - nullable: true - asset: - type: string - example: USDC - assetAddress: - type: string - nullable: true - supplyApy: - type: number - borrowApy: - type: number - supplyApr: - type: number - nullable: true - borrowApr: - type: number - nullable: true - utilization: - type: number - nullable: true - description: Borrowed / supplied as a fraction. Null for venues that publish rates only. - totalSupply: - type: number - nullable: true - totalBorrowed: - type: number - nullable: true - reserves: - type: number - nullable: true - ledger: - type: integer - timestamp: - type: string - format: date-time - - LendingRatesResponse: + ErrorCode: + type: string + description: Stable machine-readable error code. Branch on this, never on `error`, which is human-readable. + enum: + - missing_parameter + - invalid_parameter + - bad_network + - bad_interval + - bad_window + - unsupported + - unknown_pair + - unwatched_pair + - pair_already_watched + - unauthorized + - not_found + - upstream_unavailable + - internal_error + - rate_limited + - payment_required + - payment_invalid + - payment_verification_failed + - quota_exceeded + Error: type: object - required: [network, rates] + required: [error, code] properties: - network: + error: type: string - rates: - type: array - items: - $ref: '#/components/schemas/LendingRateEntry' - - InternalErrorResponse: - type: object - required: [code, message, correlationId] - properties: + description: Human-readable error message. Do not branch on it. code: - type: string - enum: [internal_error] - example: internal_error - message: - type: string - example: An internal server error occurred. - correlationId: - type: string - example: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d - - DependencyCheck: - type: object - description: >- - Result of one dependency probe. `reason` is a fixed phrase, never the - raw error: connection errors carry the host and port and this is a - public endpoint. - required: [status, latencyMs, reason] - properties: - status: - type: string - enum: [up, down, skipped] - latencyMs: - type: integer - description: Round-trip time of the probe. - reason: - type: string - nullable: true - example: unreachable or not migrated - - VenuesResponse: - type: object - required: [network, window, excludedVenues, venues, totalVolume, marketShares, periodOverPeriodPctChange] - additionalProperties: false - properties: - network: - type: string - enum: [testnet, mainnet] - window: - type: string - enum: [24h, 7d, 30d] - excludedVenues: - type: array - items: - type: string - venues: - type: array - items: - type: object - required: [venue, baseVolume, counterVolume, samples, pairs, marketShare, periodOverPeriodPctChange, lastUpdated, ageSeconds] - additionalProperties: false - properties: - venue: - type: string - baseVolume: - type: number - counterVolume: - type: number - samples: - type: integer - pairs: - type: integer - marketShare: - type: number - periodOverPeriodPctChange: - type: number - lastUpdated: - type: [string, 'null'] - ageSeconds: - type: [integer, 'null'] - totalVolume: - type: object - required: [value, unit, reason] - additionalProperties: false - properties: - value: - type: [number, 'null'] - unit: - type: [object, 'null'] - reason: - type: [string, 'null'] - marketShares: - type: object - additionalProperties: false - properties: - total: - type: number - variance: - type: number - periodOverPeriodPctChange: - type: number - - ReadinessReport: - type: object - description: >- - Readiness of one instance. Identical on 200 and 503; `ok` and the HTTP - status together say whether traffic should be routed here. - required: [ok, booted, checks, checkedAt] - properties: - ok: - type: boolean - description: True only when boot finished and every checked dependency is up. - booted: - type: boolean - description: False while the boot sequence is still running. - checks: - type: object - required: [postgres, redis] - properties: - postgres: - $ref: '#/components/schemas/DependencyCheck' - redis: - $ref: '#/components/schemas/DependencyCheck' - checkedAt: - type: string - format: date-time - description: When the underlying probes ran (answers may be briefly cached). - + $ref: '#/components/schemas/ErrorCode' Asset: type: object properties: diff --git a/src/__tests__/benchmark.test.ts b/src/__tests__/benchmark.test.ts index 575099e8..defa1486 100644 --- a/src/__tests__/benchmark.test.ts +++ b/src/__tests__/benchmark.test.ts @@ -37,7 +37,7 @@ describe('GET /benchmark/:asset', () => { }) expect(res.statusCode).toBe(404) - expect(res.json()).toEqual({ error: 'Pair USDC/USD not watched' }) + expect(res.json()).toEqual({ error: 'Pair USDC/USD not watched', code: 'unwatched_pair' }) }) it('calculates benchmark deviation correctly when asset is assetB in the pair (e.g. USD/USDC)', async () => { diff --git a/src/__tests__/errorCodes.test.ts b/src/__tests__/errorCodes.test.ts new file mode 100644 index 00000000..4b4e5db4 --- /dev/null +++ b/src/__tests__/errorCodes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { readdirSync } from 'node:fs' +import YAML from 'js-yaml' +import { ERROR_CODES, ERROR_CODE_VALUES } from '../errors' + +function readSpec(): any { + const specPath = fileURLToPath(new URL('../../openapi.yaml', import.meta.url)) + const spec = YAML.load(readFileSync(specPath, 'utf8')) + return spec +} + +function collectCodesFromFile(filePath: string): string[] { + const content = readFileSync(filePath, 'utf8') + const codes: string[] = [] + const re1 = /apiError\(\s*['"]([a-z0-9_]+)['"]/g + let m: RegExpExecArray | null + while ((m = re1.exec(content))) { + codes.push(m[1]) + } + const re2 = /\bcode:\s*['"]([a-z][a-z0-9_]*)['"]/g + while ((m = re2.exec(content))) { + codes.push(m[1]) + } + return codes +} + +function getModuleFiles(): string[] { + const modules: string[] = [] + const routesDir = fileURLToPath(new URL('../routes', import.meta.url)) + for (const f of readdirSync(routesDir)) { + if (f.endsWith('.ts')) modules.push(fileURLToPath(new URL(`../routes/${f}`, import.meta.url))) + } + const apiFiles = ['rest.ts', 'history.ts', 'admin.ts', 'auth.ts', 'usage.ts'].map(f => + fileURLToPath(new URL(`../api/${f}`, import.meta.url)), + ) + modules.push(...apiFiles) + modules.push(fileURLToPath(new URL('../middleware/network.ts', import.meta.url))) + modules.push(fileURLToPath(new URL('../middleware/x402.ts', import.meta.url))) + modules.push(fileURLToPath(new URL('../app.ts', import.meta.url))) + return modules +} + +describe('error codes', () => { + it('documents the error code enum in openapi and keeps it in sync with ERROR_CODES', () => { + const spec = readSpec() + const enumVals = spec.components?.schemas?.ErrorCode?.enum as string[] | undefined + expect(enumVals).toBeDefined() + const expected = Object.keys(ERROR_CODES).sort() + const actual = [...(enumVals || [])].sort() + expect(actual).toEqual(expected) + }) + + it('no route/error-emitting module emits a code outside the enumerated set', () => { + const allowed = new Set(Object.keys(ERROR_CODES)) + const modules = getModuleFiles() + const all = modules.flatMap(collectCodesFromFile) + const bad = all.filter(c => !allowed.has(c)) + expect(bad, `Codes outside set: ${JSON.stringify(bad)}`).toEqual([]) + }) + + it('error code set is non-empty and matches values', () => { + expect(ERROR_CODE_VALUES.length).toBeGreaterThan(0) + expect(Object.keys(ERROR_CODES).length).toBe(ERROR_CODE_VALUES.length) + }) +}) diff --git a/src/__tests__/pagination.test.ts b/src/__tests__/pagination.test.ts index 101c5918..34afd5f3 100644 --- a/src/__tests__/pagination.test.ts +++ b/src/__tests__/pagination.test.ts @@ -1,34 +1,13 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import Fastify from 'fastify' -const { mockQuery, mockGetActivePairs } = vi.hoisted(() => ({ - mockQuery: vi.fn(), - mockGetActivePairs: vi.fn(), -})) +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) vi.mock('../db', () => ({ prisma: {}, pgPool: { query: mockQuery }, })) -vi.mock('../redis', () => ({ - getCachedPrice: vi.fn(), - setCachedPrice: vi.fn(), -})) - -vi.mock('../aggregator/bestRoute', () => ({ - getBestRoute: vi.fn(), -})) - -vi.mock('../pairsRegistry', () => ({ - getActivePairs: mockGetActivePairs, - hasPair: vi.fn(), - registerPair: vi.fn(), - persistPair: vi.fn(), - parseAssetStr: vi.fn(), - makePairKey: vi.fn(), -})) - import { parseLimit, encodeCursor, @@ -38,216 +17,252 @@ import { SCREENER_MAX_LIMIT, POOLS_DEFAULT_LIMIT, POOLS_MAX_LIMIT, + PAIRS_DEFAULT_LIMIT, + PAIRS_MAX_LIMIT, + CANDLES_DEFAULT_LIMIT, + CANDLES_MAX_LIMIT, + HISTORY_DEFAULT_LIMIT, } from '../pagination' -import { registerRESTRoutes } from '../api/rest' -import { registerPairsRoutes } from '../routes/pairs' +import { registerNetworkSelector } from '../middleware/network' +import { registerHistoryRoutes, MAX_HISTORY_POINTS } from '../api/history' +import { registerCandleRoutes } from '../routes/candles' + +describe('pagination helpers', () => { + describe('parseLimit', () => { + it('falls back to the route default when omitted', () => { + expect(parseLimit(undefined, 50, 200)).toEqual({ limit: 50 }) + }) -describe('parseLimit', () => { - it('returns the default when the parameter is absent', () => { - expect(parseLimit(undefined, 20, 100)).toEqual({ limit: 20 }) - }) + it('clamps out-of-range values into [1, max]', () => { + expect(parseLimit('0', 50, 200)).toEqual({ limit: 1 }) + expect(parseLimit('-3', 50, 200)).toEqual({ limit: 1 }) + expect(parseLimit('9999', 50, 200)).toEqual({ limit: 200 }) + }) - it('clamps out-of-range values into [1, max]', () => { - expect(parseLimit('0', 20, 100)).toEqual({ limit: 1 }) - expect(parseLimit('-5', 20, 100)).toEqual({ limit: 1 }) - expect(parseLimit('1000', 20, 100)).toEqual({ limit: 100 }) - }) + it('rejects a non-integer limit rather than silently clamping it', () => { + expect(parseLimit('lots', 50, 200)).toEqual({ error: 'limit must be a valid integer' }) + }) - it('accepts a limit inside the range', () => { - expect(parseLimit('42', 20, 100)).toEqual({ limit: 42 }) - expect(parseLimit('1', 50, 200)).toEqual({ limit: 1 }) - expect(parseLimit('200', 50, 200)).toEqual({ limit: 200 }) + it('exposes each route default and maximum', () => { + expect([SCREENER_DEFAULT_LIMIT, SCREENER_MAX_LIMIT]).toEqual([20, 100]) + expect([POOLS_DEFAULT_LIMIT, POOLS_MAX_LIMIT]).toEqual([50, 200]) + expect([PAIRS_DEFAULT_LIMIT, PAIRS_MAX_LIMIT]).toEqual([50, 200]) + expect([CANDLES_DEFAULT_LIMIT, CANDLES_MAX_LIMIT]).toEqual([500, 5000]) + expect(HISTORY_DEFAULT_LIMIT).toBe(1000) + expect(MAX_HISTORY_POINTS).toBe(10_000) + }) }) - it('rejects a non-integer limit with the screener error message', () => { - expect(parseLimit('abc', 20, 100)).toEqual({ error: 'limit must be a valid integer' }) - expect(parseLimit('', 20, 100)).toEqual({ error: 'limit must be a valid integer' }) - }) -}) + describe('encodeCursor / decodeCursor', () => { + it('round-trips a [value, id] tuple', () => { + const cursor = encodeCursor(20, 'B/X') + expect(decodeCursor(cursor)).toEqual({ value: 20, id: 'B/X' }) + }) -describe('encodeCursor / decodeCursor', () => { - it('round-trips a [value, id] payload through base64 JSON', () => { - const cursor = encodeCursor(20, 'B/X') - expect(decodeCursor(cursor)).toEqual({ value: 20, id: 'B/X' }) - }) + it('returns null for a cursor that is not base64 JSON', () => { + expect(decodeCursor('not-base64-json')).toBeNull() + }) - it('is byte-identical to screener\'s historical cursor encoding', () => { - expect(encodeCursor(20, 'B/X')).toBe(Buffer.from(JSON.stringify([20, 'B/X'])).toString('base64')) + it("returns 'skip' for a well-formed but degenerate cursor", () => { + expect(decodeCursor(encodeCursor(null, 'B/X'))).toBe('skip') + expect(decodeCursor(encodeCursor(20, ''))).toBe('skip') + }) }) - it('returns null for malformed cursors so the route answers 400', () => { - expect(decodeCursor('not-base64-json')).toBeNull() - expect(decodeCursor(Buffer.from('null').toString('base64'))).toBeNull() - expect(decodeCursor(Buffer.from('{"a":1}').toString('base64'))).toBeNull() - expect(decodeCursor(Buffer.from('123').toString('base64'))).toBeNull() - }) + describe('slicePage', () => { + it('trims a limit+1 fetch and reports hasMore', () => { + expect(slicePage([1, 2, 3], 2)).toEqual({ data: [1, 2], hasMore: true }) + }) - it("returns 'skip' for a well-formed but degenerate cursor", () => { - expect(decodeCursor(Buffer.from('[null, "B/X"]').toString('base64'))).toBe('skip') - expect(decodeCursor(Buffer.from('[20, ""]').toString('base64'))).toBe('skip') - expect(decodeCursor(Buffer.from('[1]').toString('base64'))).toBe('skip') + it('keeps the whole page when it did not overflow', () => { + expect(slicePage([1, 2], 2)).toEqual({ data: [1, 2], hasMore: false }) + }) }) }) -describe('slicePage', () => { - it('reports hasMore and trims the probe row', () => { - expect(slicePage([1, 2, 3], 2)).toEqual({ data: [1, 2], hasMore: true }) - }) - - it('keeps every row when the probe found no extra row', () => { - expect(slicePage([1, 2], 2)).toEqual({ data: [1, 2], hasMore: false }) - expect(slicePage([], 2)).toEqual({ data: [], hasMore: false }) +describe('keyset paging survives a row inserted mid-page', () => { + // A miniature of a route's keyset query: strictly-after-the-cursor, descending + // by nothing, ascending by id — the same shape every list route uses. + const fetchAfter = (rows: number[], after: number | null) => + rows.filter(id => after === null || id > after) + + it('never duplicates or skips the original rows when one is inserted behind the cursor', () => { + const rows = [1, 2, 3, 4, 5] + const limit = 2 + const seen: number[] = [] + let cursor: string | null = null + let page = 0 + + while (true) { + const decoded = cursor ? decodeCursor(cursor) : null + const after = decoded && decoded !== 'skip' ? Number(decoded.value) : null + const { data, hasMore } = slicePage(fetchAfter(rows, after), limit) + seen.push(...data) + page++ + + // Insert a row between pages, behind where the cursor already points. + // An OFFSET-based page would shift and re-serve row 2 (or skip row 3). + if (page === 1) rows.push(1.5) + + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + cursor = encodeCursor(last, last) + } else { + break + } + } + + expect(seen).toEqual([1, 2, 3, 4, 5]) }) }) -/** - * The mid-page-insert acceptance tests below run against the real route - * handlers with a mutable fake table standing in for Postgres. The mock - * honours the same contract the SQL does — keyset filter, ascending order, - * LIMIT — so a row inserted between page fetches lands exactly where a real - * insert would, and an offset implementation would demonstrably return a - * duplicate. - */ - -async function buildPoolsApp() { - const app = Fastify({ logger: false }) - await registerRESTRoutes(app) - await app.ready() - return app -} - -describe('GET /pools cursor pagination', () => { - let table: { pool_id: string }[] +describe('GET /prices/history pagination', () => { + const T0 = new Date('2025-01-01T00:00:00Z') + const minutes = (n: number) => new Date(T0.getTime() + n * 60_000) + + let buckets: Date[] beforeEach(() => { - table = [{ pool_id: 'A' }, { pool_id: 'B' }, { pool_id: 'C' }] - // Contract of the route's SQL: params are [network, (cursor pool_id)?, - // limit + 1]; rows come back ordered by pool_id with the keyset applied. - mockQuery.mockImplementation(async (_sql: string, params: unknown[]) => { - const limitPlus1 = params[params.length - 1] as number - const after = params.length === 3 ? (params[1] as string) : null - let rows = table.map(r => ({ ...r, asset_a: 'X', asset_b: 'Y', reserve_a: 1, reserve_b: 2, spot_price: 2, fee_bp: 30, timestamp: new Date('2025-01-01T00:00:00Z') })) - if (after) rows = rows.filter(r => r.pool_id > after) - rows.sort((a, b) => (a.pool_id < b.pool_id ? -1 : 1)) - return { rows: rows.slice(0, limitPlus1) } + mockQuery.mockReset() + buckets = [minutes(0), minutes(1), minutes(2), minutes(3), minutes(4)] + // Behaves like the aggregate query: honours the keyset predicate ($ afterTs) + // and the trailing LIMIT, exactly as the SQL does. + mockQuery.mockImplementation(async (sql: string, params: unknown[]) => { + const hasCursor = sql.includes('AND ts > $6') + const afterTs = hasCursor ? (params[5] as Date) : null + const limit = hasCursor ? (params[6] as number) : (params[5] as number) + const filtered = buckets.filter(b => !afterTs || b.getTime() > afterTs.getTime()) + return { + rows: filtered.slice(0, limit).map(bucket => ({ bucket, price: '1.25', volume: '10' })), + } }) }) - afterEach(() => { - vi.clearAllMocks() - }) - - it('pages without duplicates or skips when a pool is inserted mid-page', async () => { - const app = await buildPoolsApp() - - // Page 1: default limit 50 would swallow everything, so ask for 2. - const page1 = await app.inject({ method: 'GET', url: '/pools?limit=2' }) - expect(page1.statusCode).toBe(200) - const body1 = page1.json() - expect(body1.pools.map((p: { pool_id: string }) => p.pool_id)).toEqual(['A', 'B']) - expect(body1.hasMore).toBe(true) - expect(body1.nextCursor).toBeTruthy() + async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerHistoryRoutes(app) + await app.ready() + return app + } - // A pool sorts into the range page 1 already covered. An OFFSET-based - // page 2 would now return B again (and skip C); the keyset cursor must not. - table.push({ pool_id: 'A2' }) + it('pages through the whole result set with a row inserted mid-page', async () => { + const app = await buildApp() + + const page1 = ( + await app.inject({ method: 'GET', url: '/prices/history?pair=XLM/USDC&interval=1m&limit=2' }) + ).json() + expect(page1.points.map((p: { ts: string }) => p.ts)).toEqual([minutes(0), minutes(1)].map(d => d.toISOString())) + expect(page1.hasMore).toBe(true) + expect(page1.nextCursor).toBeTruthy() + + // A snapshot lands between the two rows page 1 already served — behind the + // cursor. Keyset paging must ignore it and neither repeat nor skip a row. + buckets.splice(1, 0, new Date(T0.getTime() + 30_000)) + + const page2 = ( + await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&limit=2&cursor=${encodeURIComponent(page1.nextCursor)}`, + }) + ).json() + expect(page2.points.map((p: { ts: string }) => p.ts)).toEqual([minutes(2), minutes(3)].map(d => d.toISOString())) + expect(page2.hasMore).toBe(true) + + const page3 = ( + await app.inject({ + method: 'GET', + url: `/prices/history?pair=XLM/USDC&interval=1m&limit=2&cursor=${encodeURIComponent(page2.nextCursor)}`, + }) + ).json() + expect(page3.points.map((p: { ts: string }) => p.ts)).toEqual([minutes(4)].map(d => d.toISOString())) + expect(page3.hasMore).toBe(false) + expect(page3.nextCursor).toBeNull() + + const seen = [...page1.points, ...page2.points, ...page3.points].map((p: { ts: string }) => p.ts) + expect(seen).toEqual([0, 1, 2, 3, 4].map(n => minutes(n).toISOString())) - const page2 = await app.inject({ - method: 'GET', - url: `/pools?limit=2&cursor=${encodeURIComponent(body1.nextCursor)}`, - }) - expect(page2.statusCode).toBe(200) - const body2 = page2.json() - expect(body2.pools.map((p: { pool_id: string }) => p.pool_id)).toEqual(['C']) - expect(body2.hasMore).toBe(false) - expect(body2.nextCursor).toBeNull() await app.close() }) - it('returns the first page and hasMore=false when everything fits', async () => { - const app = await buildPoolsApp() - const res = await app.inject({ method: 'GET', url: '/pools' }) + it('gives an unpaged caller the first page with a default limit', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/prices/history?pair=XLM/USDC&interval=1m' }) + + expect(res.statusCode).toBe(200) const body = res.json() - expect(body.pools).toHaveLength(3) + expect(body.points).toHaveLength(buckets.length) expect(body.hasMore).toBe(false) - expect(body.nextCursor).toBeNull() + // limit + 1 probes: the default is HISTORY_DEFAULT_LIMIT, so $6 is 1001. + expect(mockQuery.mock.calls[0][1][5]).toBe(HISTORY_DEFAULT_LIMIT + 1) await app.close() }) - - it('rejects a malformed cursor and an invalid limit with 400', async () => { - const app = await buildPoolsApp() - const badCursor = await app.inject({ method: 'GET', url: '/pools?cursor=@@@' }) - expect(badCursor.statusCode).toBe(400) - const badLimit = await app.inject({ method: 'GET', url: '/pools?limit=abc' }) - expect(badLimit.statusCode).toBe(400) - await app.close() - }) - - it('uses the documented default and maximum limits', () => { - expect(SCREENER_DEFAULT_LIMIT).toBe(20) - expect(SCREENER_MAX_LIMIT).toBe(100) - expect(POOLS_DEFAULT_LIMIT).toBe(50) - expect(POOLS_MAX_LIMIT).toBe(200) - }) }) -describe('GET /pairs cursor pagination', () => { - const pair = (pairKey: string) => ({ - pairKey, - assetA: { code: 'XLM', issuer: null }, - assetB: { code: 'USDC', issuer: 'GTEST' }, - }) - - let watched: string[] - - beforeEach(() => { - watched = ['B/C', 'A/B', 'C/D'] - mockGetActivePairs.mockImplementation(() => watched.map(pair)) - mockQuery.mockResolvedValue({ rows: [] }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - async function buildPairsApp() { +describe('GET /candles pagination', () => { + async function buildApp() { const app = Fastify({ logger: false }) - await registerPairsRoutes(app) + await registerCandleRoutes(app) await app.ready() return app } - it('pages without duplicates or skips when a pair is registered mid-page', async () => { - const app = await buildPairsApp() + function candleRow(hour: number) { + return { + time: new Date(`2025-01-01T0${hour}:00:00Z`), + open: '1', + high: '2', + low: '0.5', + close: '1.5', + volume: '10', + } + } - const page1 = await app.inject({ method: 'GET', url: '/pairs?limit=2' }) - expect(page1.statusCode).toBe(200) - const body1 = page1.json() - // Sorted by pairKey regardless of registry order. - expect(body1.pairs.map((p: { pairKey: string }) => p.pairKey)).toEqual(['A/B', 'B/C']) - expect(body1.hasMore).toBe(true) + beforeEach(() => { + mockQuery.mockReset() + }) - // Insert a pair that sorts into page 1's range mid-paging. - watched.push('A2/X') + it('returns nextCursor/hasMore and a keyset condition on the next page', async () => { + mockQuery.mockResolvedValue({ rows: [candleRow(0), candleRow(1), candleRow(2)] }) + const app = await buildApp() - const page2 = await app.inject({ + const res = await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h&limit=2' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.candles).toHaveLength(2) + expect(body.hasMore).toBe(true) + + const expectedEpoch = Math.floor(new Date('2025-01-01T01:00:00Z').getTime() / 1000) + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual([expectedEpoch, 'USDC/XLM']) + expect(mockQuery.mock.calls[0][1]).toEqual([ + 3600, + 'USDC/XLM', + expect.any(Date), + expect.any(Date), + 3, + ]) + + mockQuery.mockClear() + mockQuery.mockResolvedValue({ rows: [candleRow(2)] }) + await app.inject({ method: 'GET', - url: `/pairs?limit=2&cursor=${encodeURIComponent(body1.nextCursor)}`, + url: `/candles/XLM/USDC?interval=1h&limit=2&cursor=${encodeURIComponent(body.nextCursor)}`, }) - expect(page2.statusCode).toBe(200) - const body2 = page2.json() - // Keyset: everything strictly after B/C — no duplicate B/C, no skipped C/D. - // (A2/X sorts before B/C, so it belongs to page 1 and is not repeated.) - expect(body2.pairs.map((p: { pairKey: string }) => p.pairKey)).toEqual(['C/D']) - expect(body2.hasMore).toBe(false) - expect(body2.nextCursor).toBeNull() + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).toMatch(/floor\(EXTRACT\(EPOCH FROM timestamp\) \/ \$1\) \* \$1 > \$5/) + expect(params[4]).toBe(expectedEpoch) + expect(params[5]).toBe(3) + await app.close() }) - it('rejects a malformed cursor with 400', async () => { - const app = await buildPairsApp() - const res = await app.inject({ method: 'GET', url: '/pairs?cursor=@@@' }) - expect(res.statusCode).toBe(400) + it('rejects a malformed cursor and a non-numeric limit', async () => { + const app = await buildApp() + const badCursor = await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h&cursor=nope' }) + const badLimit = await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h&limit=lots' }) + + expect(badCursor.statusCode).toBe(400) + expect(badLimit.statusCode).toBe(400) await app.close() }) }) diff --git a/src/__tests__/pairs.test.ts b/src/__tests__/pairs.test.ts index c5633d61..09bbe0ec 100644 --- a/src/__tests__/pairs.test.ts +++ b/src/__tests__/pairs.test.ts @@ -221,11 +221,29 @@ describe('GET /pairs', () => { const app = await buildApp() const body = (await app.inject({ method: 'GET', url: '/pairs' })).json() + // Sorted ascending by pairKey (the keyset order), so NEW/Y precedes QUIET/X. expect(body.pairs).toHaveLength(2) - // Sorted by pairKey ascending, so NEW/Y precedes QUIET/X. expect(body.pairs[0]).toMatchObject({ pairKey: 'NEW/Y', latestPrice: null, lastUpdated: null }) expect(body.pairs[1]).toMatchObject({ pairKey: 'QUIET/X', latestPrice: 1.5, lastUpdated: old.toISOString() }) - expect(body.nextCursor).toBeNull() + }) + + it('paginates keyset-style on pairKey, fetching one extra row', async () => { + mockGetActivePairs.mockReturnValue([pair('A/B'), pair('C/D'), pair('E/F')]) + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: '/pairs?limit=2' })).json() + + expect(body.pairs.map((p: { pairKey: string }) => p.pairKey)).toEqual(['A/B', 'C/D']) + expect(body.hasMore).toBe(true) + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual(['C/D', 'C/D']) + }) + + it('resumes after the cursor without duplicating the boundary row', async () => { + mockGetActivePairs.mockReturnValue([pair('A/B'), pair('C/D'), pair('E/F')]) + const cursor = Buffer.from(JSON.stringify(['C/D', 'C/D'])).toString('base64') + const app = await buildApp() + const body = (await app.inject({ method: 'GET', url: `/pairs?cursor=${cursor}` })).json() + + expect(body.pairs.map((p: { pairKey: string }) => p.pairKey)).toEqual(['E/F']) expect(body.hasMore).toBe(false) }) }) diff --git a/src/__tests__/pools.test.ts b/src/__tests__/pools.test.ts index 0cab37e0..ec264e43 100644 --- a/src/__tests__/pools.test.ts +++ b/src/__tests__/pools.test.ts @@ -46,7 +46,6 @@ describe('GET /pools', () => { expect(sql).not.toMatch(/DISTINCT ON/i) expect(sql).toMatch(/network = \$1/) expect(sql).toMatch(/LIMIT 1/i) - // Last bind is the limit+1 probe (default 50 + 1). expect(params).toEqual([activeNetwork, 51]) await app.close() }) @@ -75,4 +74,46 @@ describe('GET /pools', () => { expect(res.json().hasMore).toBe(false) await app.close() }) + + it('paginates keyset-style on pool_id, fetching one extra row', async () => { + const old = new Date('2020-01-01T00:00:00Z') + mockQuery.mockResolvedValue({ + rows: [ + { pool_id: 'C1', asset_a: 'XLM', asset_b: 'USDC', timestamp: old }, + { pool_id: 'C2', asset_a: 'XLM', asset_b: 'USDC', timestamp: old }, + { pool_id: 'C3', asset_a: 'XLM', asset_b: 'USDC', timestamp: old }, + ], + }) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/pools?limit=2' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.pools.map((p: { pool_id: string }) => p.pool_id)).toEqual(['C1', 'C2']) + expect(body.hasMore).toBe(true) + expect(JSON.parse(Buffer.from(body.nextCursor, 'base64').toString())).toEqual(['C2', 'C2']) + expect(mockQuery.mock.calls[0][1]).toEqual([activeNetwork, 3]) + await app.close() + }) + + it('applies the cursor as a pool_id keyset predicate', async () => { + const cursor = Buffer.from(JSON.stringify(['C2', 'C2'])).toString('base64') + const app = await buildApp() + await app.inject({ method: 'GET', url: `/pools?cursor=${cursor}` }) + + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).toMatch(/pool_id > \$2/) + expect(params).toEqual([activeNetwork, 'C2', 51]) + await app.close() + }) + + it('rejects a malformed cursor and a non-numeric limit', async () => { + const app = await buildApp() + const badCursor = await app.inject({ method: 'GET', url: '/pools?cursor=not-base64-json' }) + const badLimit = await app.inject({ method: 'GET', url: '/pools?limit=lots' }) + + expect(badCursor.statusCode).toBe(400) + expect(badLimit.statusCode).toBe(400) + await app.close() + }) }) diff --git a/src/api/admin.ts b/src/api/admin.ts index 77a3b4a4..944627bb 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -2,10 +2,8 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { randomBytes } from 'crypto' import { z } from 'zod' import { prisma } from '../db' -import { admin_auth_failures_total } from '../metrics' -import { formatValidationError } from '../validation' -import { hashApiKey, isAdminAuthorized, checkAdminAuth, invalidateApiKey } from './auth' -import { copyUsageHistory } from '../x402/metering' +import { hashApiKey } from './auth' +import { apiError } from '../errors' /** Generates a new opaque API key with a recognizable `lens_` prefix. */ export function generateApiKey(): string { @@ -150,26 +148,19 @@ export async function registerAdminRoutes(app: FastifyInstance) { '/admin/keys', { config: { admin: true, auth: 'admin' } }, async (req: FastifyRequest<{ Body: CreateKeyBody }>, reply: FastifyReply) => { - const auth = checkAdminAuth(req) - if (auth === 'not_configured') { - return reply.status(503).send({ - error: 'Service Unavailable', - code: 'admin_not_configured', - message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', - }) - } - if (auth !== 'authorized') { - admin_auth_failures_total.inc() - return reply.status(401).send({ - error: 'Unauthorized', - code: 'admin_unauthorized', - message: 'Provide a valid X-Admin-Token header.', - }) + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Provide a valid X-Admin-Token header.', + }) + } + + const { label, expiresAt, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy, scopes } = req.body ?? {} + if (!label || typeof label !== 'string' || label.trim().length === 0) { + return reply.status(400).send(apiError('missing_parameter', 'label is required')) } - - const parsed = CreateKeySchema.safeParse(req.body ?? {}) - if (!parsed.success) { - return reply.status(400).send(formatValidationError(parsed.error)) + if (expiresAt !== undefined && typeof expiresAt !== 'string') { + return reply.status(400).send({ error: 'expiresAt must be a valid future date' }) } const { label, expiresAt, ratePerMin, ratePerDay, monthlyQuotaCents, dailyQuotaCents, overagePolicy, scopes } = parsed.data const expiration = expiresAt === undefined ? undefined : new Date(expiresAt) @@ -279,28 +270,18 @@ export async function registerAdminRoutes(app: FastifyInstance) { '/admin/keys/:id', { config: { admin: true, auth: 'admin' } }, async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const auth = checkAdminAuth(req) - if (auth === 'not_configured') { - return reply.status(503).send({ - error: 'Service Unavailable', - code: 'admin_not_configured', - message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', - }) - } - if (auth !== 'authorized') { - admin_auth_failures_total.inc() - return reply.status(401).send({ - error: 'Unauthorized', - code: 'admin_unauthorized', - message: 'Provide a valid X-Admin-Token header.', - }) - } + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Provide a valid X-Admin-Token header.', + }) + } const { id } = req.params const existing = await prisma.apiKey.findUnique({ where: { id } }) - if (!existing) { - return reply.status(404).send({ error: 'API key not found' }) - } + if (!existing) { + return reply.status(404).send(apiError('not_found', 'API key not found')) + } if (existing.revokedAt) { return reply.status(200).send({ id, revoked: true, alreadyRevoked: true }) } diff --git a/src/api/auth.ts b/src/api/auth.ts index f95b412e..46b57173 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -4,12 +4,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' import { createHash, timingSafeEqual } from 'crypto' import fp from 'fastify-plugin' import { prisma } from '../db' -import { config } from '../config' -import { api_key_auth_failures_total, admin_auth_failures_total, api_key_cache_lookups_total } from '../metrics' - -const log = createLogger('auth') - -type ApiKeyAuthFailureReason = 'missing' | 'malformed' | 'unknown' | 'revoked' | 'expired' | 'insufficient_scope' +import { apiError } from '../errors' /** * Metadata for an authenticated API key, attached to each request as @@ -443,50 +438,21 @@ async function apiKeyAuthPlugin(app: FastifyInstance) { const isPublic = routeConfig.public === true || routeConfig.auth === 'none' if (isPublic || isAdminRoute(req)) return - const authorization = req.headers['authorization'] - const token = extractBearer(authorization) - if (authorization !== undefined && !token) { - api_key_auth_failures_total.inc({ reason: 'malformed' }) - return reply.status(401).send({ - error: 'Unauthorized', - code: 'malformed', - message: 'Malformed Authorization header.', - }) - } - - if (authorization === undefined) { - api_key_auth_failures_total.inc({ reason: 'missing' }) - return reply.status(401).send({ - error: 'Unauthorized', - code: 'missing', - message: 'Missing API key. Provide an Authorization header.', - }) - } - - if (!token) { - api_key_auth_failures_total.inc({ reason: 'malformed' }) - return reply.status(401).send({ - error: 'Unauthorized', - code: 'malformed', - message: 'Missing API key. Provide an Authorization: Bearer header.', - }) - } + const token = extractBearer(req.headers['authorization']) + if (!token) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Missing API key. Provide an Authorization: Bearer header.', + }) + } - const result = await lookupApiKeyResult(token) - const keyContext = result.context - if (!keyContext) { - const reason = result.failureReason ?? 'unknown' - api_key_auth_failures_total.inc({ reason }) - return reply.status(401).send({ - error: 'Unauthorized', - code: reason, - message: reason === 'revoked' - ? 'Revoked API key. Request a replacement key.' - : reason === 'expired' - ? 'Expired API key. Request a replacement key.' - : 'Unknown API key.', - }) - } + const keyContext = await lookupApiKey(token) + if (!keyContext) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Invalid or revoked API key.', + }) + } req.apiKey = keyContext // #500: after the key is known to be valid and before any scope or quota diff --git a/src/api/history.ts b/src/api/history.ts index 8b1b1652..2d8eda15 100644 --- a/src/api/history.ts +++ b/src/api/history.ts @@ -9,8 +9,14 @@ import { slicePage, } from '../pagination' import '../middleware/network' // declares req.network on the FastifyRequest type -import { findOverlappingGaps } from '../backfill/gaps' -import { formatPrice, formatVolume } from '../pricing/precision' +import { apiError } from '../errors' +import { + parseLimit, + encodeCursor, + decodeCursor, + slicePage, + HISTORY_DEFAULT_LIMIT, +} from '../pagination' /** Supported aggregation intervals → bucket width in seconds. */ export const HISTORY_INTERVAL_SECONDS: Record = { @@ -114,7 +120,7 @@ export async function queryHistory( FROM price_snapshots WHERE pair = $2 AND network = $5 AND ts >= $3 - AND ts < $4${cursorCondition} + AND ts <= $4${cursorCondition} GROUP BY floor(EXTRACT(EPOCH FROM ts) / $1) ORDER BY bucket ASC${limitClause}`, params @@ -154,25 +160,53 @@ export async function registerHistoryRoutes(app: FastifyInstance) { // clause before grouping, so a snapshot inserted mid-paging cannot shift a // page boundary the way an offset would. app.get<{ - Querystring: { pair?: string; from?: string; to?: string; interval?: string; format?: string; cursor?: string; limit?: string } - }>('/prices/history', { config: { scope: 'read:history' } }, async (req, reply) => { + Querystring: { + pair?: string + from?: string + to?: string + interval?: string + format?: string + cursor?: string + limit?: string + } + }>('/prices/history', async (req, reply) => { const { pair } = req.query if (!pair) { - return reply.status(400).send({ error: 'pair query parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'pair query parameter is required')) } // Validated before the DB round-trip so an unknown format never queries. const format = req.query.format ?? 'json' if (format !== 'json' && format !== 'csv') { - return reply.status(400).send({ error: 'format must be one of: json, csv' }) + return reply.status(400).send(apiError('invalid_parameter', 'format must be one of: json, csv')) } const interval = req.query.interval ?? '1m' const intervalSecs = HISTORY_INTERVAL_SECONDS[interval] if (!intervalSecs) { - return reply.status(400).send({ - error: `interval must be one of: ${Object.keys(HISTORY_INTERVAL_SECONDS).join(', ')}`, - }) + return reply.status(400).send( + apiError('bad_interval', `interval must be one of: ${Object.keys(HISTORY_INTERVAL_SECONDS).join(', ')}`), + ) + } + + const parsedLimit = parseLimit(req.query.limit, HISTORY_DEFAULT_LIMIT, MAX_HISTORY_POINTS) + if ('error' in parsedLimit) { + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) + } + const { limit } = parsedLimit + + let afterTs: Date | undefined + if (req.query.cursor) { + const decoded = decodeCursor(req.query.cursor) + if (decoded === null) { + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) + } + if (decoded !== 'skip') { + afterTs = new Date(String(decoded.value)) + if (isNaN(afterTs.getTime())) { + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) + } + } } const parsedLimit = parseLimit(req.query.limit, HISTORY_DEFAULT_LIMIT, MAX_HISTORY_POINTS) @@ -199,21 +233,21 @@ export async function registerHistoryRoutes(app: FastifyInstance) { const to = req.query.to ? new Date(req.query.to) : new Date() if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) } if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) } if (from.getTime() > to.getTime()) { - return reply.status(400).send({ error: 'from must be before to' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be before to')) } // Guard against unbounded scans: cap the number of buckets the window can produce. const spanSecs = (to.getTime() - from.getTime()) / 1000 if (spanSecs / intervalSecs > MAX_HISTORY_POINTS) { - return reply.status(400).send({ - error: `requested window is too large for interval=${interval} (max ${MAX_HISTORY_POINTS} points); narrow the range or use a coarser interval`, - }) + return reply.status(400).send( + apiError('invalid_parameter', `requested window is too large for interval=${interval} (max ${MAX_HISTORY_POINTS} points); narrow the range or use a coarser interval`), + ) } const network = req.network ?? activeNetwork diff --git a/src/api/rest.ts b/src/api/rest.ts index 90e3983a..483da6e8 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -22,15 +22,15 @@ import { impactResponseSchema, installResponseValidation, } from './schemas' -import { findOverlappingGaps } from '../backfill/gaps' -import { getDepth, getImpact } from '../pricing/depth' +import { getDepth } from '../pricing/depth' +import { apiError } from '../errors' import { - POOLS_DEFAULT_LIMIT, - POOLS_MAX_LIMIT, - decodeCursor, - encodeCursor, parseLimit, + encodeCursor, + decodeCursor, slicePage, + POOLS_DEFAULT_LIMIT, + POOLS_MAX_LIMIT, } from '../pagination' function makePairKey(a: string, b: string): string { @@ -226,7 +226,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { const { assetA, assetB } = req.params const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) const rawThreshold = req.query?.staleThreshold ?? req.query?.staleThresholdSeconds let staleThreshold: number | undefined @@ -290,7 +290,8 @@ export async function registerRESTRoutes(app: FastifyInstance) { if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) + if (isNaN(amount) || amount <= 0) return reply.status(400).send(apiError('invalid_parameter', 'amount must be a positive number')) const decimalFormat = resolveDecimalFormat(req) const routeInfo = await getBestRoute(pair.assetA, pair.assetB, pair.pairKey, amount, network) @@ -327,7 +328,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { } if (!['1m', '5m', '1h', '24h'].includes(window)) { - return reply.status(400).send({ error: 'window must be one of: 1m, 5m, 1h, 24h' }) + return reply.status(400).send(apiError('bad_window', 'window must be one of: 1m, 5m, 1h, 24h')) } const result = await pgPool.query( @@ -396,17 +397,13 @@ export async function registerRESTRoutes(app: FastifyInstance) { // page boundary the way an OFFSET would. app.get<{ Querystring: { cursor?: string; limit?: string } }>( '/pools', - { config: { scope: 'read:prices' }, schema: { response: { 200: poolsResponseSchema } } }, + { schema: { response: { 200: poolsResponseSchema } } }, async (req, reply) => { const network = req.network ?? activeNetwork - const decimalFormat = resolveDecimalFormat(req) - if (decimalFormat === 'string') { - reply.header('x-decimal-format', 'string') - } const parsedLimit = parseLimit(req.query.limit, POOLS_DEFAULT_LIMIT, POOLS_MAX_LIMIT) if ('error' in parsedLimit) { - return reply.status(400).send({ error: parsedLimit.error }) + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) } const { limit } = parsedLimit @@ -415,7 +412,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { if (req.query.cursor) { const decoded = decodeCursor(req.query.cursor) if (decoded === null) { - return reply.status(400).send({ error: 'Invalid cursor format' }) + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) } if (decoded !== 'skip') { params.push(decoded.value) @@ -435,7 +432,7 @@ export async function registerRESTRoutes(app: FastifyInstance) { WHERE ids.pool_id IS NOT NULL ) SELECT l.pool_id, l.asset_a, l.asset_b, - l.reserve_a::numeric, l.reserve_b::numeric, l.spot_price::numeric, l.fee_bp, l.timestamp + l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp FROM ids CROSS JOIN LATERAL ( SELECT * FROM pool_snapshots s @@ -451,22 +448,10 @@ export async function registerRESTRoutes(app: FastifyInstance) { const { data, hasMore } = slicePage(result.rows, limit) const nextCursor = - hasMore && data.length > 0 ? encodeCursor(data[data.length - 1].pool_id, data[data.length - 1].pool_id) : null - - return { - pools: data.map(r => ({ - pool_id: r.pool_id, - asset_a: r.asset_a, - asset_b: r.asset_b, - reserve_a: formatDecimal(r.reserve_a, decimalFormat), - reserve_b: formatDecimal(r.reserve_b, decimalFormat), - spot_price: formatDecimal(r.spot_price, decimalFormat), - fee_bp: r.fee_bp !== null && r.fee_bp !== undefined ? parseInt(String(r.fee_bp), 10) : null, - timestamp: r.timestamp, - })), - nextCursor, - hasMore, - } + hasMore && data.length > 0 + ? encodeCursor(data[data.length - 1].pool_id, data[data.length - 1].pool_id) + : null + return { pools: data, nextCursor, hasMore } } ) @@ -489,8 +474,8 @@ export async function registerRESTRoutes(app: FastifyInstance) { const network = req.network ?? activeNetwork const pair = findPair(assetA, assetB, network) - if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) - if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + if (!pair) return reply.status(404).send(apiError('unwatched_pair', `Pair ${assetA}/${assetB} not watched on ${network}`)) + if (isNaN(amount) || amount <= 0) return reply.status(400).send(apiError('invalid_parameter', 'amount must be a positive number')) // Depth reads are network-scoped on both legs (pool_snapshots and the // price_points subquery that names this pair's pools), so a mainnet pair diff --git a/src/api/usage.ts b/src/api/usage.ts index f777537a..f83cfdd2 100644 --- a/src/api/usage.ts +++ b/src/api/usage.ts @@ -1,29 +1,31 @@ import type { FastifyInstance, FastifyReply } from 'fastify' import { getUsageSummary, getAllUsageSummaries } from '../x402/metering' -import { admin_auth_failures_total, api_key_auth_failures_total } from '../metrics' -import { checkAdminAuth } from './auth' +import { apiError } from '../errors' + +function isAdminAuthorized(req: FastifyRequest): boolean { + const adminToken = process.env.ADMIN_TOKEN + if (!adminToken) return false + const supplied = + (req.headers['x-admin-token'] as string | undefined) ?? + req.headers['authorization']?.replace(/^Bearer\s+/i, '') + if (!supplied) return false + const a = Buffer.from(supplied) + const b = Buffer.from(adminToken) + // Constant-time comparison (timingSafeEqual requires equal-length buffers). + return a.length === b.length && require('crypto').timingSafeEqual(a, b) +} export async function registerUsageRoutes(app: FastifyInstance) { app.get<{ Params: { keyId: string } }>( '/admin/usage/:keyId', { config: { admin: true, auth: 'admin' } }, async (req, reply) => { - const auth = checkAdminAuth(req) - if (auth === 'not_configured') { - return reply.status(503).send({ - error: 'Service Unavailable', - code: 'admin_not_configured', - message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', - }) - } - if (auth !== 'authorized') { - admin_auth_failures_total.inc() - return reply.status(401).send({ - error: 'Unauthorized', - code: 'admin_unauthorized', - message: 'Provide a valid X-Admin-Token header.', - }) - } + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Provide a valid X-Admin-Token header.', + }) + } const { keyId } = req.params const summary = await getUsageSummary(keyId) return reply.send(summary) @@ -34,37 +36,29 @@ export async function registerUsageRoutes(app: FastifyInstance) { '/admin/usage', { config: { admin: true, auth: 'admin' } }, async (req, reply) => { - const auth = checkAdminAuth(req) - if (auth === 'not_configured') { - return reply.status(503).send({ - error: 'Service Unavailable', - code: 'admin_not_configured', - message: 'Admin surface is disabled: ADMIN_TOKEN is not configured on this server.', - }) - } - if (auth !== 'authorized') { - admin_auth_failures_total.inc() - return reply.status(401).send({ - error: 'Unauthorized', - code: 'admin_unauthorized', - message: 'Provide a valid X-Admin-Token header.', - }) - } + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Provide a valid X-Admin-Token header.', + }) + } const summaries = await getAllUsageSummaries() return reply.send({ keys: summaries }) } ) - const handleCallerUsage = async (req: any, reply: FastifyReply) => { - reply.header('Cache-Control', 'no-store') - const apiKey = req.apiKey as { id: string } | undefined - if (!apiKey) { - api_key_auth_failures_total.inc({ reason: 'missing' }) - return reply.status(401).send({ - error: 'Unauthorized', - code: 'missing', - message: 'Missing API key. Provide an Authorization: Bearer header.', - }) + app.get( + '/usage/me', + async (req, reply) => { + const apiKey = (req as any).apiKey as { id: string } | undefined + if (!apiKey) { + return reply.status(401).send({ + ...apiError('unauthorized', 'Unauthorized'), + message: 'Missing API key. Provide an Authorization: Bearer header.', + }) + } + const summary = await getUsageSummary(apiKey.id) + return reply.send(summary) } // The key presented determines the answer — never a parameter. const summary = await getUsageSummary(apiKey.id) diff --git a/src/app.ts b/src/app.ts index 85de28aa..40b5fcc6 100644 --- a/src/app.ts +++ b/src/app.ts @@ -42,12 +42,8 @@ import { registerDispersionRoutes } from './routes/dispersion' import { registerHealthRoutes } from './routes/health' import { registerTvlRoutes } from './routes/tvl' import { registerCachePolicy } from './middleware/cachePolicy' -import { registerFiatRoutes } from './routes/fiat' -import { registerGlobalRateLimit, parseGlobalRateLimit } from './middleware/globalRateLimit' -import { getMetrics, rate_limit_rejections_total } from './metrics' -import { loggerOptions, createDestination } from './logger' -import { isPoolExhaustionError, recordPoolExhaustion } from './db' -import { createCorsDelegator, type CorsConfig } from './cors' +import { getMetrics } from './metrics' +import { apiError } from './errors' /** * Routes the rate limiter skips. @@ -167,35 +163,13 @@ export async function buildApp(options: BuildAppOptions = {}): Promise req.apiKey?.ratePerMin ?? ipRateLimitMax, timeWindow: '1 minute', keyGenerator: (req) => req.apiKey?.id ?? req.ip, - // PROBE_PATHS, not just /status: /live and /ready are polled by the - // platform on a fixed interval and must never be rate limited. - allowList: (req) => PROBE_PATHS.has(req.url), - errorResponseBuilder: (req, context) => { - rate_limit_rejections_total.inc({ scope: req.apiKey ? 'key' : 'ip' }) - const seconds = typeof context.after === 'number' ? context.after : (Math.ceil(context.ttl / 1000) || 60) - return { - statusCode: 429, - error: 'Too Many Requests', - code: 'rate_limited', - message: `Rate limit exceeded, retry in ${seconds} seconds`, - retryAfter: seconds, - } - }, - }) - - // Ensure Retry-After header is always present on 429 responses (#272) - app.addHook('onSend', async (_req, reply, payload) => { - if (reply.statusCode === 429 && !reply.hasHeader('retry-after')) { - try { - const body = typeof payload === 'string' ? JSON.parse(payload) : payload - if (body?.retryAfter) { - reply.header('Retry-After', String(body.retryAfter)) - } - } catch { - // non-fatal - } - } - return payload + allowList: (req) => req.url === '/status', + errorResponseBuilder: (req, context) => ({ + statusCode: 429, + ...apiError('rate_limited', 'Too Many Requests'), + message: `Rate limit exceeded, retry in ${context.after}`, + retryAfter: context.after + }) }) // The per-instance ceiling runs after the per-key/IP limiter. Health checks diff --git a/src/errors.ts b/src/errors.ts index 25a22b99..b02fb60e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,45 +1,81 @@ -import { randomUUID } from 'node:crypto' -import type { FastifyReply, FastifyRequest } from 'fastify' +/** + * The single, enumerated set of stable machine-readable error codes. + * + * Every JSON error response the API emits carries a `code` beside its existing + * `error` string. `error` stays human-readable and may be reworded at any time; + * `code` is a contract a client branches on. Codes are declared here (and + * mirrored in `openapi.yaml`) rather than invented at each call site, so the set + * can be audited in one place and a typo fails to compile instead of shipping. + * + * Lowercase snake_case is deliberate: it matches the codes the Bazaar and x402 + * surfaces already expose (`rate_limited`, `unexpected_settle_error`, ...). + */ +export const ERROR_CODES = { + /** A required query/body/path parameter was not supplied. */ + missing_parameter: 'missing_parameter', + /** A supplied parameter was present but malformed, out of range, or unknown. */ + invalid_parameter: 'invalid_parameter', + /** `network` was not one of the supported networks (testnet, mainnet). */ + bad_network: 'bad_network', + /** An interval was not one of the supported values for the endpoint. */ + bad_interval: 'bad_interval', + /** A window was not one of the supported values for the endpoint. */ + bad_window: 'bad_window', + /** The request named something the API recognises but deliberately does not serve. */ + unsupported: 'unsupported', + /** No price/data exists for the requested pair or asset. */ + unknown_pair: 'unknown_pair', + /** The requested pair is not in the set this instance watches. */ + unwatched_pair: 'unwatched_pair', + /** The pair is already being watched (conflict). */ + pair_already_watched: 'pair_already_watched', + /** Credentials were missing or invalid. */ + unauthorized: 'unauthorized', + /** A referenced resource (e.g. an API key) does not exist. */ + not_found: 'not_found', + /** A dependency the endpoint relies on (database, venue, oracle) was unreachable. */ + upstream_unavailable: 'upstream_unavailable', + /** An unexpected server-side fault. */ + internal_error: 'internal_error', + /** The request was throttled by a quota or rate limit. */ + rate_limited: 'rate_limited', + /** Payment is required before the resource can be served. */ + payment_required: 'payment_required', + /** The supplied payment was rejected. */ + payment_invalid: 'payment_invalid', + /** The payment could not be verified because verification itself failed. */ + payment_verification_failed: 'payment_verification_failed', + /** The caller's quota is exhausted and further payment is required. */ + quota_exceeded: 'quota_exceeded', +} as const -export const INTERNAL_ERROR_CODE = 'internal_error' -export const INTERNAL_ERROR_MESSAGE = 'An internal server error occurred.' +/** A member of {@link ERROR_CODES}. */ +export type ErrorCode = keyof typeof ERROR_CODES -export interface InternalErrorResponse { - code: typeof INTERNAL_ERROR_CODE - message: typeof INTERNAL_ERROR_MESSAGE - correlationId: string -} +/** Every code as a plain string array, for validation and spec generation. */ +export const ERROR_CODE_VALUES: readonly ErrorCode[] = Object.values(ERROR_CODES) -export function getCorrelationId(req?: FastifyRequest): string { - const headerId = req?.headers?.['x-correlation-id'] || req?.headers?.['x-request-id'] - if (typeof headerId === 'string' && headerId.length > 0) { - return headerId - } - if (typeof req?.id === 'string' && req.id.length > 0) { - return req.id - } - return randomUUID() -} +const ERROR_CODE_SET: ReadonlySet = new Set(ERROR_CODE_VALUES) -export function createInternalErrorBody(correlationId: string): InternalErrorResponse { - return { - code: INTERNAL_ERROR_CODE, - message: INTERNAL_ERROR_MESSAGE, - correlationId, - } +/** Narrows an unknown value to an {@link ErrorCode}. */ +export function isErrorCode(value: unknown): value is ErrorCode { + return typeof value === 'string' && ERROR_CODE_SET.has(value) } -export function sendInternalError( - reply: FastifyReply, - req: FastifyRequest, - err: unknown, - logMessage = 'Internal server error', -): FastifyReply { - const correlationId = getCorrelationId(req) - const log = req?.log ?? reply?.log - if (log && typeof log.error === 'function') { - log.error({ err, correlationId }, logMessage) - } +/** The JSON body of every non-2xx error response. */ +export interface ApiError { + /** Human-readable message. Do not branch on this — branch on `code`. */ + error: string + /** Stable code from {@link ERROR_CODES}. */ + code: ErrorCode +} - return reply.status(500).send(createInternalErrorBody(correlationId)) +/** + * Builds an error body from the enumerated set. + * + * Typing `code` as {@link ErrorCode} is what stops a route inventing its own + * code: an unknown value is a compile error, not a runtime surprise. + */ +export function apiError(code: ErrorCode, message: string): ApiError { + return { error: message, code } } diff --git a/src/middleware/network.ts b/src/middleware/network.ts index 8519559f..7883881e 100644 --- a/src/middleware/network.ts +++ b/src/middleware/network.ts @@ -60,7 +60,7 @@ async function networkSelectorPlugin(app: FastifyInstance) { app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { const resolved = resolveNetworkName(rawNetworkFromRequest(req)) if (!resolved.ok) { - reply.status(400).send({ error: resolved.error }) + reply.status(400).send({ error: resolved.error, code: 'bad_network' }) return } req.network = resolved.network diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index a2651f0a..19600928 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -4,6 +4,7 @@ import { checkQuota, recordUsage, parseCents, getQuotaConfig } from '../x402/met import { X402_NETWORK_LABEL, paymentAddressFor, isX402Configured, getX402ResourceServer } from '../x402/network' import fp from 'fastify-plugin' import './network' // declares req.network on the FastifyRequest type +import { apiError } from '../errors' export interface GatedRoute { readonly path: string @@ -108,7 +109,7 @@ async function x402Plugin(app: FastifyInstance) { const network = req.network ?? 'testnet' const paymentAddress = paymentAddressFor(network) if (!paymentAddress) { - reply.status(402).send({ error: `x402 payments are not configured for network "${network}"` }) + reply.status(402).send(apiError('payment_required', `x402 payments are not configured for network "${network}"`)) return } @@ -127,7 +128,7 @@ async function x402Plugin(app: FastifyInstance) { reply.status(402).send({ x402Version: 1, accepts: [requirements], - error: 'Payment required', + ...apiError('payment_required', 'Payment required'), description, }) return @@ -145,7 +146,7 @@ async function x402Plugin(app: FastifyInstance) { const resourceServer = await getX402ResourceServer(network, FACILITATOR_URL) const result = await resourceServer.verify(payload, requirements) if (!result.isValid) { - reply.status(402).send({ error: 'Payment invalid', reason: result.invalidReason }) + reply.status(402).send({ ...apiError('payment_invalid', 'Payment invalid'), reason: result.invalidReason }) return } @@ -163,14 +164,14 @@ async function x402Plugin(app: FastifyInstance) { await recordUsage(apiKeyId, parseCents(price)) } else if (config.overagePolicy === 'charge_402') { reply.status(402).send({ - error: 'Quota exceeded — additional payment required', + ...apiError('quota_exceeded', 'Quota exceeded — additional payment required'), policy: 'charge_402', }) return } else { // default: block reply.status(402).send({ - error: 'Quota exceeded', + ...apiError('quota_exceeded', 'Quota exceeded'), policy: 'block', }) return @@ -185,7 +186,7 @@ async function x402Plugin(app: FastifyInstance) { app.log.error({ err }, '[oracle] x402 settle error') }) } catch (err) { - reply.status(402).send({ error: 'Payment verification failed', reason: (err as Error).message }) + reply.status(402).send({ ...apiError('payment_verification_failed', 'Payment verification failed'), reason: (err as Error).message }) } }) } diff --git a/src/pagination.ts b/src/pagination.ts index 648b2940..4eba620c 100644 --- a/src/pagination.ts +++ b/src/pagination.ts @@ -97,7 +97,7 @@ export function decodeCursor(raw: string): DecodedCursor { let value: unknown let id: unknown try { - ;[value, id] = parsed as Iterable + ;[value, id] = parsed as [unknown, unknown] } catch { return null } diff --git a/src/routes/basket.ts b/src/routes/basket.ts index 4ab411eb..0e32f2ff 100644 --- a/src/routes/basket.ts +++ b/src/routes/basket.ts @@ -3,8 +3,7 @@ import { formatValidationError } from "../validation" import type { FastifyInstance } from 'fastify' import { z } from 'zod' import { pgPool } from '../db' -import { activeNetwork, type NetworkName } from '../config' -import '../middleware/network' // declares req.network on the FastifyRequest type +import { apiError } from '../errors' async function fetchAssetVWAP(asset: string, network: NetworkName): Promise { const result = await pgPool.query( @@ -35,43 +34,19 @@ const BasketQuerySchema = z const assets = data.asset ?? [] const weightStrings = data.weight ?? [] if (assets.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['asset'], - message: 'at least one asset is required', - }) + return reply.status(400).send(apiError('missing_parameter', 'At least one asset is required')) } - if (assets.length === 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['asset'], - message: 'basket requires at least 2 assets', - }) + if (assets.length < 2) { + return reply.status(400).send(apiError('invalid_parameter', 'Basket requires at least 2 assets')) } if (assets.length !== weightStrings.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['weight'], - message: 'number of assets and weights must match', - }) - } - const weights = weightStrings.map((w) => parseFloat(w)) - if (weights.some((w) => isNaN(w) || w <= 0)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['weight'], - message: 'all weights must be positive numbers', - }) + return reply.status(400).send(apiError('invalid_parameter', 'Number of assets and weights must match')) } }) -export async function registerBasketRoutes(app: FastifyInstance) { - app.get<{ - Querystring: { asset?: string | string[]; weight?: string | string[] } - }>('/basket', { config: { scope: 'read:prices' } }, async (req, reply) => { - const parsed = BasketQuerySchema.safeParse(req.query) - if (!parsed.success) { - return reply.status(400).send(formatValidationError(parsed.error)) + const rawWeightValues = weightStrings.map(w => parseFloat(w)) + if (rawWeightValues.some(w => isNaN(w) || w <= 0)) { + return reply.status(400).send(apiError('invalid_parameter', 'All weights must be positive numbers')) } const assets = parsed.data.asset ?? [] const weightStrings = parsed.data.weight ?? [] @@ -96,14 +71,7 @@ export async function registerBasketRoutes(app: FastifyInstance) { const missingAssets = components.filter((c) => c.price === null).map((c) => c.asset) if (missingAssets.length > 0) { - // 200 with reason: query was valid but no price data exists (yet). - // A 404 would imply the assets themselves are invalid/unwatched. - return { - error: `No price data found for: ${missingAssets.join(', ')}`, - reason: 'missing_price_data', - missingAssets, - requestedAt: new Date().toISOString(), - } + return reply.status(404).send(apiError('unknown_pair', `No price data found for: ${missingAssets.join(', ')}`)) } const basketPrice = components.reduce( diff --git a/src/routes/benchmark.ts b/src/routes/benchmark.ts index 20fa639d..8e4987ba 100644 --- a/src/routes/benchmark.ts +++ b/src/routes/benchmark.ts @@ -4,7 +4,7 @@ import { getActivePairs } from '../pairsRegistry' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type import { benchmarkResponseSchema } from '../api/schemas' -import { sendInternalError } from '../errors' +import { apiError } from '../errors' function findPair(assetCode: string, targetCode: string) { const normalize = (c: string) => c.toLowerCase() === 'native' ? 'XLM' : c.split(':')[0].toUpperCase() @@ -34,7 +34,7 @@ export async function registerBenchmarkRoutes(app: FastifyInstance) { const pair = findPair(asset, target) if (!pair) { - return reply.status(404).send({ error: `Pair ${asset}/${target} not watched` }) + return reply.status(404).send(apiError('unwatched_pair', `Pair ${asset}/${target} not watched`)) } const isAssetA = pair.assetA.code.toUpperCase() === normAsset @@ -91,7 +91,7 @@ export async function registerBenchmarkRoutes(app: FastifyInstance) { rolling24h, } } catch (err) { - return sendInternalError(reply, req, err, 'Benchmark computation failed') + return reply.status(500).send(apiError('upstream_unavailable', `Benchmark computation failed: ${(err as Error).message}`)) } } ) diff --git a/src/routes/book.ts b/src/routes/book.ts index 8ad06f1a..e6c5161a 100644 --- a/src/routes/book.ts +++ b/src/routes/book.ts @@ -7,6 +7,7 @@ import '../middleware/network' import { getHorizonServer } from '../network/clients' import { aggregateBookLevels, generateAmmBookLevels, type AmmBookPool, type RawBookLevel } from '../pricing/book' import { bookResponseSchema } from '../api/schemas' +import { apiError } from '../errors' const BookQuerySchema = z.object({ depth: z.coerce.number().min(0).max(0.5).default(0.1), @@ -138,7 +139,7 @@ export async function registerBookRoutes(app: FastifyInstance) { }>('/book/:assetA/:assetB', { config: { scope: 'read:prices' }, schema: { response: { 200: bookResponseSchema, 503: bookResponseSchema } } }, async (req, reply) => { const parsedQuery = BookQuerySchema.safeParse(req.query) if (!parsedQuery.success) { - return reply.status(400).send({ error: parsedQuery.error.issues[0]?.message ?? 'Invalid book parameters' }) + return reply.status(400).send(apiError('invalid_parameter', parsedQuery.error.issues[0]?.message ?? 'Invalid book parameters')) } let assetA: ParsedAsset @@ -148,7 +149,7 @@ export async function registerBookRoutes(app: FastifyInstance) { assetB = parseAsset(req.params.assetB) if (assetA.canonical === assetB.canonical) throw new Error('Assets must be different') } catch (err) { - return reply.status(400).send({ error: (err as Error).message }) + return reply.status(400).send(apiError('invalid_parameter', (err as Error).message)) } const network: NetworkName = req.network ?? activeNetwork diff --git a/src/routes/candles.ts b/src/routes/candles.ts index df79a2a3..99ad831a 100644 --- a/src/routes/candles.ts +++ b/src/routes/candles.ts @@ -1,18 +1,23 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' +import { apiError } from '../errors' import { - CANDLES_DEFAULT_LIMIT, - CANDLES_MAX_LIMIT, - decodeCursor, - encodeCursor, parseLimit, + encodeCursor, + decodeCursor, slicePage, + CANDLES_DEFAULT_LIMIT, + CANDLES_MAX_LIMIT, } from '../pagination' -import { activeNetwork, type NetworkName } from '../config' -import { findOverlappingGaps } from '../backfill/gaps' -import '../middleware/network' // declares req.network on the FastifyRequest type -import { formatPrice, formatVolume } from '../pricing/precision' +const INTERVAL_SECONDS: Record = { + '1m': 60, + '5m': 300, + '15m': 900, + '1h': 3600, + '4h': 14_400, + '1d': 86_400, +} const INTERVAL_SECONDS: Record = { '1m': 60, @@ -45,17 +50,23 @@ export async function registerCandleRoutes(app: FastifyInstance) { app.get<{ Params: { assetA: string; assetB: string } Querystring: { interval?: string; from?: string; to?: string; cursor?: string; limit?: string } - }>('/candles/:assetA/:assetB', { config: { scope: 'read:history' } }, async (req, reply) => { + }>('/candles/:assetA/:assetB', async (req, reply) => { const { assetA, assetB } = req.params const interval = req.query.interval ?? '1h' const intervalSecs = INTERVAL_SECONDS[interval] if (!intervalSecs) { - return reply.status(400).send({ - error: `interval must be one of: ${Object.keys(INTERVAL_SECONDS).join(', ')}`, - }) + return reply.status(400).send( + apiError('bad_interval', `interval must be one of: ${Object.keys(INTERVAL_SECONDS).join(', ')}`), + ) } + const parsedLimit = parseLimit(req.query.limit, CANDLES_DEFAULT_LIMIT, CANDLES_MAX_LIMIT) + if ('error' in parsedLimit) { + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) + } + const { limit } = parsedLimit + const parsedLimit = parseLimit(req.query.limit, CANDLES_DEFAULT_LIMIT, CANDLES_MAX_LIMIT) if ('error' in parsedLimit) { return reply.status(400).send({ error: parsedLimit.error }) @@ -76,25 +87,22 @@ export async function registerCandleRoutes(app: FastifyInstance) { const to = toRaw ? new Date(toRaw) : new Date() if (isNaN(from.getTime())) { - return reply.status(400).send({ error: 'from must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'from must be a valid ISO 8601 date')) } if (isNaN(to.getTime())) { - return reply.status(400).send({ error: 'to must be a valid ISO 8601 date' }) + return reply.status(400).send(apiError('invalid_parameter', 'to must be a valid ISO 8601 date')) } - // $5 is the network predicate in the WHERE clause below. It has to be - // pushed here, before the optional cursor and the limit, or those take - // its placeholder and every row is compared against a row count. - const params: unknown[] = [intervalSecs, pairKey, from, to, network] + const params: unknown[] = [intervalSecs, pairKey, from, to] let cursorCondition = '' if (req.query.cursor) { const decoded = decodeCursor(req.query.cursor) if (decoded === null) { - return reply.status(400).send({ error: 'Invalid cursor format' }) + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) } if (decoded !== 'skip') { params.push(Number(decoded.value)) - cursorCondition = ` AND floor(EXTRACT(EPOCH FROM observed_at) / $1) * $1 > $${params.length}` + cursorCondition = ` AND floor(EXTRACT(EPOCH FROM timestamp) / $1) * $1 > $${params.length}` } } params.push(limit + 1) // Fetch one extra to determine hasMore @@ -110,9 +118,9 @@ export async function registerCandleRoutes(app: FastifyInstance) { FROM price_points WHERE pair_key = $2 AND network = $5 - AND observed_at >= $3 - AND observed_at < $4${cursorCondition} - GROUP BY floor(EXTRACT(EPOCH FROM observed_at) / $1) + AND timestamp >= $3 + AND timestamp <= $4${cursorCondition} + GROUP BY floor(EXTRACT(EPOCH FROM timestamp) / $1) ORDER BY time ASC LIMIT $${params.length}`, params diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index b3a6fee5..c4001fa6 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -2,13 +2,8 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { activeNetwork, type NetworkName } from '../config' import type { OracleAdapter } from '../ingest/oracles/adapter' import { reflectorOracleAdapter } from '../ingest/oracles/reflector' -import { - compareOracles, - computeOracleSummaryStatistics, - queryOracleComparisonHistory, - ORACLE_HISTORY_WINDOWS, - type OracleHistoryWindow, -} from '../services/oracleComparison' +import { pgPool } from '../db' +import { apiError } from '../errors' const oracleAdapters: OracleAdapter[] = [reflectorOracleAdapter] @@ -147,26 +142,41 @@ export async function registerOracleRoutes( const assetCode = asset.toUpperCase() const requestedNetwork = req.query.network if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { - return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) } const network: NetworkName = (requestedNetwork as NetworkName) ?? req.network ?? activeNetwork - const comparison = await compareOracles(assetCode, network, adapters) - // CHANGE FROM UPSTREAM (#262): Return 200 with status:"no_data" instead of 404 - // when both Lens and all oracles have no data. The asset code itself is valid, - // the query succeeded — there is just no data yet. A 404 would imply the asset - // is invalid or the endpoint does not exist. - if (!comparison) { - return { - asset: assetCode, - network, - lens: null, - reflector: null, - deviationPct: null, - status: 'no_data', - fetchedAt: new Date().toISOString(), - oracles: [], - } + const [dbResult, oracleResults] = await Promise.all([ + pgPool.query( + `SELECT + COALESCE(SUM(price::numeric * base_volume::numeric), 0) + / NULLIF(SUM(base_volume::numeric), 0) AS vwap + FROM price_points + WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 + AND timestamp > NOW() - INTERVAL '5 minutes' + GROUP BY pair_key + LIMIT 1`, + [assetCode, network] + ), + Promise.all(adapters.map(async adapter => { + if (!adapter.isConfigured(network)) { + return { name: adapter.name, configured: false as const, quote: null } + } + try { + return { name: adapter.name, configured: true as const, quote: await adapter.getPrice(assetCode, network) } + } catch { + // A failed oracle should be visible in the response instead of + // causing the comparison endpoint to silently omit that source. + return { name: adapter.name, configured: true as const, quote: null } + } + })) + ]) + + const lensPrice = dbResult.rows[0] ? parseFloat(dbResult.rows[0].vwap) : null + const anyOracleConfigured = oracleResults.some(result => result.configured) + if (lensPrice === null && !anyOracleConfigured) { + return reply.status(404).send(apiError('unknown_pair', `No price data found for asset ${assetCode}`)) } return comparison } diff --git a/src/routes/pairs.ts b/src/routes/pairs.ts index ee337217..55a969e4 100644 --- a/src/routes/pairs.ts +++ b/src/routes/pairs.ts @@ -11,7 +11,15 @@ import { slicePage, } from '../pagination' import '../middleware/network' // declares req.network on the FastifyRequest type -import { admin_auth_failures_total } from '../metrics' +import { apiError } from '../errors' +import { + parseLimit, + encodeCursor, + decodeCursor, + slicePage, + PAIRS_DEFAULT_LIMIT, + PAIRS_MAX_LIMIT, +} from '../pagination' export async function registerPairsRoutes(app: FastifyInstance) { // GET /pairs?cursor=&limit= — list active pairs with latest price metadata @@ -31,16 +39,13 @@ export async function registerPairsRoutes(app: FastifyInstance) { // limit+1 probe are applied to the merged list; the cursor carries the last // page's pairKey, so a pair registered mid-paging cannot shift a page // boundary the way an offset would. - app.get<{ Querystring: { cursor?: string; limit?: string } }>( - '/pairs', - { config: { scope: 'read:prices' } }, - async (req, reply) => { + app.get<{ Querystring: { cursor?: string; limit?: string } }>('/pairs', async (req, reply) => { const activePairs = getActivePairs() const network = req.network ?? activeNetwork const parsedLimit = parseLimit(req.query.limit, PAIRS_DEFAULT_LIMIT, PAIRS_MAX_LIMIT) if ('error' in parsedLimit) { - return reply.status(400).send({ error: parsedLimit.error }) + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) } const { limit } = parsedLimit @@ -48,7 +53,7 @@ export async function registerPairsRoutes(app: FastifyInstance) { if (req.query.cursor) { const decoded = decodeCursor(req.query.cursor) if (decoded === null) { - return reply.status(400).send({ error: 'Invalid cursor format' }) + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) } if (decoded !== 'skip') afterKey = String(decoded.value) } @@ -92,8 +97,10 @@ export async function registerPairsRoutes(app: FastifyInstance) { const pageSource = afterKey ? rows.filter(r => r.pairKey > afterKey) : rows const { data, hasMore } = slicePage(pageSource, limit) - const nextCursor = hasMore && data.length > 0 ? encodeCursor(data[data.length - 1].pairKey, data[data.length - 1].pairKey) : null - + const nextCursor = + hasMore && data.length > 0 + ? encodeCursor(data[data.length - 1].pairKey, data[data.length - 1].pairKey) + : null return { pairs: data, nextCursor, hasMore } }) @@ -106,38 +113,33 @@ export async function registerPairsRoutes(app: FastifyInstance) { const ADMIN_API_KEY = process.env.ADMIN_API_KEY const key = req.headers['x-admin-key'] ?? req.headers['authorization']?.replace(/^Bearer /, '') if (!ADMIN_API_KEY || key !== ADMIN_API_KEY) { - admin_auth_failures_total.inc() - return reply.status(401).send({ - error: 'Unauthorized', - code: 'admin_unauthorized', - message: 'Provide valid admin authentication.', - }) + return reply.status(401).send(apiError('unauthorized', 'Unauthorized — provide a valid X-Admin-Key header')) } const { assetA: assetAStr, assetB: assetBStr } = req.body ?? {} if (!assetAStr || !assetBStr) { - return reply.status(400).send({ error: 'assetA and assetB are required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetA and assetB are required')) } const assetA = parseAssetStr(assetAStr) if (!assetA) { - return reply.status(400).send({ - error: `Invalid assetA format "${assetAStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`, - }) + return reply.status(400).send( + apiError('invalid_parameter', `Invalid assetA format "${assetAStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`), + ) } const assetB = parseAssetStr(assetBStr) if (!assetB) { - return reply.status(400).send({ - error: `Invalid assetB format "${assetBStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`, - }) + return reply.status(400).send( + apiError('invalid_parameter', `Invalid assetB format "${assetBStr}". Expected CODE or CODE:ISSUER (e.g. XLM:native or USDC:GBBD47...)`), + ) } const pairKey = makePairKey(assetA, assetB) if (hasPair(pairKey)) { - return reply.status(409).send({ error: `Pair ${pairKey} is already being watched` }) + return reply.status(409).send(apiError('pair_already_watched', `Pair ${pairKey} is already being watched`)) } const pair = { assetA, assetB, pairKey } diff --git a/src/routes/price.ts b/src/routes/price.ts index 56a8d874..64182392 100644 --- a/src/routes/price.ts +++ b/src/routes/price.ts @@ -4,8 +4,7 @@ import { z } from 'zod' import { formatValidationError } from '../validation' import '../middleware/network' import { activeNetwork, type NetworkName } from '../config' -import { findOverlappingGaps } from '../backfill/gaps' -import { resolveDecimalFormat } from '../utils/decimals' +import { apiError } from '../errors' export const TWAP_WINDOW_MIN_MINUTES = 1 export const TWAP_WINDOW_MAX_MINUTES = 1440 @@ -65,7 +64,7 @@ export async function registerPriceRoutes(app: FastifyInstance) { const { assetA, assetB } = req.params const parsed = TwapQuerySchema.safeParse(req.query) if (!parsed.success) { - return reply.status(400).send(formatValidationError(parsed.error)) + return reply.status(400).send(apiError('invalid_parameter', parsed.error.issues[0].message || 'Invalid parameters')) } const { window: windowMinutes, sampleInterval, method, staleThreshold } = parsed.data const network: NetworkName = req.network ?? parsed.data.network ?? activeNetwork @@ -116,8 +115,8 @@ export async function registerPriceRoutes(app: FastifyInstance) { } catch (err) { // A driver error message can carry the connection string, and so the // Postgres credentials. Log it; never put it in the response body. - log.error({ err }, 'TWAP computation failed') - return reply.status(500).send({ error: 'TWAP computation failed' }) + console.error('[price] TWAP computation failed:', (err as Error).message) + return reply.status(500).send(apiError('upstream_unavailable', 'TWAP computation failed')) } } ) @@ -142,7 +141,7 @@ export async function registerPriceRoutes(app: FastifyInstance) { const { assetA, assetB } = req.params const parsed = VwapQuerySchema.safeParse(req.query) if (!parsed.success) { - return reply.status(400).send(formatValidationError(parsed.error)) + return reply.status(400).send(apiError('invalid_parameter', parsed.error.issues[0].message || 'Invalid parameters')) } const { window: windowMinutes, source, method, staleThreshold } = parsed.data const decimalFormat = resolveDecimalFormat(req) @@ -194,8 +193,8 @@ export async function registerPriceRoutes(app: FastifyInstance) { } } catch (err) { // Same as TWAP above: the driver message can contain credentials. - log.error({ err }, 'VWAP computation failed') - return reply.status(500).send({ error: 'VWAP computation failed' }) + console.error('[price] VWAP computation failed:', (err as Error).message) + return reply.status(500).send(apiError('upstream_unavailable', 'VWAP computation failed')) } } ) diff --git a/src/routes/screener.ts b/src/routes/screener.ts index 813be4c2..6b902992 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -11,6 +11,15 @@ import { SCREENER_DEFAULT_LIMIT, } from '../services/screener' import '../middleware/network' // declares req.network on the FastifyRequest type +import { apiError } from '../errors' +import { + parseLimit, + encodeCursor, + decodeCursor, + slicePage, + SCREENER_DEFAULT_LIMIT, + SCREENER_MAX_LIMIT, +} from '../pagination' // The query execution (filters, sorting, cursor pagination, the liquidity // definition and the [1, 100] limit clamp) lives in src/services/screener.ts @@ -90,25 +99,153 @@ export async function registerScreenerRoutes(app: FastifyInstance) { try { const q = req.query - const parsed = ScreenerQuerySchema.safeParse(q) - if (!parsed.success) { - // market_cap is not a validation error but an unsupported parameter; - // it keeps its own machine-readable code while using the shared shape. - const marketCapRejected = parsed.error.issues.some( - (issue) => issue.path[0] === 'market_cap' - ) - return reply - .status(400) - .send( - formatValidationError( - parsed.error, - marketCapRejected ? MARKET_CAP_CODE : VALIDATION_ERROR_CODE - ) - ) + // 1. Parse and validate pagination & sorting + const parsedLimit = parseLimit(q.limit, SCREENER_DEFAULT_LIMIT, SCREENER_MAX_LIMIT) + if ('error' in parsedLimit) { + return reply.status(400).send(apiError('invalid_parameter', parsedLimit.error)) + } + const limit = parsedLimit.limit + + if (q.market_cap !== undefined || q.sortBy === 'market_cap') { + return reply.status(400).send(apiError('unsupported', MARKET_CAP_UNSUPPORTED)) + } + + const allowedSortFields = ['volume', 'change_24h', 'price', 'liquidity'] + const sortBy = q.sortBy ?? 'volume' + if (!allowedSortFields.includes(sortBy)) { + return reply.status(400).send(apiError('invalid_parameter', `sortBy must be one of: ${allowedSortFields.join(', ')}`)) + } + + const order = (q.order ?? 'desc').toLowerCase() + if (order !== 'asc' && order !== 'desc') { + return reply.status(400).send(apiError('invalid_parameter', 'order must be asc or desc')) } const { limit, sortBy, order, cursor } = parsed.data + const filters = { + volume: parseNum(q.volume), + change_24h_min: parseNum(q.change_24h_min), + change_24h_max: parseNum(q.change_24h_max), + price_min: parseNum(q.price_min), + price_max: parseNum(q.price_max), + liquidity: parseNum(q.liquidity), + } + + for (const [key, val] of Object.entries(filters)) { + if (val !== undefined && isNaN(val)) { + return reply.status(400).send(apiError('invalid_parameter', `Filter ${key} must be a valid number`)) + } + } - return await runScreener({ + // 3. Build query parameters and WHERE conditions + const conditions: string[] = [] + // Every CTE below is scoped to one network: testnet and mainnet rows share + // these tables, and pooling them would rank a pair on the other chain's data. + const params: any[] = [req.network ?? activeNetwork] + let paramIdx = 2 + + if (filters.volume !== undefined) { + conditions.push(`volume >= $${paramIdx++}`) + params.push(filters.volume) + } + if (filters.change_24h_min !== undefined) { + conditions.push(`change_24h >= $${paramIdx++}`) + params.push(filters.change_24h_min) + } + if (filters.change_24h_max !== undefined) { + conditions.push(`change_24h <= $${paramIdx++}`) + params.push(filters.change_24h_max) + } + if (filters.price_min !== undefined) { + conditions.push(`price >= $${paramIdx++}`) + params.push(filters.price_min) + } + if (filters.price_max !== undefined) { + conditions.push(`price <= $${paramIdx++}`) + params.push(filters.price_max) + } + if (filters.liquidity !== undefined) { + conditions.push(`liquidity >= $${paramIdx++}`) + params.push(filters.liquidity) + } + + // 4. Cursor decoding and pagination condition + if (q.cursor) { + const decoded = decodeCursor(q.cursor) + if (decoded === null) { + return reply.status(400).send(apiError('invalid_parameter', 'Invalid cursor format')) + } + if (decoded !== 'skip') { + const op = order === 'desc' ? '<' : '>' + conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) + params.push(decoded.value, decoded.id) + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + // 5. Construct the full SQL query using CTEs + const sql = ` + WITH pair_pools AS ( + SELECT DISTINCT pair_key, pool_id + FROM price_points + WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL + ), + latest_snapshots AS ( + SELECT DISTINCT ON (ps.pool_id) + ps.pool_id, + (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq + FROM pool_snapshots ps + WHERE ps.network = $1 + ORDER BY ps.pool_id, ps.timestamp DESC + ), + pair_liquidity AS ( + SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity + FROM pair_pools pp + JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id + GROUP BY pp.pair_key + ), + latest_aggregates AS ( + SELECT DISTINCT ON (pair_key) + pair_key, + COALESCE(volume::float, 0) AS volume, + COALESCE(close_price::float, 0) AS price, + CASE + WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 + ELSE 0 + END AS change_24h + FROM price_aggregates + WHERE network = $1 AND window = '24h' + ORDER BY pair_key, bucket DESC + ), + screener_data AS ( + SELECT + la.pair_key, + la.volume, + la.price, + la.change_24h, + COALESCE(pl.liquidity::float, 0) AS liquidity + FROM latest_aggregates la + LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key + ) + SELECT * FROM screener_data + ${whereClause} + ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} + LIMIT $${paramIdx} + ` + + // 6. Execute query + params.push(limit + 1) // Fetch one extra to determine hasMore + const result = await pgPool.query(sql, params) + const { data, hasMore } = slicePage(result.rows, limit) + + // 7. Generate nextCursor + let nextCursor = null + if (hasMore && data.length > 0) { + const lastRecord = data[data.length - 1] + nextCursor = encodeCursor(lastRecord[sortBy], lastRecord.pair_key) + } + + return { network: req.network ?? activeNetwork, filters: { volume: parsed.data.volume, @@ -124,13 +261,8 @@ export async function registerScreenerRoutes(app: FastifyInstance) { limit, }) } catch (error) { - // A malformed cursor is the caller's mistake and gets the same 400 body - // the route has always returned for it. - if (error instanceof ScreenerCursorError) { - return reply.status(400).send({ error: error.message, code: error.code }) - } - log.error({ err: error }, 'screener query failed') - return reply.status(500).send({ error: 'Internal server error' }) + console.error('[screener]', error) + return reply.status(500).send(apiError('internal_error', 'Internal server error')) } }) } diff --git a/src/routes/spreads.ts b/src/routes/spreads.ts index ed327cbc..bf0d867a 100644 --- a/src/routes/spreads.ts +++ b/src/routes/spreads.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' -import { sendInternalError } from '../errors' +import { apiError } from '../errors' // Supported windows → lookback in minutes. A spread is a statement about *now*, // so these are deliberately short: widen the window far enough and you stop @@ -74,16 +74,34 @@ export async function registerSpreadsRoutes(app: FastifyInstance) { const { asset } = req.params if (!asset) { - return reply.status(400).send({ error: 'Asset parameter is required' }) + return reply.status(400).send(apiError('missing_parameter', 'Asset parameter is required')) } + if (!(window in WINDOW_MINUTES)) { + return reply + .status(400) + .send(apiError('bad_window', `window must be one of: ${WINDOWS.join(', ')}`)) + } + + // Prices from two chains are not comparable, and a spread computed across + // both is not a wide spread — it is a meaningless one. Default to this + // process's own network rather than silently pooling them. + const requested = req.query.network + if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) + } + const network: NetworkName = (requested as NetworkName) ?? activeNetwork + + const minutes = WINDOW_MINUTES[window as SpreadWindow] + const endTime = new Date() + const startTime = new Date(endTime.getTime() - minutes * 60 * 1000) + try { return await computeSpreads(asset, req.query.window, req.query.network) } catch (err) { - if (err instanceof SpreadsInputError) { - return reply.status(400).send({ error: err.message }) - } - return sendInternalError(reply, req, err, 'Spread aggregation failed') + return reply + .status(500) + .send(apiError('upstream_unavailable', `Spread aggregation failed: ${(err as Error).message}`)) } }) } diff --git a/src/routes/volumes.ts b/src/routes/volumes.ts index 5cfe5877..762310ff 100644 --- a/src/routes/volumes.ts +++ b/src/routes/volumes.ts @@ -1,8 +1,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' -import { sendInternalError } from '../errors' -import { isAggregatorVenue } from '../ingest/venues' +import { apiError } from '../errors' // Supported windows → lookback in hours. const WINDOW_HOURS = { @@ -42,24 +41,20 @@ export async function registerVolumeRoutes(app: FastifyInstance) { }>('/volumes/:asset', { config: { scope: 'read:prices' } }, async (req, reply) => { const { asset } = req.params - try { - return await computeVolumes(asset, req.query.window, req.query.network, req.network) - } catch (err) { - if (err instanceof VolumesInputError) { - return reply.status(400).send({ error: err.message }) - } - return sendInternalError(reply, req, err, 'Volume aggregation failed') + if (!(window in WINDOW_HOURS)) { + return reply + .status(400) + .send(apiError('bad_window', `window must be one of: ${WINDOWS.join(', ')}`)) } }) } -/** A caller-facing validation failure: REST answers it with a 400, GraphQL with INVALID_INPUT. */ -export class VolumesInputError extends Error { - constructor(readonly field: 'window' | 'network', message: string) { - super(message) - this.name = 'VolumesInputError' - } -} + const requested = req.query.network + if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { + return reply.status(400).send(apiError('bad_network', 'network must be one of: testnet, mainnet')) + } + const network: NetworkName = req.network + ?? (requested ? requested as NetworkName : activeNetwork) export type VolumesResult = Awaited> @@ -93,122 +88,44 @@ export async function computeVolumes( const network: NetworkName = requestNetwork ?? (requested ? requested as NetworkName : activeNetwork) - const hours = WINDOW_HOURS[window as VolumeWindow] - const endTime = new Date() - const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000) - - // The CTE gives each observation its volume in the requested asset's own - // units. Statistics are aggregated in Postgres so a long window does not - // require sending every trade to the API process. - const { rows } = await pgPool.query( - `WITH asset_trades AS ( - SELECT source, - timestamp, - (CASE - WHEN asset_a = $1 THEN base_volume - WHEN asset_b = $1 THEN counter_volume - END)::numeric AS volume - FROM price_points - WHERE (asset_a = $1 OR asset_b = $1) - AND network = $2 - AND COALESCE(observed_at, timestamp) >= $3 - ), - venue_totals AS ( - SELECT source, - SUM(volume)::float8 AS volume, - (COUNT(*) FILTER (WHERE volume > 0))::int AS trade_count - FROM asset_trades - GROUP BY source - ), - trade_stats AS ( - SELECT (COUNT(*) FILTER (WHERE volume > 0))::int AS trade_count, - COUNT(*)::int AS observed_sample_count, - SUM(volume)::float8 AS total_volume, - percentile_cont(0.5) WITHIN GROUP (ORDER BY volume::float8) - FILTER (WHERE volume > 0) AS median_trade_size, - (MAX(volume) FILTER (WHERE volume > 0))::float8 - / NULLIF((SUM(volume) FILTER (WHERE volume > 0))::float8, 0) AS top_trade_volume_share, - (SUM(volume * volume) FILTER (WHERE volume > 0))::float8 - / NULLIF(POWER((SUM(volume) FILTER (WHERE volume > 0))::float8, 2), 0) AS volume_concentration, - MAX(timestamp) FILTER (WHERE volume > 0) AS latest_trade_at - FROM asset_trades - ), - venue_summary AS ( - SELECT COALESCE(jsonb_object_agg(source, volume) FILTER (WHERE volume > 0), '{}'::jsonb) AS by_venue, - COALESCE(jsonb_agg(source ORDER BY source) FILTER (WHERE volume > 0), '[]'::jsonb) AS venues, - COALESCE(jsonb_object_agg(source, trade_count) FILTER (WHERE volume > 0), '{}'::jsonb) AS trade_count_by_venue - FROM venue_totals - ) - SELECT trade_stats.*, venue_summary.by_venue, venue_summary.venues, venue_summary.trade_count_by_venue - FROM trade_stats CROSS JOIN venue_summary`, - [asset, network, startTime], - ) - - const row = rows[0] ?? {} - const tradeCount = Number(row.trade_count) || 0 - const observedSampleCount = Number(row.observed_sample_count) || 0 - const totalVolume = Number(row.total_volume) || 0 - const latestTradeAt = row.latest_trade_at == null - ? null - : new Date(row.latest_trade_at as string | Date).toISOString() - const dataAgeSeconds = latestTradeAt === null - ? null - : Math.max(0, Math.floor((endTime.getTime() - new Date(latestTradeAt).getTime()) / 1000)) - const status = observedSampleCount === 0 - ? 'no_trades' - : tradeCount === 0 || totalVolume <= 0 - ? 'no_positive_volume' - : 'ok' - - // Net excludes aggregator venues, which re-report trades that already - // settled on a primary market; gross is everything observed. The - // distribution statistics above (median, HHI, status) stay on gross. - const byVenue: Record = row.by_venue ?? {} - const tradesByVenue: Record = row.trade_count_by_venue ?? {} - let aggregatorVolume = 0 - let aggregatorTradeCount = 0 - for (const [source, volume] of Object.entries(byVenue)) { - if (!isAggregatorVenue(source)) continue - aggregatorVolume += Number(volume) || 0 - aggregatorTradeCount += Number(tradesByVenue[source]) || 0 - } - const grossVolume = totalVolume - const grossTradeCount = tradeCount - const netVolume = Math.max(0, grossVolume - aggregatorVolume) - const netTradeCount = Math.max(0, grossTradeCount - aggregatorTradeCount) - - return { - asset, - network, - window, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - // Labelled net and gross totals to eliminate aggregator double-counting. - totals: { - net: netVolume, - gross: grossVolume, - }, - netVolume, - grossVolume, - // Backward-compatible alias for net volume. - totalVolume: netVolume, - // Per-venue breakdown that the totals are summed from. - byVenue, - venues: row.venues ?? [], - tradeCount: netTradeCount, - netTradeCount, - grossTradeCount, - medianTradeSize: row.median_trade_size == null ? null : Number(row.median_trade_size), - topTradeVolumeShare: row.top_trade_volume_share == null ? null : Number(row.top_trade_volume_share), - volumeConcentration: row.volume_concentration == null ? null : Number(row.volume_concentration), - concentrationMeasure: 'hhi', - status, - provenance: { - venues: row.venues ?? [], - sampleCount: grossTradeCount, - observedSampleCount, - latestTradeAt, - dataAgeSeconds, - }, - } + return { + asset, + network, + window, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + // Labelled net and gross totals to eliminate aggregator double-counting. + totals: { + net: netVolume, + gross: grossVolume, + }, + netVolume, + grossVolume, + // Backward-compatible alias for net volume. + totalVolume: netVolume, + // Per-venue breakdown that the totals are summed from. + byVenue, + venues: row.venues ?? [], + tradeCount: netTradeCount, + netTradeCount, + grossTradeCount, + medianTradeSize: row.median_trade_size == null ? null : Number(row.median_trade_size), + topTradeVolumeShare: row.top_trade_volume_share == null ? null : Number(row.top_trade_volume_share), + volumeConcentration: row.volume_concentration == null ? null : Number(row.volume_concentration), + concentrationMeasure: 'hhi', + status, + provenance: { + venues: row.venues ?? [], + sampleCount: grossTradeCount, + observedSampleCount, + latestTradeAt, + dataAgeSeconds, + }, + } + } catch (err) { + return reply + .status(500) + .send(apiError('upstream_unavailable', `Volume aggregation failed: ${(err as Error).message}`)) + } + }) } diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 2366ac2f..eec21245 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'crypto' import { prisma } from '../db' import { activeNetwork, type NetworkName } from '../config' import { resolveNetworkName } from '../middleware/network' +import { apiError } from '../errors' function isValidHttpsUrl(raw: string): boolean { try { @@ -21,26 +22,26 @@ export async function registerWebhookRoutes(app: FastifyInstance) { const { url, assetA, assetB, threshold, direction, network: bodyNetwork } = req.body ?? {} if (!url || !isValidHttpsUrl(url)) { - return reply.status(400).send({ error: 'url must be a valid HTTPS URL' }) + return reply.status(400).send(apiError('invalid_parameter', 'url must be a valid HTTPS URL')) } if (!assetA || typeof assetA !== 'string') { - return reply.status(400).send({ error: 'assetA is required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetA is required')) } if (!assetB || typeof assetB !== 'string') { - return reply.status(400).send({ error: 'assetB is required' }) + return reply.status(400).send(apiError('missing_parameter', 'assetB is required')) } if (typeof threshold !== 'number' || isNaN(threshold)) { - return reply.status(400).send({ error: 'threshold must be a number' }) + return reply.status(400).send(apiError('invalid_parameter', 'threshold must be a number')) } if (direction !== 'above' && direction !== 'below') { - return reply.status(400).send({ error: 'direction must be "above" or "below"' }) + return reply.status(400).send(apiError('invalid_parameter', 'direction must be "above" or "below"')) } let targetNetwork: NetworkName = req.network ?? activeNetwork if (bodyNetwork !== undefined) { const resolved = resolveNetworkName(bodyNetwork) if (!resolved.ok) { - return reply.status(400).send({ error: resolved.error }) + return reply.status(400).send(apiError('bad_network', resolved.error)) } targetNetwork = resolved.network }