diff --git a/apps/api-gateway/Dockerfile b/apps/api-gateway/Dockerfile new file mode 100644 index 0000000..152ebdf --- /dev/null +++ b/apps/api-gateway/Dockerfile @@ -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"] diff --git a/apps/api-gateway/README.md b/apps/api-gateway/README.md new file mode 100644 index 0000000..55d5abc --- /dev/null +++ b/apps/api-gateway/README.md @@ -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 ` 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** diff --git a/apps/api-gateway/package.json b/apps/api-gateway/package.json new file mode 100644 index 0000000..ec42d2b --- /dev/null +++ b/apps/api-gateway/package.json @@ -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" + } +} diff --git a/apps/api-gateway/src/api-keys.ts b/apps/api-gateway/src/api-keys.ts new file mode 100644 index 0000000..b62af5a --- /dev/null +++ b/apps/api-gateway/src/api-keys.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + }> { + 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) || {}; + const monthlyStats = (results?.[1]?.[1] as Record) || {}; + const methodStats = (results?.[2]?.[1] as Record) || {}; + + 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 { + await this.redis.quit(); + } +} diff --git a/apps/api-gateway/src/index.ts b/apps/api-gateway/src/index.ts new file mode 100644 index 0000000..b8a1c37 --- /dev/null +++ b/apps/api-gateway/src/index.ts @@ -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); +}); diff --git a/apps/api-gateway/src/rate-limiter.ts b/apps/api-gateway/src/rate-limiter.ts new file mode 100644 index 0000000..c7c287c --- /dev/null +++ b/apps/api-gateway/src/rate-limiter.ts @@ -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 = { + 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 { + 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 { + await this.redis.quit(); + } +} diff --git a/apps/api-gateway/tsconfig.json b/apps/api-gateway/tsconfig.json new file mode 100644 index 0000000..704aa6c --- /dev/null +++ b/apps/api-gateway/tsconfig.json @@ -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"] +} diff --git a/docker-compose.testnet.yml b/docker-compose.testnet.yml index 90d611b..43bf765 100644 --- a/docker-compose.testnet.yml +++ b/docker-compose.testnet.yml @@ -313,6 +313,55 @@ services: profiles: - 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 # ============================================================================= @@ -335,3 +384,4 @@ volumes: prometheus-data: grafana-data: alertmanager-data: + redis-data: