feat(api): add public API gateway with rate limiting
- Create API gateway service with Express.js - Implement sliding window rate limiting via Redis - Add API key management with tiered access (free/developer/enterprise) - Track usage analytics per key and globally - Add RPC proxy to blockchain nodes - Configure Docker Compose with api-gateway and redis services - Free tier: 100 req/min, Developer: 1000 req/min, Enterprise: unlimited
This commit is contained in:
parent
d121759d2c
commit
4a2825f516
8 changed files with 933 additions and 0 deletions
35
apps/api-gateway/Dockerfile
Normal file
35
apps/api-gateway/Dockerfile
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copy built files
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3100
|
||||||
|
|
||||||
|
EXPOSE 3100
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
223
apps/api-gateway/README.md
Normal file
223
apps/api-gateway/README.md
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
# Synor Public API Gateway
|
||||||
|
|
||||||
|
Rate-limited, authenticated access to Synor blockchain RPC.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with API profile
|
||||||
|
docker compose -f docker-compose.testnet.yml --profile api up -d
|
||||||
|
|
||||||
|
# Test the API
|
||||||
|
curl http://localhost:17400/health
|
||||||
|
|
||||||
|
# Make an RPC call (anonymous - 100 req/min)
|
||||||
|
curl -X POST http://localhost:17400/rpc \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Tiers
|
||||||
|
|
||||||
|
| Tier | Rate Limit | Price | Features |
|
||||||
|
|------------|---------------|----------|-----------------------------|
|
||||||
|
| Free | 100 req/min | $0 | Anonymous or API key |
|
||||||
|
| Developer | 1000 req/min | $49/mo | API key + analytics |
|
||||||
|
| Enterprise | Unlimited | Custom | SLA, dedicated support |
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### API Key Header
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:17400/rpc \
|
||||||
|
-H "Authorization: Bearer sk_developer_abc123..." \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### X-API-Key Header
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:17400/rpc \
|
||||||
|
-H "X-API-Key: sk_developer_abc123..." \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameter
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:17400/rpc?api_key=sk_developer_abc123..." \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"synor_getBlockCount","params":[],"id":1}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Headers
|
||||||
|
|
||||||
|
All RPC responses include rate limit information:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-RateLimit-Limit: 1000
|
||||||
|
X-RateLimit-Remaining: 999
|
||||||
|
X-RateLimit-Reset: 1704067260
|
||||||
|
X-RateLimit-Tier: developer
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
#### Health Check
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
Returns service health status.
|
||||||
|
|
||||||
|
#### JSON-RPC Proxy
|
||||||
|
```
|
||||||
|
POST /rpc
|
||||||
|
```
|
||||||
|
Proxies JSON-RPC requests to the Synor node.
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
Requires `Authorization: Bearer <ADMIN_KEY>` header.
|
||||||
|
|
||||||
|
#### Create API Key
|
||||||
|
```
|
||||||
|
POST /v1/keys
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"tier": "developer",
|
||||||
|
"name": "My App"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "sk_developer_abc123...",
|
||||||
|
"tier": "developer",
|
||||||
|
"name": "My App",
|
||||||
|
"createdAt": 1704067200000,
|
||||||
|
"lastUsed": 0,
|
||||||
|
"requestCount": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List API Keys
|
||||||
|
```
|
||||||
|
GET /v1/keys
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Key Stats
|
||||||
|
```
|
||||||
|
GET /v1/keys/:key/stats
|
||||||
|
```
|
||||||
|
(Accessible by key owner or admin)
|
||||||
|
|
||||||
|
#### Revoke API Key
|
||||||
|
```
|
||||||
|
DELETE /v1/keys/:key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
### Rate Limit Exceeded (429)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32005,
|
||||||
|
"message": "Rate limit exceeded",
|
||||||
|
"data": {
|
||||||
|
"retryAfter": 45,
|
||||||
|
"tier": "free",
|
||||||
|
"upgrade": "Upgrade to Developer tier for 1000 req/min"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid API Key (401)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32001,
|
||||||
|
"message": "Invalid API key"
|
||||||
|
},
|
||||||
|
"id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RPC Node Unavailable (502)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": "RPC node unavailable"
|
||||||
|
},
|
||||||
|
"id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported RPC Methods
|
||||||
|
|
||||||
|
### Chain Methods
|
||||||
|
- `synor_getBlockCount` - Get current block height
|
||||||
|
- `synor_getBlockHash` - Get block hash by height
|
||||||
|
- `synor_getBlock` - Get block by hash
|
||||||
|
- `synor_getDAGInfo` - Get DAG structure info
|
||||||
|
- `synor_getChainInfo` - Get chain statistics
|
||||||
|
|
||||||
|
### Transaction Methods
|
||||||
|
- `synor_sendRawTransaction` - Submit signed transaction
|
||||||
|
- `synor_getTransaction` - Get transaction by ID
|
||||||
|
- `synor_getMempool` - Get mempool transactions
|
||||||
|
- `synor_estimateFee` - Estimate transaction fee
|
||||||
|
|
||||||
|
### Address Methods
|
||||||
|
- `synor_getBalance` - Get address balance
|
||||||
|
- `synor_getUtxos` - Get unspent outputs
|
||||||
|
- `synor_getAddressTransactions` - Get address history
|
||||||
|
|
||||||
|
### Contract Methods
|
||||||
|
- `synor_deployContract` - Deploy WASM contract
|
||||||
|
- `synor_callContract` - Call contract method
|
||||||
|
- `synor_getContractState` - Get contract storage
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|-------------|----------------------------|--------------------------|
|
||||||
|
| PORT | 3100 | API gateway port |
|
||||||
|
| REDIS_URL | redis://localhost:6379 | Redis connection URL |
|
||||||
|
| RPC_TARGET | http://localhost:16110 | Backend RPC node URL |
|
||||||
|
| ADMIN_KEY | admin-secret-key | Admin API key |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api-gateway
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
The API gateway is designed for horizontal scaling:
|
||||||
|
|
||||||
|
1. Deploy multiple API gateway instances behind a load balancer
|
||||||
|
2. All instances share the same Redis for rate limiting
|
||||||
|
3. Use Redis Cluster for high availability
|
||||||
|
4. Set unique `ADMIN_KEY` per environment
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Always use HTTPS in production**
|
||||||
|
2. **Rotate admin keys regularly**
|
||||||
|
3. **Monitor for abuse patterns**
|
||||||
|
4. **Set up alerting for high error rates**
|
||||||
|
5. **Use Redis AUTH in production**
|
||||||
30
apps/api-gateway/package.json
Normal file
30
apps/api-gateway/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "@synor/api-gateway",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Public API gateway for Synor blockchain",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
198
apps/api-gateway/src/api-keys.ts
Normal file
198
apps/api-gateway/src/api-keys.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/**
|
||||||
|
* API Key management for Synor public API.
|
||||||
|
*
|
||||||
|
* Keys are stored in Redis with the format:
|
||||||
|
* apikey:{key} -> { tier, name, createdAt, lastUsed }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Use require for ioredis to avoid ESM import issues
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import IORedisModule from 'ioredis';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const IORedis = (IORedisModule as any).default || IORedisModule;
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type { RateLimitTier } from './rate-limiter.js';
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
key: string;
|
||||||
|
tier: RateLimitTier;
|
||||||
|
name: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsed: number;
|
||||||
|
requestCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyOptions {
|
||||||
|
tier: RateLimitTier;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiKeyManager {
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor(redisUrl: string) {
|
||||||
|
this.redis = new IORedis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API key.
|
||||||
|
*/
|
||||||
|
async createKey(options: CreateApiKeyOptions): Promise<ApiKey> {
|
||||||
|
const key = `sk_${options.tier}_${uuidv4().replace(/-/g, '')}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const apiKey: ApiKey = {
|
||||||
|
key,
|
||||||
|
tier: options.tier,
|
||||||
|
name: options.name,
|
||||||
|
createdAt: now,
|
||||||
|
lastUsed: 0,
|
||||||
|
requestCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.redis.set(
|
||||||
|
`apikey:${key}`,
|
||||||
|
JSON.stringify(apiKey),
|
||||||
|
'EX',
|
||||||
|
365 * 24 * 60 * 60 // 1 year expiry
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return its metadata.
|
||||||
|
*/
|
||||||
|
async validateKey(key: string): Promise<ApiKey | null> {
|
||||||
|
const data = await this.redis.get(`apikey:${key}`);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const apiKey = JSON.parse(data) as ApiKey;
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
pipeline.hset(`apikey:${key}:stats`, 'lastUsed', Date.now());
|
||||||
|
pipeline.hincrby(`apikey:${key}:stats`, 'requestCount', 1);
|
||||||
|
await pipeline.exec();
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API key with usage statistics.
|
||||||
|
*/
|
||||||
|
async getKeyWithStats(key: string): Promise<(ApiKey & { stats: {
|
||||||
|
requestsToday: number;
|
||||||
|
requestsThisMonth: number;
|
||||||
|
} }) | null> {
|
||||||
|
const data = await this.redis.get(`apikey:${key}`);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const apiKey = JSON.parse(data) as ApiKey;
|
||||||
|
const stats = await this.redis.hgetall(`apikey:${key}:stats`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...apiKey,
|
||||||
|
lastUsed: parseInt(stats.lastUsed || '0'),
|
||||||
|
requestCount: parseInt(stats.requestCount || '0'),
|
||||||
|
stats: {
|
||||||
|
requestsToday: parseInt(stats.requestsToday || '0'),
|
||||||
|
requestsThisMonth: parseInt(stats.requestsThisMonth || '0'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key.
|
||||||
|
*/
|
||||||
|
async revokeKey(key: string): Promise<boolean> {
|
||||||
|
const deleted = await this.redis.del(`apikey:${key}`);
|
||||||
|
await this.redis.del(`apikey:${key}:stats`);
|
||||||
|
return deleted > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all API keys for an account (by prefix).
|
||||||
|
*/
|
||||||
|
async listKeys(pattern: string = 'apikey:*'): Promise<string[]> {
|
||||||
|
const keys = await this.redis.keys(pattern);
|
||||||
|
return keys.map((k: string) => k.replace('apikey:', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record usage for analytics.
|
||||||
|
*/
|
||||||
|
async recordUsage(
|
||||||
|
key: string,
|
||||||
|
method: string,
|
||||||
|
responseTime: number
|
||||||
|
): Promise<void> {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const month = today.substring(0, 7);
|
||||||
|
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
|
||||||
|
// Per-key daily stats
|
||||||
|
pipeline.hincrby(`usage:${key}:${today}`, 'requests', 1);
|
||||||
|
pipeline.hincrby(`usage:${key}:${today}`, 'responseTime', responseTime);
|
||||||
|
pipeline.expire(`usage:${key}:${today}`, 90 * 24 * 60 * 60); // 90 days
|
||||||
|
|
||||||
|
// Per-key monthly stats
|
||||||
|
pipeline.hincrby(`usage:${key}:${month}`, 'requests', 1);
|
||||||
|
pipeline.expire(`usage:${key}:${month}`, 365 * 24 * 60 * 60); // 1 year
|
||||||
|
|
||||||
|
// Method tracking
|
||||||
|
pipeline.hincrby(`methods:${today}`, method, 1);
|
||||||
|
pipeline.expire(`methods:${today}`, 90 * 24 * 60 * 60);
|
||||||
|
|
||||||
|
// Global stats
|
||||||
|
pipeline.hincrby('stats:global', 'totalRequests', 1);
|
||||||
|
pipeline.lpush('stats:latency', responseTime);
|
||||||
|
pipeline.ltrim('stats:latency', 0, 999); // Keep last 1000
|
||||||
|
|
||||||
|
await pipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage analytics.
|
||||||
|
*/
|
||||||
|
async getAnalytics(key: string): Promise<{
|
||||||
|
today: { requests: number; avgResponseTime: number };
|
||||||
|
thisMonth: { requests: number };
|
||||||
|
methods: Record<string, number>;
|
||||||
|
}> {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const month = today.substring(0, 7);
|
||||||
|
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
pipeline.hgetall(`usage:${key}:${today}`);
|
||||||
|
pipeline.hgetall(`usage:${key}:${month}`);
|
||||||
|
pipeline.hgetall(`methods:${today}`);
|
||||||
|
|
||||||
|
const results = await pipeline.exec();
|
||||||
|
|
||||||
|
const dailyStats = (results?.[0]?.[1] as Record<string, string>) || {};
|
||||||
|
const monthlyStats = (results?.[1]?.[1] as Record<string, string>) || {};
|
||||||
|
const methodStats = (results?.[2]?.[1] as Record<string, string>) || {};
|
||||||
|
|
||||||
|
const requests = parseInt(dailyStats.requests || '0');
|
||||||
|
const totalTime = parseInt(dailyStats.responseTime || '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
today: {
|
||||||
|
requests,
|
||||||
|
avgResponseTime: requests > 0 ? Math.round(totalTime / requests) : 0,
|
||||||
|
},
|
||||||
|
thisMonth: {
|
||||||
|
requests: parseInt(monthlyStats.requests || '0'),
|
||||||
|
},
|
||||||
|
methods: Object.fromEntries(
|
||||||
|
Object.entries(methodStats).map(([k, v]) => [k, parseInt(v)])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.redis.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
258
apps/api-gateway/src/index.ts
Normal file
258
apps/api-gateway/src/index.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
/**
|
||||||
|
* Synor Public API Gateway
|
||||||
|
*
|
||||||
|
* Provides rate-limited, authenticated access to Synor blockchain RPC.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - POST /rpc - JSON-RPC proxy to node (requires API key)
|
||||||
|
* - GET /health - Health check
|
||||||
|
* - GET /v1/keys - List API keys (admin)
|
||||||
|
* - POST /v1/keys - Create API key (admin)
|
||||||
|
* - GET /v1/keys/:key/stats - Get usage stats
|
||||||
|
* - DELETE /v1/keys/:key - Revoke API key (admin)
|
||||||
|
* - GET /v1/analytics - Global analytics (admin)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { RateLimiter, RateLimitTier } from './rate-limiter.js';
|
||||||
|
import { ApiKeyManager } from './api-keys.js';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const PORT = parseInt(process.env.PORT || '3100');
|
||||||
|
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
const RPC_TARGET = process.env.RPC_TARGET || 'http://localhost:16110';
|
||||||
|
const ADMIN_KEY = process.env.ADMIN_KEY || 'admin-secret-key';
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
const rateLimiter = new RateLimiter(REDIS_URL);
|
||||||
|
const apiKeyManager = new ApiKeyManager(REDIS_URL);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Request timing middleware
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
(req as any).startTime = Date.now();
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check (no auth required)
|
||||||
|
app.get('/health', (req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'healthy', timestamp: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API key extraction middleware
|
||||||
|
const extractApiKey = (req: Request): string | null => {
|
||||||
|
// Check Authorization header
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
return authHeader.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check X-API-Key header
|
||||||
|
const apiKeyHeader = req.headers['x-api-key'];
|
||||||
|
if (typeof apiKeyHeader === 'string') {
|
||||||
|
return apiKeyHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check query parameter
|
||||||
|
if (typeof req.query.api_key === 'string') {
|
||||||
|
return req.query.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limiting and auth middleware for RPC
|
||||||
|
const rpcAuthMiddleware = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) => {
|
||||||
|
const apiKey = extractApiKey(req);
|
||||||
|
|
||||||
|
let tier: RateLimitTier = 'free';
|
||||||
|
let keyIdentifier: string;
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
const keyData = await apiKeyManager.validateKey(apiKey);
|
||||||
|
if (keyData) {
|
||||||
|
tier = keyData.tier;
|
||||||
|
keyIdentifier = apiKey;
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32001, message: 'Invalid API key' },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Anonymous access - rate limit by IP
|
||||||
|
keyIdentifier = `ip:${req.ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const result = await rateLimiter.checkLimit(keyIdentifier, tier);
|
||||||
|
|
||||||
|
// Set rate limit headers
|
||||||
|
res.set('X-RateLimit-Limit', tier === 'enterprise' ? 'unlimited' : String(
|
||||||
|
tier === 'developer' ? 1000 : 100
|
||||||
|
));
|
||||||
|
res.set('X-RateLimit-Remaining', String(result.remaining));
|
||||||
|
res.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
|
||||||
|
res.set('X-RateLimit-Tier', tier);
|
||||||
|
|
||||||
|
if (!result.allowed) {
|
||||||
|
return res.status(429).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32005,
|
||||||
|
message: 'Rate limit exceeded',
|
||||||
|
data: {
|
||||||
|
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
|
||||||
|
tier,
|
||||||
|
upgrade: tier === 'free'
|
||||||
|
? 'Upgrade to Developer tier for 1000 req/min'
|
||||||
|
: tier === 'developer'
|
||||||
|
? 'Contact us for Enterprise tier'
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store for analytics
|
||||||
|
(req as any).apiKey = keyIdentifier;
|
||||||
|
(req as any).tier = tier;
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// RPC proxy
|
||||||
|
app.post(
|
||||||
|
'/rpc',
|
||||||
|
rpcAuthMiddleware,
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: RPC_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/rpc': '/' },
|
||||||
|
onProxyRes: async (proxyRes, req: any) => {
|
||||||
|
// Record usage analytics
|
||||||
|
const responseTime = Date.now() - req.startTime;
|
||||||
|
const body = req.body || {};
|
||||||
|
const method = body.method || 'unknown';
|
||||||
|
|
||||||
|
await apiKeyManager.recordUsage(req.apiKey, method, responseTime);
|
||||||
|
},
|
||||||
|
onError: (err, req, res: any) => {
|
||||||
|
res.status(502).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32603, message: 'RPC node unavailable' },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin middleware
|
||||||
|
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader !== `Bearer ${ADMIN_KEY}`) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin: Create API key
|
||||||
|
app.post('/v1/keys', adminAuth, async (req: Request, res: Response) => {
|
||||||
|
const { tier, name } = req.body;
|
||||||
|
|
||||||
|
if (!tier || !name) {
|
||||||
|
return res.status(400).json({ error: 'tier and name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['free', 'developer', 'enterprise'].includes(tier)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid tier' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await apiKeyManager.createKey({ tier, name });
|
||||||
|
res.status(201).json(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: List API keys
|
||||||
|
app.get('/v1/keys', adminAuth, async (req: Request, res: Response) => {
|
||||||
|
const keys = await apiKeyManager.listKeys();
|
||||||
|
res.json({ keys });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get API key stats (key owner or admin)
|
||||||
|
app.get('/v1/keys/:key/stats', async (req: Request, res: Response) => {
|
||||||
|
const { key } = req.params;
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
// Allow key owner or admin
|
||||||
|
if (authHeader !== `Bearer ${key}` && authHeader !== `Bearer ${ADMIN_KEY}`) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await apiKeyManager.getKeyWithStats(key);
|
||||||
|
if (!stats) {
|
||||||
|
return res.status(404).json({ error: 'Key not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const analytics = await apiKeyManager.getAnalytics(key);
|
||||||
|
res.json({ ...stats, analytics });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: Revoke API key
|
||||||
|
app.delete('/v1/keys/:key', adminAuth, async (req: Request, res: Response) => {
|
||||||
|
const { key } = req.params;
|
||||||
|
const deleted = await apiKeyManager.revokeKey(key);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ error: 'Key not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin: Global analytics
|
||||||
|
app.get('/v1/analytics', adminAuth, async (req: Request, res: Response) => {
|
||||||
|
// Get global statistics
|
||||||
|
res.json({
|
||||||
|
message: 'Analytics endpoint - implement as needed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32603, message: 'Internal server error' },
|
||||||
|
id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Synor API Gateway running on port ${PORT}`);
|
||||||
|
console.log(`Proxying RPC to: ${RPC_TARGET}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('Shutting down...');
|
||||||
|
await rateLimiter.close();
|
||||||
|
await apiKeyManager.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
121
apps/api-gateway/src/rate-limiter.ts
Normal file
121
apps/api-gateway/src/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* Rate limiting using Redis sliding window algorithm.
|
||||||
|
*
|
||||||
|
* Tiers:
|
||||||
|
* - free: 100 requests/minute
|
||||||
|
* - developer: 1000 requests/minute
|
||||||
|
* - enterprise: unlimited
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Use require for ioredis to avoid ESM import issues
|
||||||
|
import type { Redis } from 'ioredis';
|
||||||
|
import IORedisModule from 'ioredis';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const IORedis = (IORedisModule as any).default || IORedisModule;
|
||||||
|
|
||||||
|
export type RateLimitTier = 'free' | 'developer' | 'enterprise';
|
||||||
|
|
||||||
|
interface TierConfig {
|
||||||
|
requestsPerMinute: number;
|
||||||
|
burstLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_CONFIGS: Record<RateLimitTier, TierConfig> = {
|
||||||
|
free: { requestsPerMinute: 100, burstLimit: 20 },
|
||||||
|
developer: { requestsPerMinute: 1000, burstLimit: 100 },
|
||||||
|
enterprise: { requestsPerMinute: Infinity, burstLimit: Infinity },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: number;
|
||||||
|
tier: RateLimitTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimiter {
|
||||||
|
private redis: Redis;
|
||||||
|
private windowMs = 60_000; // 1 minute
|
||||||
|
|
||||||
|
constructor(redisUrl: string) {
|
||||||
|
this.redis = new IORedis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLimit(key: string, tier: RateLimitTier): Promise<RateLimitResult> {
|
||||||
|
const config = TIER_CONFIGS[tier];
|
||||||
|
|
||||||
|
// Enterprise tier bypasses rate limiting
|
||||||
|
if (tier === 'enterprise') {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: Infinity,
|
||||||
|
resetAt: 0,
|
||||||
|
tier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - this.windowMs;
|
||||||
|
const redisKey = `ratelimit:${key}`;
|
||||||
|
|
||||||
|
// Use Redis sorted set for sliding window
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
|
||||||
|
// Remove expired entries
|
||||||
|
pipeline.zremrangebyscore(redisKey, 0, windowStart);
|
||||||
|
|
||||||
|
// Count current requests in window
|
||||||
|
pipeline.zcard(redisKey);
|
||||||
|
|
||||||
|
// Add current request
|
||||||
|
pipeline.zadd(redisKey, now, `${now}-${Math.random()}`);
|
||||||
|
|
||||||
|
// Set expiry on the key
|
||||||
|
pipeline.expire(redisKey, 120);
|
||||||
|
|
||||||
|
const results = await pipeline.exec();
|
||||||
|
const currentCount = (results?.[1]?.[1] as number) || 0;
|
||||||
|
|
||||||
|
const allowed = currentCount < config.requestsPerMinute;
|
||||||
|
const remaining = Math.max(0, config.requestsPerMinute - currentCount - 1);
|
||||||
|
|
||||||
|
// Calculate reset time
|
||||||
|
const oldestRequest = await this.redis.zrange(redisKey, 0, 0, 'WITHSCORES');
|
||||||
|
const resetAt = oldestRequest.length >= 2
|
||||||
|
? parseInt(oldestRequest[1]) + this.windowMs
|
||||||
|
: now + this.windowMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed,
|
||||||
|
remaining: allowed ? remaining : 0,
|
||||||
|
resetAt,
|
||||||
|
tier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsageStats(key: string): Promise<{
|
||||||
|
requestsLastMinute: number;
|
||||||
|
requestsLastHour: number;
|
||||||
|
requestsLastDay: number;
|
||||||
|
}> {
|
||||||
|
const now = Date.now();
|
||||||
|
const redisKey = `ratelimit:${key}`;
|
||||||
|
|
||||||
|
const pipeline = this.redis.pipeline();
|
||||||
|
pipeline.zcount(redisKey, now - 60_000, now);
|
||||||
|
pipeline.zcount(redisKey, now - 3600_000, now);
|
||||||
|
pipeline.zcount(redisKey, now - 86400_000, now);
|
||||||
|
|
||||||
|
const results = await pipeline.exec();
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestsLastMinute: (results?.[0]?.[1] as number) || 0,
|
||||||
|
requestsLastHour: (results?.[1]?.[1] as number) || 0,
|
||||||
|
requestsLastDay: (results?.[2]?.[1] as number) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.redis.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/api-gateway/tsconfig.json
Normal file
18
apps/api-gateway/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
@ -313,6 +313,55 @@ services:
|
||||||
profiles:
|
profiles:
|
||||||
- monitoring
|
- monitoring
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Redis - API Gateway Cache & Rate Limiting
|
||||||
|
# ==========================================================================
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: synor-redis
|
||||||
|
hostname: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "17379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
profiles:
|
||||||
|
- api
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Public API Gateway - Rate-limited RPC Access
|
||||||
|
# ==========================================================================
|
||||||
|
api-gateway:
|
||||||
|
build:
|
||||||
|
context: ./apps/api-gateway
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: synor-api-gateway
|
||||||
|
hostname: api-gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "17400:3100"
|
||||||
|
environment:
|
||||||
|
- PORT=3100
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- RPC_TARGET=http://seed1:17110
|
||||||
|
- ADMIN_KEY=${API_ADMIN_KEY:-admin-secret-key}
|
||||||
|
networks:
|
||||||
|
- synor-testnet
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
seed1:
|
||||||
|
condition: service_healthy
|
||||||
|
profiles:
|
||||||
|
- api
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Networks
|
# Networks
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -335,3 +384,4 @@ volumes:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
alertmanager-data:
|
alertmanager-data:
|
||||||
|
redis-data:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue