diff --git a/README.md b/README.md index 41f428f7..129fa696 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,6 @@ The Token API provides access to onchain NFT and fungible token data, including | `LARGE_QUERIES_ROWS_TRIGGER` | Row threshold for large-query metrics | `10000000` | No | | `LARGE_QUERIES_BYTES_TRIGGER` | Byte threshold for large-query metrics | `1000000000` | No | | `SKIP_NETWORKS_VALIDATION` | Skip startup validation that configured networks exist in ClickHouse | `false` | No | -| `PLANS` | Plan limits as `name:limit,batched,intervals` entries | empty (disabled) | No | | `PRETTY_LOGGING` | Enable pretty console logging | `false` | No | | `VERBOSE` | Enable verbose logging | `false` | No | diff --git a/index.ts b/index.ts index 2be6860c..6d171e2f 100644 --- a/index.ts +++ b/index.ts @@ -11,13 +11,7 @@ const app = new Hono(); // Tracking all incoming requests app.use(async (c: Context, next) => { - const pathname = c.req.path; - logger.trace(`Incoming request: '${pathname}'`); - - // Set `X-Plan` to free by default if none received - // This will have no effect unless `config.plans` is setup through ENV or CLI - if (!c.req.header('X-Plan')) c.req.raw.headers.set('X-Plan', 'free'); - + logger.trace(`Incoming request: '${c.req.path}'`); await next(); }); diff --git a/src/config.spec.ts b/src/config.spec.ts deleted file mode 100644 index 36a8dc88..00000000 --- a/src/config.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'bun:test'; -import { parsePlans } from './config.js'; - -describe('parsePlans', () => { - it('should return null for empty string', () => { - expect(parsePlans('')).toBeNull(); - }); - - it('should return null for whitespace-only string', () => { - expect(parsePlans(' ')).toBeNull(); - }); - - it('should parse a single plan', () => { - const result = parsePlans('starter:100,5,1m|5m|15m'); - expect(result).toBeInstanceOf(Map); - expect(result?.size).toBe(2); // original + tgm- prefixed - expect(result?.get('starter')).toEqual({ - maxLimit: 100, - maxBatched: 5, - allowedIntervals: ['1m', '5m', '15m'], - }); - expect(result?.get('tgm-STARTER')).toEqual({ - maxLimit: 100, - maxBatched: 5, - allowedIntervals: ['1m', '5m', '15m'], - }); - }); - - it('should parse multiple plans separated by semicolons', () => { - const result = parsePlans('free:10,1,1m;pro:1000,50,1m|5m|15m|1h'); - expect(result).toBeInstanceOf(Map); - expect(result?.size).toBe(4); // 2 plans + 2 tgm- prefixed - expect(result?.get('free')).toEqual({ - maxLimit: 10, - maxBatched: 1, - allowedIntervals: ['1m'], - }); - expect(result?.get('pro')).toEqual({ - maxLimit: 1000, - maxBatched: 50, - allowedIntervals: ['1m', '5m', '15m', '1h'], - }); - }); - - it('should handle plan with empty intervals', () => { - const result = parsePlans('basic:50,3,'); - expect(result).toBeInstanceOf(Map); - expect(result?.get('basic')).toEqual({ - maxLimit: 50, - maxBatched: 3, - allowedIntervals: [], - }); - }); - - it('should not duplicate tgm- prefixed plans', () => { - const result = parsePlans('tgm-PRO:500,10,1m|5m'); - expect(result).toBeInstanceOf(Map); - expect(result?.size).toBe(1); // only the original, no extra tgm- prefix - expect(result?.get('tgm-PRO')).toEqual({ - maxLimit: 500, - maxBatched: 10, - allowedIntervals: ['1m', '5m'], - }); - }); - - it('should throw on malformed plan entry (no colon separator)', () => { - expect(() => parsePlans('invalid-plan')).toThrow('Malformed plan entry'); - }); - - it('should throw on invalid limits format (wrong number of comma-separated values)', () => { - expect(() => parsePlans('bad:100,5')).toThrow('Invalid limits format'); - }); - - it('should throw on non-numeric limit', () => { - expect(() => parsePlans('bad:abc,5,1m')).toThrow('Invalid numeric limits'); - }); - - it('should throw on non-numeric batched', () => { - expect(() => parsePlans('bad:100,xyz,1m')).toThrow('Invalid numeric limits'); - }); -}); diff --git a/src/config.ts b/src/config.ts index 839d1477..99ba46e6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,8 +39,6 @@ export const DEFAULT_SKIP_NETWORKS_VALIDATION = false; export const DEFAULT_CACHE_SERVER_MAX_AGE = 600; // s-maxage for shared/proxy caches export const DEFAULT_CACHE_MAX_AGE = 60; // max-age for browser caches export const DEFAULT_CACHE_STALE_WHILE_REVALIDATE = 30; // RFC 5861 stale-while-revalidate window -export const DEFAULT_PLANS = ''; - // GitHub metadata const GIT_COMMIT = (process.env.GIT_COMMIT ?? (await $`git rev-parse HEAD`.text())).replace(/\n/, '').slice(0, 7); const GIT_DATE = (process.env.GIT_DATE ?? (await $`git log -1 --format=%cd --date=short`.text())).replace(/\n/, ''); @@ -58,65 +56,6 @@ export const APP_NAME = pkg.name; export const APP_DESCRIPTION = pkg.description; export const APP_VERSION = `${GIT_APP.version}+${GIT_APP.commit} (${GIT_APP.date})`; -/** - * Parse plan configuration string into a Map. - * Format: "name:limit,batched,intervals;name2:limit,batched,intervals" - * Intervals are pipe-separated: "1m|5m|15m" - * Returns null if input is empty (bypasses plan limits for local development). - */ -export function parsePlans(val: string) { - if (!val || val.trim() === '') { - return null; - } - - const plans = new Map< - string, - { - maxLimit: number; - maxBatched: number; - allowedIntervals: string[]; - } - >(); - - val.split(';').forEach((planDef) => { - const [name, limits] = planDef.split(':'); - if (!name || !limits) { - throw new Error(`Malformed plan entry: "${planDef}". Skipping.`); - } - - // Format: name:limit,batched,intervals - const parts = limits.split(','); - if (parts.length !== 3) { - throw new Error(`Invalid limits format for plan "${name}". Expected 3 values.`); - } - - const [limit, batched, intervals] = parts; - const maxLimit = Number(limit); - const maxBatched = Number(batched); - const allowedIntervals = intervals ? intervals.split('|').filter((s) => s.length > 0) : []; - - if (Number.isNaN(maxLimit) || Number.isNaN(maxBatched)) { - throw new Error(`Invalid numeric limits for plan "${name}".`); - } - - plans.set(name, { - maxLimit, - maxBatched, - allowedIntervals, - }); - - if (!name.startsWith('tgm-')) { - plans.set(`tgm-${name.toUpperCase()}`, { - maxLimit, - maxBatched, - allowedIntervals, - }); - } - }); - - return plans; -} - // parse command line options const opts = program .name(pkg.name) @@ -250,11 +189,6 @@ const opts = program .env('CACHE_STALE_WHILE_REVALIDATE') .default(DEFAULT_CACHE_STALE_WHILE_REVALIDATE) ) - .addOption( - new Option('--plans ', 'Plan configurations (name:limit,batched,intervals)') - .env('PLANS') - .default(DEFAULT_PLANS) - ) .allowUnknownOption() .allowExcessArguments() .parse() @@ -293,7 +227,6 @@ const config = z cacheServerMaxAge: z.coerce.number().nonnegative('Cache server max-age must be non-negative'), cacheMaxAge: z.coerce.number().nonnegative('Cache max-age must be non-negative'), cacheStaleWhileRevalidate: z.coerce.number().nonnegative('Cache stale-while-revalidate must be non-negative'), - plans: z.string().transform(parsePlans), }) .transform((data) => { // Use YAML config as the authoritative source — spread all database maps dynamically diff --git a/src/routes/routes.perf.spec.ts b/src/routes/routes.perf.spec.ts index db0c6ad2..1ad09a3d 100644 --- a/src/routes/routes.perf.spec.ts +++ b/src/routes/routes.perf.spec.ts @@ -587,7 +587,7 @@ function getNetworkForChain(chain: ChainType): string { async function fetchRoute(url: string): Promise<{ status: number; body: any; duration_ms: number }> { const start = performance.now(); - const response = await app.request(url, { headers: { 'X-Plan': 'free', 'Cache-Control': 'no-cache' } }); + const response = await app.request(url, { headers: { 'Cache-Control': 'no-cache' } }); const body = await response.json(); const duration_ms = Math.round((performance.now() - start) * 100) / 100; return { status: response.status, body, duration_ms }; @@ -613,8 +613,6 @@ const allResults: TestResult[] = []; describe.skipIf(!DB_TESTS)('Database performance', () => { beforeAll(async () => { const { config } = await import('../config.js'); - // Bypass plan limits for tests - (config as any).plans = null; app = new Hono(); const routes = await import('./index.js'); diff --git a/src/routes/routes.sql.spec.ts b/src/routes/routes.sql.spec.ts index 27c50058..96d54e42 100644 --- a/src/routes/routes.sql.spec.ts +++ b/src/routes/routes.sql.spec.ts @@ -207,7 +207,7 @@ const SINGLE_FILTER_ROUTE_CASES = [ ] as const; async function fetchRoute(path: string) { - const response = await app.request(path, { headers: { 'X-Plan': 'free' } }); + const response = await app.request(path); const body = await response.json(); return { response, body }; } @@ -238,9 +238,6 @@ describe.skipIf(!DB_TESTS)('SQL queries', () => { const { config } = await import('../config.js'); const { hasDatabase } = await import('../supported-routes.js'); - // Bypass plan limits for DB integration tests (config.plans may be mutated by other test files) - (config as any).plans = null; - app = new Hono(); const routes = await import('./index.js'); app.route('/', routes.default); diff --git a/src/routes/swaps/evm.uniswap-v3.sql.spec.ts b/src/routes/swaps/evm.uniswap-v3.sql.spec.ts index d1c78778..2351cecc 100644 --- a/src/routes/swaps/evm.uniswap-v3.sql.spec.ts +++ b/src/routes/swaps/evm.uniswap-v3.sql.spec.ts @@ -13,7 +13,7 @@ let evmNetwork: string; let hasEvmDex: boolean; async function fetchRoute(path: string) { - const response = await app.request(path, { headers: { 'X-Plan': 'free' } }); + const response = await app.request(path); const body = await response.json(); return { response, body }; } @@ -24,8 +24,6 @@ describe.skipIf(!DB_TESTS)('EVM swaps Uniswap V3 regression checks', () => { const { hasDatabase } = await import('../../supported-routes.js'); const { default: evmSwapsRoute } = await import('./evm.js'); - (config as any).plans = null; - app = new Hono(); app.route('/v1/evm/swaps', evmSwapsRoute); diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 86fcc8f9..1baa5311 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -1,13 +1,8 @@ -import { beforeEach, describe, expect, it, mock } from 'bun:test'; +import { describe, expect, it, mock } from 'bun:test'; import type { Context } from 'hono'; import { z } from 'zod'; -import { config } from './config.js'; import { APIErrorResponse, now, validatorHook, withErrorResponses, withTimeout } from './utils.js'; -type ConfigWithPlans = typeof config & { - plans: Map | null; -}; - function createMockContext( overrides: Partial<{ path: string; @@ -143,10 +138,6 @@ describe('now', () => { }); describe('validatorHook', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = null; - }); - it('should set validated data on successful parse', () => { const ctx = createMockContext(); const parseResult = { @@ -229,19 +220,16 @@ describe('validatorHook', () => { expect(validatedDataCalls).toHaveLength(0); }); - describe('Plan Limits', () => { - describe('Config plans is null (local development bypass)', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = null; - }); - - it('should bypass all checks when config.plans is null', () => { + describe('Plan Limits (gateway headers)', () => { + describe('No headers (local dev / direct hit)', () => { + it('should bypass all checks when no limit headers are set', () => { const ctx = createMockContext(); const parseResult = { success: true as const, data: { - limit: 10000, + limit: 10_000, addresses: new Array(1000).fill('0x123'), + interval: 1, }, }; @@ -249,347 +237,130 @@ describe('validatorHook', () => { expect(result).toBeUndefined(); expect(ctx.set).toHaveBeenCalledWith('validatedData', expect.any(Object)); }); - - it('should not require X-Plan header when bypassed', () => { - const ctx = createMockContext(); - const parseResult = { - success: true as const, - data: { limit: 100 }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); - }); - }); - - describe('Invalid parse result', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['starter', { maxLimit: 15, maxBatched: 3, allowedIntervals: ['1d', '1w'] }], - ]); - }); - - it('should return error when parseResult.success is false', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'starter' } }); - const parseResult = { - success: false as const, - error: { issues: [{ message: 'Invalid input' }] }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); - }); }); - describe('Missing or invalid X-Plan header', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['starter', { maxLimit: 15, maxBatched: 3, allowedIntervals: ['1d', '1w'] }], - ]); + describe('x-token-api-items-returned', () => { + it('should allow limit within bounds', () => { + const ctx = createMockContext({ headers: { 'x-token-api-items-returned': '15' } }); + const parseResult = { success: true as const, data: { limit: 15 } }; + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); - it('should return error when X-Plan header is missing', () => { - const ctx = createMockContext(); - const parseResult = { - success: true as const, - data: { limit: 5 }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); + it('should reject limit exceeding header cap', () => { + const ctx = createMockContext({ headers: { 'x-token-api-items-returned': '15' } }); + const parseResult = { success: true as const, data: { limit: 16 } }; + expect(validatorHook(parseResult, ctx)).toBeDefined(); }); - it('should return error when X-Plan header has invalid value', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'nonexistent' } }); - const parseResult = { - success: true as const, - data: { limit: 5 }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); + it('should treat 0 as unlimited', () => { + const ctx = createMockContext({ headers: { 'x-token-api-items-returned': '0' } }); + const parseResult = { success: true as const, data: { limit: 100_000 } }; + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); }); - describe('Starter Plan - Basic limits and batching', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['starter', { maxLimit: 15, maxBatched: 3, allowedIntervals: ['1d', '1w'] }], - ['tgm-STARTER', { maxLimit: 15, maxBatched: 3, allowedIntervals: ['1d', '1w'] }], - ]); - }); - - it('should allow limit within bounds', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'starter' } }); - const parseResult = { - success: true as const, - data: { limit: 15 }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); - expect(ctx.set).toHaveBeenCalled(); - }); - - it('should reject limit exceeding max_limit', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'starter' } }); + describe('x-token-api-batch-size', () => { + it('should allow batched parameters within bounds', () => { + const ctx = createMockContext({ headers: { 'x-token-api-batch-size': '3' } }); const parseResult = { success: true as const, - data: { limit: 16 }, + data: { limit: 10, addresses: ['0x1', '0x2', '0x3'] }, }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); - it('should allow batched parameters within bounds', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'starter' } }); + it('should reject batched parameters exceeding header cap', () => { + const ctx = createMockContext({ headers: { 'x-token-api-batch-size': '3' } }); const parseResult = { success: true as const, - data: { - limit: 10, - addresses: ['0x123', '0x456', '0x789'], - }, + data: { limit: 10, addresses: ['0x1', '0x2', '0x3', '0x4'] }, }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); + expect(validatorHook(parseResult, ctx)).toBeDefined(); }); - it('should reject batched parameters exceeding limit', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'starter' } }); + it('should report all exceeded batched parameters', () => { + const ctx = createMockContext({ headers: { 'x-token-api-batch-size': '3' } }); const parseResult = { success: true as const, data: { limit: 10, - addresses: ['0x123', '0x456', '0x789', '0xabc'], + addresses: ['0x1', '0x2', '0x3', '0x4'], + chain_ids: ['1', '56', '137', '10'], }, }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); + expect(validatorHook(parseResult, ctx)).toBeDefined(); }); - it('should work with tgm- prefixed alias', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'tgm-STARTER' } }); + it('should treat 0 as unlimited', () => { + const ctx = createMockContext({ headers: { 'x-token-api-batch-size': '0' } }); const parseResult = { success: true as const, - data: { limit: 15 }, + data: { addresses: new Array(500).fill('0x1') }, }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); }); - describe('Hobby Plan - OHLCV intervals', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['hobby', { maxLimit: 25, maxBatched: 5, allowedIntervals: ['4h', '1d', '1w'] }], - ['tgm-HOBBY', { maxLimit: 25, maxBatched: 5, allowedIntervals: ['4h', '1d', '1w'] }], - ]); - }); - - it('should allow 4h interval', () => { - const ctx = createMockContext({ - headers: { 'X-Plan': 'hobby' }, - path: '/api/v1/tokens/ohlc', - }); - const nowTimestamp = Math.floor(Date.now() / 1000); - const sevenDaysAgo = nowTimestamp - 7 * 24 * 60 * 60; - - const parseResult = { - success: true as const, - data: { - limit: 20, - interval: 240, - start_time: sevenDaysAgo, - end_time: nowTimestamp, - }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); - }); - - it('should reject 1h interval', () => { - const ctx = createMockContext({ - headers: { 'X-Plan': 'hobby' }, - path: '/api/v1/tokens/ohlc', - }); - const nowTimestamp = Math.floor(Date.now() / 1000); - const oneDayAgo = nowTimestamp - 24 * 60 * 60; - - const parseResult = { - success: true as const, - data: { - limit: 20, - interval: 60, - start_time: oneDayAgo, - end_time: nowTimestamp, - }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); + describe('x-token-api-lowest-time-parameter', () => { + it('should allow interval equal to threshold', () => { + const ctx = createMockContext({ headers: { 'x-token-api-lowest-time-parameter': '4h' } }); + const parseResult = { success: true as const, data: { interval: 240 } }; + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); - }); - describe('Growth Plan - Extended limits', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['growth', { maxLimit: 150, maxBatched: 30, allowedIntervals: ['1h', '4h', '1d', '1w'] }], - ['tgm-GROWTH', { maxLimit: 150, maxBatched: 30, allowedIntervals: ['1h', '4h', '1d', '1w'] }], - ]); + it('should allow coarser interval than threshold', () => { + const ctx = createMockContext({ headers: { 'x-token-api-lowest-time-parameter': '4h' } }); + const parseResult = { success: true as const, data: { interval: 1440 } }; + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); - it('should allow 1h interval', () => { - const ctx = createMockContext({ - headers: { 'X-Plan': 'growth' }, - path: '/api/v1/tokens/ohlc', - }); - const nowTimestamp = Math.floor(Date.now() / 1000); - const oneDayAgo = nowTimestamp - 24 * 60 * 60; - - const parseResult = { - success: true as const, - data: { - limit: 100, - interval: 60, - start_time: oneDayAgo, - end_time: nowTimestamp, - }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); + it('should reject finer interval than threshold', () => { + const ctx = createMockContext({ headers: { 'x-token-api-lowest-time-parameter': '4h' } }); + const parseResult = { success: true as const, data: { interval: 60 } }; + expect(validatorHook(parseResult, ctx)).toBeDefined(); }); - }); - describe('Business Plan - Unlimited OHLCV', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['business', { maxLimit: 750, maxBatched: 100, allowedIntervals: [] }], - ['tgm-BUSINESS', { maxLimit: 750, maxBatched: 100, allowedIntervals: [] }], - ]); + it('should ignore invalid header values', () => { + const ctx = createMockContext({ headers: { 'x-token-api-lowest-time-parameter': 'bogus' } }); + const parseResult = { success: true as const, data: { interval: 1 } }; + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); - it('should allow any interval', () => { - const ctx = createMockContext({ - headers: { 'X-Plan': 'business' }, - path: '/api/v1/tokens/ohlc', - }); - const nowTimestamp = Math.floor(Date.now() / 1000); - const oneDayAgo = nowTimestamp - 24 * 60 * 60; - - const parseResult = { - success: true as const, - data: { - limit: 500, - interval: 60, - start_time: oneDayAgo, - end_time: nowTimestamp, - }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); + it('should not check threshold when interval is absent', () => { + const ctx = createMockContext({ headers: { 'x-token-api-lowest-time-parameter': '1d' } }); + const parseResult = { success: true as const, data: { limit: 10 } }; + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); }); - describe('Elite Plan - All unlimited', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['elite', { maxLimit: 0, maxBatched: 0, allowedIntervals: [] }], - ]); - }); - - it('should allow any interval', () => { + describe('Combined headers', () => { + it('should enforce all three limits together', () => { const ctx = createMockContext({ - headers: { 'X-Plan': 'elite' }, - path: '/api/v1/tokens/historical', - }); - const nowTimestamp = Math.floor(Date.now() / 1000); - const tenYearsAgo = nowTimestamp - 10 * 365 * 24 * 60 * 60; - - const parseResult = { - success: true as const, - data: { - limit: 10000, - interval: 60, - start_time: tenYearsAgo, - end_time: nowTimestamp, - }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); - }); - }); - - describe('Multiple batched parameters exceeding limits', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['starter', { maxLimit: 15, maxBatched: 3, allowedIntervals: ['1d', '1w'] }], - ]); - }); - - it('should report all exceeded batched parameters', () => { - const ctx = createMockContext({ headers: { 'X-Plan': 'starter' } }); - const parseResult = { - success: true as const, - data: { - limit: 10, - addresses: ['0x123', '0x456', '0x789', '0xabc'], - chain_ids: ['1', '56', '137', '10'], + headers: { + 'x-token-api-items-returned': '100', + 'x-token-api-batch-size': '10', + 'x-token-api-lowest-time-parameter': '1h', }, - }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeDefined(); - }); - }); - - describe('Edge cases', () => { - beforeEach(() => { - (config as ConfigWithPlans).plans = new Map([ - ['hobby', { maxLimit: 25, maxBatched: 5, allowedIntervals: ['4h', '1d', '1w'] }], - ]); - }); - - it('should not check OHLCV limits on non-OHLCV endpoints', () => { - const ctx = createMockContext({ - headers: { 'X-Plan': 'hobby' }, - path: '/api/v1/tokens/balances', }); - const nowTimestamp = Math.floor(Date.now() / 1000); - const oneYearAgo = nowTimestamp - 365 * 24 * 60 * 60; - const parseResult = { success: true as const, data: { - limit: 20, - interval: 60, - start_time: oneYearAgo, - end_time: nowTimestamp, + limit: 50, + addresses: new Array(5).fill('0x1'), + interval: 240, }, }; - - const result = validatorHook(parseResult, ctx); - expect(result).toBeUndefined(); + expect(validatorHook(parseResult, ctx)).toBeUndefined(); }); it('should merge validatedData with existing context data', () => { const ctx = createMockContext({ - headers: { 'X-Plan': 'hobby' }, + headers: { 'x-token-api-items-returned': '50' }, validatedData: { existing: 'data' }, }); const parseResult = { success: true as const, - data: { - limit: 20, - address: '0x123', - }, + data: { limit: 20, address: '0x123' }, }; validatorHook(parseResult, ctx); diff --git a/src/utils.ts b/src/utils.ts index c986543b..b750f058 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,7 +2,6 @@ import type { Context } from 'hono'; import type { DescribeRouteOptions } from 'hono-openapi'; import { resolver } from 'hono-openapi'; import { ZodError } from 'zod'; -import { config } from './config.js'; import { logger } from './logger.js'; import { type ApiErrorResponse, @@ -13,6 +12,19 @@ import { serverErrorResponseSchema, } from './types/zod.js'; +// Plan limits are injected by the upstream gateway on every authenticated +// request. Requests without these headers (e.g. local development) bypass +// enforcement. +const HEADER_BATCH_SIZE = 'x-token-api-batch-size'; +const HEADER_ITEMS_RETURNED = 'x-token-api-items-returned'; +const HEADER_LOWEST_TIME_PARAMETER = 'x-token-api-lowest-time-parameter'; + +function parseLimitHeader(raw: string | undefined): number | null { + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : null; +} + export function APIErrorResponse( c: Context, status: ApiErrorResponse['status'], @@ -77,70 +89,44 @@ export function validatorHook( ) { if (!parseResult.success) return APIErrorResponse(ctx, 400, 'bad_query_input', parseResult.error); - const plan = ctx.req.header('X-Plan'); + const maxItems = parseLimitHeader(ctx.req.header(HEADER_ITEMS_RETURNED)); + const maxBatch = parseLimitHeader(ctx.req.header(HEADER_BATCH_SIZE)); + const lowestInterval = ctx.req.header(HEADER_LOWEST_TIME_PARAMETER); + const data = parseResult.data; - // Bypass plan limits if PLANS config is empty (local development) and for monitoring and DEX list endpoints - if ( - config.plans !== null && - ctx.req.path !== '/v1/health' && - ctx.req.path !== '/v1/version' && - ctx.req.path !== '/v1/networks' && - !ctx.req.path.endsWith(`/dexes`) - ) { - if (!plan) return APIErrorResponse(ctx, 400, 'bad_header', `Missing 'X-Plan' header in request.`); - - const planConfig = config.plans.get(plan); - if (!planConfig) { - return APIErrorResponse(ctx, 400, 'bad_header', `'X-Plan' header has invalid value.`); - } - - const max_limit: number = planConfig.maxLimit; - const max_batched: number = planConfig.maxBatched; - const allowed_intervals: string[] = planConfig.allowedIntervals; - const data = parseResult.data; - - // Limit - if (max_limit !== 0 && data.limit && data.limit > max_limit) - return APIErrorResponse(ctx, 403, 'forbidden', `Parameter 'limit' exceeds maximum of ${max_limit} items.`); + // `items-returned`: cap on `limit`. 0 = unlimited. + if (maxItems !== null && maxItems > 0 && data.limit && data.limit > maxItems) { + return APIErrorResponse(ctx, 403, 'forbidden', `Parameter 'limit' exceeds maximum of ${maxItems} items.`); + } - // Batched parameters + // `batch-size`: cap on any array-valued query parameter. 0 = unlimited. + if (maxBatch !== null && maxBatch > 0) { const exceededParams = Object.entries(data) - .filter(([_, value]) => Array.isArray(value) && value.length > max_batched) + .filter(([_, value]) => Array.isArray(value) && value.length > maxBatch) .map(([key, value]) => ({ name: key, length: (value as unknown[]).length })); - if (max_batched !== 0 && exceededParams.length > 0) { + if (exceededParams.length > 0) { const paramDetails = exceededParams.map((p) => `'${p.name}' (${p.length} values)`).join(', '); return APIErrorResponse( ctx, 403, 'forbidden', - `Parameters ${paramDetails} exceed maximum batch limit of ${max_batched}.` + `Parameters ${paramDetails} exceed maximum batch limit of ${maxBatch}.` ); } + } - // OHLCV interval restrictions - const is_ohlcv_endpoint = ctx.req.path.endsWith('/ohlc') || ctx.req.path.endsWith('/historical'); - if (is_ohlcv_endpoint && data.interval) { - // Check interval restrictions - if (allowed_intervals.length > 0) { - // Parse allowed intervals using evmIntervalSchema (superset of intervalSchema) - const allowedIntervalMinutes: number[] = []; - for (const intervalStr of allowed_intervals) { - const parseResult = evmIntervalSchema.safeParse(intervalStr); - if (parseResult.success) { - allowedIntervalMinutes.push(parseResult.data); - } - } - - if (!allowedIntervalMinutes.includes(data.interval)) { - return APIErrorResponse( - ctx, - 403, - 'forbidden', - `Parameter 'interval' must be one of: ${allowed_intervals.join(', ')}.` - ); - } - } + // `lowest-time-parameter`: minimum granularity (in minutes) the user may query. + // Only OHLCV endpoints carry an `interval` field. + if (lowestInterval && data.interval) { + const parsed = evmIntervalSchema.safeParse(lowestInterval); + if (parsed.success && data.interval < parsed.data) { + return APIErrorResponse( + ctx, + 403, + 'forbidden', + `Parameter 'interval' must be coarser than or equal to '${lowestInterval}'.` + ); } }