Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,13 @@ Create a `.env` file in the root directory (see `example.env` for reference):
| `PORT` | Server port | `3000` |
| `SERVER_REQUEST_TIMEOUT` | Request timeout (ms) | `300000` |
| `TRUST_PROXY_HOPS` | Reverse-proxy hops in front of the API (needed for per-IP rate limiting behind a LB) | `0` |
| `RATE_LIMIT_WINDOW_MS` | Anonymous rate-limit window (ms); also used by the global anonymous ceiling | `60000` |
| `RATE_LIMIT_MAX_REQUESTS` | Anonymous per-IP requests per window | `60` |
| `GLOBAL_RATE_LIMIT_MAX` | Aggregate anonymous requests per window across all IPs (`0` disables) | `0` |
| `TRUSTED_API_KEYS` | Comma-separated allowlist of trusted partner keys | — |
| `TRUSTED_RATE_LIMIT_MAX` | Per-bucket request count per minute for trusted keys | `600` |
| `RESTRICTION_MODE` | Emergency mode: `normal` or `restricted` | `normal` |
| `RESTRICTION_DISABLED_PATHS` | Comma-separated path prefixes to hard-disable for all tiers | — |
| `CACHE_TICKERS_TTL` | On-chain data cache TTL (ms) | `55000` |
| **Served indexer DB (required — the only database this API uses)** | | |
| `DATABASE_PG_URL` | Read-only connection to the served indexer DB (Meteora, tickers, DexScreener, first-trade-dates). **Required** — `/api/market-data` returns 503 without it. | — |
Expand Down Expand Up @@ -303,12 +308,36 @@ The DexScreener adapter reads **directly from the external indexer DB** (`v0_6_s

## Rate Limiting

- **Anonymous (default):** 60 requests per minute per IP. Returns `429 Too Many Requests` when exceeded.
- **Anonymous (default):** 60 requests per minute per IP, configurable with `RATE_LIMIT_MAX_REQUESTS` and `RATE_LIMIT_WINDOW_MS`. Returns `429 Too Many Requests` when exceeded.
- **Behind a proxy/load balancer, set `TRUST_PROXY_HOPS`** to the real hop count — otherwise every anonymous client resolves to the proxy's IP and shares a single bucket.
- **Trusted partners:** 600 requests per minute per key (configurable via `TRUSTED_RATE_LIMIT_MAX`). Send the issued key in the `X-API-Key` header. Each key has its own bucket — partners do not share quota.
- **Global anonymous ceiling:** set `GLOBAL_RATE_LIMIT_MAX` to cap aggregate anonymous traffic per instance across all IPs. `0` disables it.
- **Trusted access:** 600 requests per minute per key (configurable via `TRUSTED_RATE_LIMIT_MAX`). Send the issued key in the `X-API-Key` header.
- Requests sent with an `X-API-Key` header that does not match the server-side allowlist receive `401 Unauthorized` with `code: "INVALID_API_KEY"`.
- Rate-limited responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After`. Allowed responses include the `X-RateLimit-*` headers.
- Keys are issued out-of-band by the team. Contact us if you need elevated access.

## Emergency Restriction Controls

All emergency controls are env-driven and require a process restart. There is no runtime admin endpoint.

- `RESTRICTION_MODE=normal`: default behavior.
- `RESTRICTION_MODE=restricted`: health, metrics, and trusted access remain allowed; anonymous API traffic gets `503 SERVICE_RESTRICTED`.
- `RESTRICTION_DISABLED_PATHS`: hard-disables matching path prefixes for all tiers except health and metrics.

Operational checklist:

1. Confirm expected high-volume callers have valid trusted access.
2. Set the smallest effective env change for the risk level.
3. Restart the service.
4. Verify health and metrics remain reachable.
5. Test one anonymous request and one trusted request before leaving the restriction in place.

## Frontend And Development Access

Browser traffic should stay anonymous because API secrets must not be shipped to clients. Use the anonymous tier for direct browser calls.

Server-side callers and local development proxies can use a server-held trusted key from `.env` and send it as `X-API-Key`.

## Error Handling

```json
Expand Down
20 changes: 18 additions & 2 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,30 @@ NODE_ENV=production
# SERVER_REQUEST_TIMEOUT=300000
# SERVER_KEEP_ALIVE_TIMEOUT=300000

# Anonymous rate limit. Defaults: 60 requests per 60000 ms per IP.
# RATE_LIMIT_WINDOW_MS=60000
# RATE_LIMIT_MAX_REQUESTS=60

# Aggregate anonymous ceiling per instance. 0 disables.
# GLOBAL_RATE_LIMIT_MAX=0

# ===========================================
# Trusted API keys (elevated rate limit)
# ===========================================
# Comma-separated opaque secrets sent via the 'X-API-Key' header; each gets its own
# bucket, unknown keys get 401. Per-key limit/min via TRUSTED_RATE_LIMIT_MAX (default 600).
# Comma-separated opaque secrets sent via the 'X-API-Key' header. Unknown keys
# get 401. Per-key limit/min via TRUSTED_RATE_LIMIT_MAX (default 600).
# TRUSTED_API_KEYS=
# TRUSTED_RATE_LIMIT_MAX=600

# ===========================================
# Emergency restriction controls (env + restart)
# ===========================================
# normal = default. restricted sheds anonymous API traffic while keeping health,
# metrics, and trusted access available.
# RESTRICTION_MODE=normal
# Hard-disable expensive path prefixes for all tiers except health and metrics.
# RESTRICTION_DISABLED_PATHS=

# ===========================================
# Solana RPC (used by /api/tickers + supply reads)
# ===========================================
Expand Down
69 changes: 7 additions & 62 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,21 @@
import express, { type Request, type Response, type NextFunction } from 'express';
import type { Application } from 'express';
import { requestIdMiddleware } from './middleware/requestId.js';
import { errorHandler, asyncHandler, AppError } from './middleware/errorHandler.js';
import { createClientContextMiddleware } from './middleware/clientContext.js';
import { createRateLimitMiddleware } from './middleware/rateLimit.js';
import { restrictionMiddleware } from './middleware/restriction.js';
import { metricsService } from './services/metricsService.js';
import { config } from './config.js';
import { createRoutes } from './routes/index.js';
import { createServiceGetters, type Services } from './routes/types.js';

export type { Services } from './routes/types.js';

declare global {
namespace Express {
interface Request {
clientTier?: 'anon' | 'trusted';
}
}
}

export interface AppOptions {
services: Services;
}

function createRateLimitMiddleware() {
const buckets = new Map<string, { count: number; resetTime: number }>();

// Evict expired buckets so the map doesn't grow without bound across
// distinct client IPs/keys. unref() keeps the sweep from holding the
// process (or test runner) open.
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
const sweep = setInterval(() => {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now > bucket.resetTime) buckets.delete(key);
}
}, SWEEP_INTERVAL_MS);
sweep.unref?.();

return (req: Request, res: Response, next: NextFunction): void => {
const apiKey = req.header('x-api-key');
let tier: { windowMs: number; maxRequests: number };
let bucketKey: string;

if (apiKey) {
if (!config.server.trustedApiKeys.has(apiKey)) {
throw AppError.unauthorized('Invalid API key', 'INVALID_API_KEY');
}
tier = config.server.trustedRateLimit;
bucketKey = `key:${apiKey}`;
req.clientTier = 'trusted';
} else {
tier = config.server.rateLimit;
bucketKey = `ip:${req.ip ?? 'unknown'}`;
req.clientTier = 'anon';
}

const now = Date.now();
const limit = buckets.get(bucketKey);

if (!limit || now > limit.resetTime) {
buckets.set(bucketKey, { count: 1, resetTime: now + tier.windowMs });
next();
return;
}

if (limit.count >= tier.maxRequests) {
res.status(429).json({ error: 'Too many requests' });
return;
}

limit.count++;
next();
};
}

function createMetricsMiddleware() {
return (req: Request, res: Response, next: NextFunction): void => {
if (req.path === '/metrics') {
Expand Down Expand Up @@ -103,6 +46,7 @@
const app = express();
const { services } = options;
const serviceGetters = createServiceGetters(services);
metricsService.setRestrictionMode(config.server.restriction.mode);

// Resolve the real client IP from X-Forwarded-For when behind a reverse
// proxy. Without this, every anonymous client shares the proxy's IP — and
Expand All @@ -115,15 +59,16 @@
app.use(express.json());

app.use(requestIdMiddleware);

app.use((req: Request, res: Response, next: NextFunction) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, X-API-Key');
next();
});

// Metrics BEFORE the rate limiter so 429/401 responses are recorded too.
app.use(createMetricsMiddleware());
app.use(createClientContextMiddleware());
app.use(restrictionMiddleware);
app.use(createRateLimitMiddleware());

// Mount all routes
Expand Down
76 changes: 57 additions & 19 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,79 @@
import { PublicKey } from '@solana/web3.js';

export type RestrictionMode = 'normal' | 'restricted';

const VALID_RESTRICTION_MODES = ['normal', 'restricted'] as const;

function parseInteger(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}

function parseCsv(value: string | undefined): string[] {
return (value || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
}

function parseRestrictionMode(value: string | undefined): RestrictionMode {
switch (value) {
case 'restricted':
return 'restricted';
case 'normal':
case undefined:
case '':
return 'normal';
default:
console.warn(JSON.stringify({
level: 'WARN',
message: 'Invalid RESTRICTION_MODE; falling back to normal',
value,
validValues: VALID_RESTRICTION_MODES,
}));
return 'normal';
}
}

export const config = {
solana: {
rpcUrl: process.env.RPCPOOL_RPC_URL || process.env.SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com',
},
server: {
port: parseInt(process.env.PORT || '3000'),
port: parseInteger(process.env.PORT, 3000),
// Request timeout in milliseconds (default: 5 minutes)
requestTimeout: parseInt(process.env.SERVER_REQUEST_TIMEOUT || '300000'),
requestTimeout: parseInteger(process.env.SERVER_REQUEST_TIMEOUT, 300000),
// Keep-alive timeout in milliseconds (default: 5 minutes)
keepAliveTimeout: parseInt(process.env.SERVER_KEEP_ALIVE_TIMEOUT || '300000'),
keepAliveTimeout: parseInteger(process.env.SERVER_KEEP_ALIVE_TIMEOUT, 300000),
// Number of reverse-proxy hops in front of this process. Express uses it to
// resolve the real client IP from X-Forwarded-For for per-IP rate limiting.
// 0 = no proxy (req.ip is the socket peer). Use the exact hop count — a
// blanket "trust everything" would let clients spoof their IP via XFF.
trustProxyHops: parseInt(process.env.TRUST_PROXY_HOPS || '0'),
trustProxyHops: parseInteger(process.env.TRUST_PROXY_HOPS, 0),
rateLimit: {
windowMs: 60000, // 1 minute
maxRequests: 60, // 60 requests per minute
windowMs: parseInteger(process.env.RATE_LIMIT_WINDOW_MS, 60000),
maxRequests: parseInteger(process.env.RATE_LIMIT_MAX_REQUESTS, 60),
},
globalRateLimit: {
maxRequests: parseInteger(process.env.GLOBAL_RATE_LIMIT_MAX, 0),
windowMs: parseInteger(process.env.RATE_LIMIT_WINDOW_MS, 60000),
},
trustedApiKeys: new Set<string>(
(process.env.TRUSTED_API_KEYS || '')
.split(',')
.map(k => k.trim())
.filter(Boolean)
parseCsv(process.env.TRUSTED_API_KEYS)
),
trustedRateLimit: {
windowMs: 60_000,
maxRequests: parseInt(process.env.TRUSTED_RATE_LIMIT_MAX || '600'),
maxRequests: parseInteger(process.env.TRUSTED_RATE_LIMIT_MAX, 600),
},
restriction: {
mode: parseRestrictionMode(process.env.RESTRICTION_MODE),
disabledPaths: parseCsv(process.env.RESTRICTION_DISABLED_PATHS),
alwaysAllowedPaths: ['/health', '/api/health', '/metrics'],
},
},
cache: {
// TTL for blockchain data cache in milliseconds (default: 55 seconds).
// Consumers (CoinGecko/DexScreener pollers) read about once per minute, so a
// sub-minute TTL keeps every poll fresher than its cadence while cutting the
// full DAO RPC scan from ~6x/minute to ~1x/minute.
// Lower = more real-time prices but more RPC calls.
tickersTTL: parseInt(process.env.CACHE_TICKERS_TTL || '55000'),
tickersTTL: parseInteger(process.env.CACHE_TICKERS_TTL, 55000),
},
dex: {
forkType: process.env.DEX_FORK_TYPE || 'Custom',
Expand Down Expand Up @@ -70,10 +108,10 @@
heartbeat: {
// Background self-check cadence (served DB connectivity, data freshness,
// contract drift). 0 disables the heartbeat entirely.
intervalMs: parseInt(process.env.HEARTBEAT_INTERVAL_MS || '60000'),
intervalMs: parseInteger(process.env.HEARTBEAT_INTERVAL_MS, 60000),
// Alert when the newest user_pool swap is older than this (seconds).
// 0 disables the staleness alert (connectivity/contract alerts remain).
maxDataAgeSeconds: parseInt(process.env.HEARTBEAT_MAX_DATA_AGE_SECONDS || '21600'),
maxDataAgeSeconds: parseInteger(process.env.HEARTBEAT_MAX_DATA_AGE_SECONDS, 21600),
// Run the served-data contract check every Nth heartbeat tick.
contractCheckEveryTicks: 10,
},
Expand Down
47 changes: 47 additions & 0 deletions src/middleware/clientContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Request, Response, NextFunction } from 'express';
import { config } from '../config.js';
import { AppError } from './errorHandler.js';
import { timingSafeStringEqual } from '../utils/timingSafe.js';

declare global {
namespace Express {
interface Request {
clientTier?: 'anon' | 'trusted';
apiKey?: string;
}
}
}

function findTrustedApiKey(apiKey: string): string | undefined {
// Iterate every key without early-exit: returning on first match would leak
// the matching key's position via how many comparisons ran, undermining the
// constant-time intent of timingSafeStringEqual.
let matched: string | undefined;
for (const trustedKey of config.server.trustedApiKeys) {
if (timingSafeStringEqual(apiKey, trustedKey)) {
matched = trustedKey;
}
}

return matched;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

export function createClientContextMiddleware() {
return (req: Request, _res: Response, next: NextFunction): void => {
const apiKey = req.header('x-api-key');

if (apiKey) {
const trustedKey = findTrustedApiKey(apiKey);
if (!trustedKey) {
throw AppError.unauthorized('Invalid API key', 'INVALID_API_KEY');
}

req.clientTier = 'trusted';
req.apiKey = trustedKey;
} else {
req.clientTier = 'anon';
}

next();
};
}
Loading
Loading