- 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
258 lines
6.9 KiB
TypeScript
258 lines
6.9 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|