diff --git a/services/mana-api-gateway/.env.example b/services/mana-api-gateway/.env.example new file mode 100644 index 000000000..7edf19f23 --- /dev/null +++ b/services/mana-api-gateway/.env.example @@ -0,0 +1,34 @@ +# Server +PORT=3030 +NODE_ENV=development + +# Database (same as mana-core-auth) +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/manacore + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Backend Services +SEARCH_SERVICE_URL=http://localhost:3021 +STT_SERVICE_URL=http://localhost:3020 +TTS_SERVICE_URL=http://localhost:3022 + +# Auth Service (for JWT validation & credits) +MANA_CORE_AUTH_URL=http://localhost:3001 + +# API Key Generation +API_KEY_PREFIX_LIVE=sk_live_ +API_KEY_PREFIX_TEST=sk_test_ + +# Rate Limiting Defaults +DEFAULT_RATE_LIMIT=10 +DEFAULT_MONTHLY_CREDITS=100 + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Development Auth Bypass +DEV_BYPASS_AUTH=true +DEV_USER_ID=00000000-0000-0000-0000-000000000000 diff --git a/services/mana-api-gateway/.gitignore b/services/mana-api-gateway/.gitignore new file mode 100644 index 000000000..6684e57ab --- /dev/null +++ b/services/mana-api-gateway/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Test coverage +coverage/ + +# Drizzle +drizzle/meta/ diff --git a/services/mana-api-gateway/CLAUDE.md b/services/mana-api-gateway/CLAUDE.md new file mode 100644 index 000000000..8f58e0c4d --- /dev/null +++ b/services/mana-api-gateway/CLAUDE.md @@ -0,0 +1,274 @@ +# Mana API Gateway + +Custom NestJS API Gateway for monetizing ManaCore services (mana-search, mana-stt, mana-tts). + +## Overview + +- **Port**: 3030 +- **Technology**: NestJS 10 + Drizzle ORM + Redis +- **Purpose**: API Key Management, Usage Tracking, Rate Limiting, Credit-based Billing + +## Architecture + +``` + ┌─────────────────────────┐ + │ API Gateway │ + │ (Port 3030) │ + │ │ + Clients ───────────>│ • API Key Validation │ + (X-API-Key Header) │ • Rate Limiting │ + │ • Usage Tracking │ + │ • Credit Deduction │ + └───────────┬─────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ mana-search │ │ mana-stt │ │ mana-tts │ + │ (Port 3021) │ │ (Port 3020) │ │ (Port 3022) │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +## Quick Start + +### Development + +```bash +# Install dependencies +pnpm install + +# Push database schema +pnpm db:push + +# Start in development mode +pnpm dev +``` + +### Production + +```bash +# Build +pnpm build + +# Start +pnpm start +``` + +## API Endpoints + +### Public API (with API Key) + +| Method | Endpoint | Description | Credits | +|--------|----------|-------------|---------| +| POST | `/v1/search` | Web search | 1 | +| POST | `/v1/extract` | Content extraction | 1 | +| POST | `/v1/extract/bulk` | Bulk extraction | 1 per URL | +| GET | `/v1/search/engines` | Available search engines | 0 | +| POST | `/v1/stt/transcribe` | Audio → Text | 10/min | +| GET | `/v1/stt/models` | Available STT models | 0 | +| GET | `/v1/stt/languages` | Supported languages | 0 | +| POST | `/v1/tts/synthesize` | Text → Audio | 1/1000 chars | +| GET | `/v1/tts/voices` | Available voices | 0 | +| GET | `/v1/tts/languages` | Supported languages | 0 | + +### Management API (with JWT) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api-keys` | Create new API key | +| GET | `/api-keys` | List all API keys | +| GET | `/api-keys/:id` | Get API key details | +| PATCH | `/api-keys/:id` | Update API key | +| DELETE | `/api-keys/:id` | Delete API key | +| POST | `/api-keys/:id/regenerate` | Regenerate API key | +| GET | `/api-keys/:id/usage` | Get usage statistics | +| GET | `/api-keys/:id/usage/summary` | Get usage summary | + +### System + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Health check | +| GET | `/metrics` | Prometheus metrics | + +## Pricing Tiers + +| Tier | Rate Limit | Monthly Credits | Endpoints | Price | +|------|------------|-----------------|-----------|-------| +| Free | 10 req/min | 100 | Search only | Free | +| Pro | 100 req/min | 5,000 | All | €19/month | +| Enterprise | 1,000 req/min | 50,000 | All | €99/month | + +## Credit Costs + +| Operation | Cost | +|-----------|------| +| Search | 1 credit | +| Extract | 1 credit | +| STT | 10 credits/minute | +| TTS | 1 credit/1000 chars | + +## Usage Examples + +### Create an API Key + +```bash +# First, get a JWT token from mana-core-auth +TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com", "password": "password"}' | jq -r '.accessToken') + +# Create an API key +curl -X POST http://localhost:3030/api-keys \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "My API Key", "tier": "free"}' +``` + +### Use the API + +```bash +# Search +curl -X POST http://localhost:3030/v1/search \ + -H "X-API-Key: sk_live_xxx" \ + -H "Content-Type: application/json" \ + -d '{"query": "quantum computing"}' + +# Extract content +curl -X POST http://localhost:3030/v1/extract \ + -H "X-API-Key: sk_live_xxx" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/article"}' + +# Text-to-Speech +curl -X POST http://localhost:3030/v1/tts/synthesize \ + -H "X-API-Key: sk_live_xxx" \ + -H "Content-Type: application/json" \ + -d '{"text": "Hello, world!", "voice": "en-US-1"}' \ + --output audio.mp3 + +# Speech-to-Text +curl -X POST http://localhost:3030/v1/stt/transcribe \ + -H "X-API-Key: sk_live_xxx" \ + -F "file=@audio.wav" +``` + +### Check Usage + +```bash +curl http://localhost:3030/api-keys/{id}/usage \ + -H "Authorization: Bearer $TOKEN" +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | 3030 | API port | +| `DATABASE_URL` | - | PostgreSQL connection URL | +| `REDIS_HOST` | localhost | Redis host | +| `REDIS_PORT` | 6379 | Redis port | +| `SEARCH_SERVICE_URL` | http://localhost:3021 | mana-search URL | +| `STT_SERVICE_URL` | http://localhost:3020 | mana-stt URL | +| `TTS_SERVICE_URL` | http://localhost:3022 | mana-tts URL | +| `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL | + +## Development Commands + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev + +# Build for production +pnpm build + +# Start production server +pnpm start + +# Type checking +pnpm type-check + +# Linting +pnpm lint + +# Database commands +pnpm db:push # Push schema to database +pnpm db:generate # Generate migrations +pnpm db:migrate # Run migrations +pnpm db:studio # Open Drizzle Studio +``` + +## Database Schema + +The gateway uses its own schema (`api_gateway`) in the shared ManaCore database: + +- `api_gateway.api_keys` - API key storage and configuration +- `api_gateway.api_usage` - Detailed usage logs +- `api_gateway.api_usage_daily` - Aggregated daily usage for billing + +## Rate Limiting + +Rate limiting uses Redis with a sliding window algorithm: +- Each API key has a configurable rate limit (requests per minute) +- Rate limit headers are included in responses: + - `X-RateLimit-Limit` - Maximum requests per minute + - `X-RateLimit-Remaining` - Remaining requests + - `X-RateLimit-Reset` - Unix timestamp when limit resets + +## Authentication + +Two types of authentication: +1. **X-API-Key header** - For public API endpoints (`/v1/*`) +2. **Bearer JWT token** - For management endpoints (`/api-keys/*`) + +The JWT token is validated against mana-core-auth service. + +## Project Structure + +``` +services/mana-api-gateway/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── config/ +│ │ ├── configuration.ts # App configuration +│ │ └── pricing.ts # Pricing tiers and credit costs +│ ├── db/ +│ │ ├── schema/ # Drizzle schemas +│ │ ├── database.module.ts # Database provider +│ │ ├── connection.ts # DB connection +│ │ └── migrate.ts # Migration script +│ ├── api-keys/ # API key management +│ ├── usage/ # Usage tracking +│ ├── proxy/ # Proxy services to backends +│ ├── guards/ # Auth, rate limit, credits guards +│ ├── common/ # Decorators, filters, interceptors +│ ├── credits/ # Credits service (mana-core-auth client) +│ ├── metrics/ # Prometheus metrics +│ └── health/ # Health check endpoint +├── drizzle.config.ts # Drizzle Kit configuration +├── package.json +├── tsconfig.json +└── Dockerfile +``` + +## Troubleshooting + +### API Key not working + +1. Check the key is valid: `curl -H "X-API-Key: $KEY" http://localhost:3030/health` +2. Check the key is active in the database +3. Check the key hasn't expired +4. Check the endpoint is allowed for the key's tier + +### Rate limit exceeded + +Wait for the `X-RateLimit-Reset` timestamp, or upgrade to a higher tier. + +### Credits exhausted + +Check usage with the management API, or wait for monthly reset. diff --git a/services/mana-api-gateway/Dockerfile b/services/mana-api-gateway/Dockerfile new file mode 100644 index 000000000..74076aec6 --- /dev/null +++ b/services/mana-api-gateway/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +WORKDIR /app + +# Install dependencies +FROM base AS deps +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile --prod=false + +# Build the application +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +# Production image +FROM base AS runner +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs +USER nestjs + +# Copy built application +COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist +COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nestjs:nodejs /app/package.json ./ + +EXPOSE 3030 + +CMD ["node", "dist/main.js"] diff --git a/services/mana-api-gateway/drizzle.config.ts b/services/mana-api-gateway/drizzle.config.ts new file mode 100644 index 000000000..f6741218a --- /dev/null +++ b/services/mana-api-gateway/drizzle.config.ts @@ -0,0 +1,6 @@ +import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; + +export default createDrizzleConfig({ + dbName: 'manacore', + schemaFilter: ['api_gateway'], +}); diff --git a/services/mana-api-gateway/nest-cli.json b/services/mana-api-gateway/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/mana-api-gateway/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/mana-api-gateway/package.json b/services/mana-api-gateway/package.json new file mode 100644 index 000000000..903f275e5 --- /dev/null +++ b/services/mana-api-gateway/package.json @@ -0,0 +1,59 @@ +{ + "name": "@manacore/api-gateway", + "version": "1.0.0", + "description": "ManaCore API Gateway for monetizing core services", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@manacore/shared-nestjs-auth": "workspace:*", + "@nestjs/common": "^10.4.17", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.17", + "@nestjs/platform-express": "^10.4.17", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "drizzle-orm": "^0.38.4", + "ioredis": "^5.4.2", + "multer": "^1.4.5-lts.1", + "postgres": "^3.4.5", + "prom-client": "^15.1.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@manacore/shared-drizzle-config": "workspace:*", + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@nestjs/testing": "^10.4.17", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^22.10.7", + "drizzle-kit": "^0.30.4", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "tsx": "^4.19.2", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } +} diff --git a/services/mana-api-gateway/src/api-keys/api-keys.controller.ts b/services/mana-api-gateway/src/api-keys/api-keys.controller.ts new file mode 100644 index 000000000..d922d19d0 --- /dev/null +++ b/services/mana-api-gateway/src/api-keys/api-keys.controller.ts @@ -0,0 +1,97 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ApiKeysService } from './api-keys.service'; +import { CreateApiKeyDto, UpdateApiKeyDto } from './dto'; +import { UsageService } from '../usage/usage.service'; + +@Controller('api-keys') +@UseGuards(JwtAuthGuard) +export class ApiKeysController { + constructor( + private readonly apiKeyService: ApiKeysService, + private readonly usageService: UsageService + ) {} + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) { + const result = await this.apiKeyService.create(user.userId, dto); + return { + message: 'API key created successfully. Save your key - it will not be shown again.', + key: result.key, + apiKey: result.apiKey, + }; + } + + @Get() + async list(@CurrentUser() user: CurrentUserData) { + const keys = await this.apiKeyService.listByUser(user.userId); + return { apiKeys: keys }; + } + + @Get(':id') + async get(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + const key = await this.apiKeyService.getByIdAndUser(id, user.userId); + return { apiKey: key }; + } + + @Patch(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateApiKeyDto + ) { + const key = await this.apiKeyService.update(id, user.userId, dto); + return { apiKey: key }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.apiKeyService.delete(id, user.userId); + return { message: 'API key deleted successfully' }; + } + + @Post(':id/regenerate') + async regenerate(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + const result = await this.apiKeyService.regenerate(id, user.userId); + return { + message: 'API key regenerated successfully. Save your new key - it will not be shown again.', + key: result.key, + apiKey: result.apiKey, + }; + } + + @Get(':id/usage') + async getUsage( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Query('days') days?: string + ) { + // Verify ownership + await this.apiKeyService.getByIdAndUser(id, user.userId); + + const daysNum = parseInt(days || '30', 10); + const usage = await this.usageService.getDailyUsage(id, daysNum); + + return { usage }; + } + + @Get(':id/usage/summary') + async getUsageSummary(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + // Verify ownership + await this.apiKeyService.getByIdAndUser(id, user.userId); + + const summary = await this.usageService.getUsageSummary(id); + + return { summary }; + } +} diff --git a/services/mana-api-gateway/src/api-keys/api-keys.module.ts b/services/mana-api-gateway/src/api-keys/api-keys.module.ts new file mode 100644 index 000000000..3c577b564 --- /dev/null +++ b/services/mana-api-gateway/src/api-keys/api-keys.module.ts @@ -0,0 +1,12 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeysService } from './api-keys.service'; +import { UsageModule } from '../usage/usage.module'; + +@Module({ + imports: [forwardRef(() => UsageModule)], + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/services/mana-api-gateway/src/api-keys/api-keys.service.ts b/services/mana-api-gateway/src/api-keys/api-keys.service.ts new file mode 100644 index 000000000..66b369145 --- /dev/null +++ b/services/mana-api-gateway/src/api-keys/api-keys.service.ts @@ -0,0 +1,277 @@ +import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and } from 'drizzle-orm'; +import * as crypto from 'crypto'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { apiKeys, ApiKey, NewApiKey } from '../db/schema'; +import { CreateApiKeyDto, UpdateApiKeyDto } from './dto'; +import { PRICING_TIERS, PricingTier } from '../config/pricing'; + +export interface ApiKeyData { + id: string; + userId: string | null; + organizationId: string | null; + name: string; + tier: string; + rateLimit: number; + monthlyCredits: number; + creditsUsed: number; + allowedEndpoints: string | null; + allowedIps: string | null; + active: boolean; + expiresAt: Date | null; + lastUsedAt: Date | null; +} + +@Injectable() +export class ApiKeysService { + private readonly keyPrefixLive: string; + private readonly keyPrefixTest: string; + + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: ReturnType, + private readonly configService: ConfigService + ) { + this.keyPrefixLive = this.configService.get('apiKey.prefixLive') || 'sk_live_'; + this.keyPrefixTest = this.configService.get('apiKey.prefixTest') || 'sk_test_'; + } + + /** + * Generate a new API key + */ + private generateKey(isTest: boolean = false): { key: string; hash: string; prefix: string } { + const prefix = isTest ? this.keyPrefixTest : this.keyPrefixLive; + const randomPart = crypto.randomBytes(24).toString('base64url'); + const key = `${prefix}${randomPart}`; + const hash = crypto.createHash('sha256').update(key).digest('hex'); + return { key, hash, prefix }; + } + + /** + * Create a new API key for a user + */ + async create(userId: string, dto: CreateApiKeyDto): Promise<{ key: string; apiKey: ApiKey }> { + const { key, hash, prefix } = this.generateKey(dto.isTest); + const tier = (dto.tier || 'free') as PricingTier; + const tierConfig = PRICING_TIERS[tier]; + + const newKey: NewApiKey = { + key: key, + keyHash: hash, + keyPrefix: prefix, + userId, + name: dto.name, + description: dto.description, + tier, + rateLimit: tierConfig.rateLimit, + monthlyCredits: tierConfig.monthlyCredits, + creditsUsed: 0, + creditsResetAt: this.getNextMonthReset(), + allowedEndpoints: dto.allowedEndpoints + ? JSON.stringify(dto.allowedEndpoints) + : JSON.stringify(tierConfig.endpoints), + allowedIps: dto.allowedIps ? JSON.stringify(dto.allowedIps) : null, + active: true, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, + }; + + const [created] = await this.db.insert(apiKeys).values(newKey).returning(); + + // Return the full key only on creation (it's not stored) + return { + key, + apiKey: { ...created, key: this.maskKey(key) }, + }; + } + + /** + * List all API keys for a user (keys are masked) + */ + async listByUser(userId: string): Promise { + const keys = await this.db.select().from(apiKeys).where(eq(apiKeys.userId, userId)); + + return keys.map((k) => ({ + ...k, + key: this.maskKey(k.key), + })); + } + + /** + * Get a single API key by ID (verified for user ownership) + */ + async getByIdAndUser(id: string, userId: string): Promise { + const [key] = await this.db + .select() + .from(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))); + + if (!key) { + throw new NotFoundException('API key not found'); + } + + return { ...key, key: this.maskKey(key.key) }; + } + + /** + * Validate an API key and return its data + */ + async validateKey(rawKey: string): Promise { + const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); + + const [key] = await this.db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)); + + if (!key) { + return null; + } + + // Update last used timestamp + await this.db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, key.id)); + + return { + id: key.id, + userId: key.userId, + organizationId: key.organizationId, + name: key.name, + tier: key.tier, + rateLimit: key.rateLimit, + monthlyCredits: key.monthlyCredits, + creditsUsed: key.creditsUsed, + allowedEndpoints: key.allowedEndpoints, + allowedIps: key.allowedIps, + active: key.active, + expiresAt: key.expiresAt, + lastUsedAt: key.lastUsedAt, + }; + } + + /** + * Update an API key + */ + async update(id: string, userId: string, dto: UpdateApiKeyDto): Promise { + // Verify ownership + await this.getByIdAndUser(id, userId); + + const updates: Partial = { + updatedAt: new Date(), + }; + + if (dto.name !== undefined) updates.name = dto.name; + if (dto.description !== undefined) updates.description = dto.description; + if (dto.allowedEndpoints !== undefined) { + updates.allowedEndpoints = JSON.stringify(dto.allowedEndpoints); + } + if (dto.allowedIps !== undefined) { + updates.allowedIps = JSON.stringify(dto.allowedIps); + } + if (dto.active !== undefined) updates.active = dto.active; + if (dto.expiresAt !== undefined) { + updates.expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null; + } + + const [updated] = await this.db + .update(apiKeys) + .set(updates) + .where(eq(apiKeys.id, id)) + .returning(); + + return { ...updated, key: this.maskKey(updated.key) }; + } + + /** + * Delete an API key + */ + async delete(id: string, userId: string): Promise { + // Verify ownership + await this.getByIdAndUser(id, userId); + + await this.db.delete(apiKeys).where(eq(apiKeys.id, id)); + } + + /** + * Regenerate an API key + */ + async regenerate(id: string, userId: string): Promise<{ key: string; apiKey: ApiKey }> { + // Verify ownership + const existing = await this.getByIdAndUser(id, userId); + const isTest = existing.keyPrefix === this.keyPrefixTest; + + const { key, hash, prefix } = this.generateKey(isTest); + + const [updated] = await this.db + .update(apiKeys) + .set({ + key, + keyHash: hash, + keyPrefix: prefix, + updatedAt: new Date(), + }) + .where(eq(apiKeys.id, id)) + .returning(); + + return { + key, + apiKey: { ...updated, key: this.maskKey(key) }, + }; + } + + /** + * Increment credits used for an API key + */ + async incrementCreditsUsed(id: string, amount: number): Promise { + const [key] = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)); + + if (key) { + // Check if we need to reset credits + if (key.creditsResetAt && new Date() > key.creditsResetAt) { + await this.db + .update(apiKeys) + .set({ + creditsUsed: amount, + creditsResetAt: this.getNextMonthReset(), + }) + .where(eq(apiKeys.id, id)); + } else { + await this.db + .update(apiKeys) + .set({ + creditsUsed: key.creditsUsed + amount, + }) + .where(eq(apiKeys.id, id)); + } + } + } + + /** + * Check if API key has enough credits + */ + async hasEnoughCredits(id: string, requiredCredits: number): Promise { + const [key] = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)); + + if (!key) return false; + + // Check if we need to reset credits + if (key.creditsResetAt && new Date() > key.creditsResetAt) { + return true; // Credits will be reset + } + + return key.creditsUsed + requiredCredits <= key.monthlyCredits; + } + + /** + * Mask an API key for display (show only prefix and last 4 chars) + */ + private maskKey(key: string): string { + if (key.length <= 12) return key; + const prefix = key.startsWith(this.keyPrefixTest) ? this.keyPrefixTest : this.keyPrefixLive; + return `${prefix}...${key.slice(-4)}`; + } + + /** + * Get the next month reset date + */ + private getNextMonthReset(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, 1); + } +} diff --git a/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts b/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts new file mode 100644 index 000000000..a6ce57d06 --- /dev/null +++ b/services/mana-api-gateway/src/api-keys/dto/create-api-key.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsOptional, IsEnum, IsArray, IsDateString } from 'class-validator'; +import { PricingTier } from '../../config/pricing'; + +export class CreateApiKeyDto { + @IsString() + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + @IsEnum(['free', 'pro', 'enterprise']) + tier?: PricingTier; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + allowedEndpoints?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + allowedIps?: string[]; + + @IsDateString() + @IsOptional() + expiresAt?: string; + + @IsOptional() + isTest?: boolean; +} diff --git a/services/mana-api-gateway/src/api-keys/dto/index.ts b/services/mana-api-gateway/src/api-keys/dto/index.ts new file mode 100644 index 000000000..0ef554cf7 --- /dev/null +++ b/services/mana-api-gateway/src/api-keys/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-api-key.dto'; +export * from './update-api-key.dto'; diff --git a/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts b/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts new file mode 100644 index 000000000..2ae7d11d0 --- /dev/null +++ b/services/mana-api-gateway/src/api-keys/dto/update-api-key.dto.ts @@ -0,0 +1,29 @@ +import { IsString, IsOptional, IsBoolean, IsArray, IsDateString } from 'class-validator'; + +export class UpdateApiKeyDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + allowedEndpoints?: string[]; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + allowedIps?: string[]; + + @IsBoolean() + @IsOptional() + active?: boolean; + + @IsDateString() + @IsOptional() + expiresAt?: string; +} diff --git a/services/mana-api-gateway/src/app.module.ts b/services/mana-api-gateway/src/app.module.ts new file mode 100644 index 000000000..8d32c9fb9 --- /dev/null +++ b/services/mana-api-gateway/src/app.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import configuration from './config/configuration'; +import { DatabaseModule } from './db/database.module'; +import { HealthModule } from './health/health.module'; +import { ApiKeysModule } from './api-keys/api-keys.module'; +import { UsageModule } from './usage/usage.module'; +import { ProxyModule } from './proxy/proxy.module'; +import { CreditsModule } from './credits/credits.module'; +import { MetricsModule } from './metrics/metrics.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + DatabaseModule, + HealthModule, + ApiKeysModule, + UsageModule, + ProxyModule, + CreditsModule, + MetricsModule, + ], +}) +export class AppModule {} diff --git a/services/mana-api-gateway/src/common/decorators/api-key.decorator.ts b/services/mana-api-gateway/src/common/decorators/api-key.decorator.ts new file mode 100644 index 000000000..973fbcba6 --- /dev/null +++ b/services/mana-api-gateway/src/common/decorators/api-key.decorator.ts @@ -0,0 +1,22 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { ApiKeyData } from '../../api-keys/api-keys.service'; + +/** + * Parameter decorator to extract the validated API key data from the request. + * Must be used with ApiKeyGuard. + * + * @example + * ```typescript + * @Post('search') + * @UseGuards(ApiKeyGuard) + * search(@ApiKeyData() apiKey: ApiKeyData) { + * return { keyId: apiKey.id }; + * } + * ``` + */ +export const ApiKeyParam = createParamDecorator( + (data: unknown, ctx: ExecutionContext): ApiKeyData => { + const request = ctx.switchToHttp().getRequest(); + return request.apiKey; + } +); diff --git a/services/mana-api-gateway/src/common/filters/http-exception.filter.ts b/services/mana-api-gateway/src/common/filters/http-exception.filter.ts new file mode 100644 index 000000000..11e292fcc --- /dev/null +++ b/services/mana-api-gateway/src/common/filters/http-exception.filter.ts @@ -0,0 +1,37 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let details: any = undefined; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if (typeof exceptionResponse === 'object') { + const resp = exceptionResponse as any; + message = resp.message || message; + details = resp; + } + } else if (exception instanceof Error) { + message = exception.message; + console.error('Unhandled exception:', exception); + } + + response.status(status).json({ + statusCode: status, + message, + timestamp: new Date().toISOString(), + ...(details && status !== HttpStatus.INTERNAL_SERVER_ERROR && { details }), + }); + } +} diff --git a/services/mana-api-gateway/src/common/interceptors/usage-tracking.interceptor.ts b/services/mana-api-gateway/src/common/interceptors/usage-tracking.interceptor.ts new file mode 100644 index 000000000..5826f48b9 --- /dev/null +++ b/services/mana-api-gateway/src/common/interceptors/usage-tracking.interceptor.ts @@ -0,0 +1,113 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap, catchError } from 'rxjs/operators'; +import { ApiKeyData } from '../../api-keys/api-keys.service'; +import { ApiKeysService } from '../../api-keys/api-keys.service'; +import { UsageService } from '../../usage/usage.service'; +import { CreditsService } from '../../credits/credits.service'; +import { CREDIT_COSTS } from '../../config/pricing'; + +@Injectable() +export class UsageTrackingInterceptor implements NestInterceptor { + constructor( + private readonly usageService: UsageService, + private readonly creditsService: CreditsService, + private readonly apiKeysService: ApiKeysService + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const apiKey = request.apiKey as ApiKeyData; + const startTime = Date.now(); + + if (!apiKey) { + return next.handle(); + } + + return next.handle().pipe( + tap(async (responseBody) => { + const latencyMs = Date.now() - startTime; + const endpoint = this.extractEndpoint(request.path); + + // Calculate credits + const creditsUsed = this.calculateCredits(endpoint, request, responseBody); + + // Track usage + await this.usageService.track({ + apiKeyId: apiKey.id, + endpoint, + method: request.method, + path: request.path, + latencyMs, + statusCode: response.statusCode || 200, + creditsUsed, + metadata: { + userAgent: request.headers['user-agent'], + }, + }); + + // Increment credits used on the API key + if (creditsUsed > 0) { + await this.apiKeysService.incrementCreditsUsed(apiKey.id, creditsUsed); + } + + // Deduct credits from user account if applicable + if (apiKey.userId && creditsUsed > 0) { + try { + await this.creditsService.deduct(apiKey.userId, creditsUsed, { + appId: 'api-gateway', + description: `API: ${endpoint}`, + apiKeyId: apiKey.id, + }); + } catch (error) { + // Log but don't fail the request + console.error('Failed to deduct credits from user account:', error); + } + } + }), + catchError(async (error) => { + const latencyMs = Date.now() - startTime; + const endpoint = this.extractEndpoint(request.path); + + // Track failed requests (no credits deducted) + await this.usageService.track({ + apiKeyId: apiKey.id, + endpoint, + method: request.method, + path: request.path, + latencyMs, + statusCode: error.status || 500, + creditsUsed: 0, + metadata: { + userAgent: request.headers['user-agent'], + error: error.message, + }, + }); + + throw error; + }) + ); + } + + private extractEndpoint(path: string): string { + const match = path.match(/\/v1\/(\w+)/); + return match ? match[1] : 'unknown'; + } + + private calculateCredits(endpoint: string, request: any, response: any): number { + switch (endpoint) { + case 'search': + return CREDIT_COSTS.search; + case 'tts': + const text = request.body?.text || ''; + return Math.max(1, Math.ceil(text.length / 1000) * CREDIT_COSTS.tts.per1000Chars); + case 'stt': + // Calculate from actual audio duration if available in response + const minutes = response?.duration ? response.duration / 60 : 1; + return Math.max(1, Math.ceil(minutes) * CREDIT_COSTS.stt.perMinute); + default: + return 0; + } + } +} diff --git a/services/mana-api-gateway/src/config/configuration.ts b/services/mana-api-gateway/src/config/configuration.ts new file mode 100644 index 000000000..83bffa5b9 --- /dev/null +++ b/services/mana-api-gateway/src/config/configuration.ts @@ -0,0 +1,42 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3030', 10), + nodeEnv: process.env.NODE_ENV || 'development', + + database: { + url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore', + }, + + cors: { + origins: process.env.CORS_ORIGINS?.split(',') || [ + 'http://localhost:3000', + 'http://localhost:5173', + ], + }, + + redis: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD, + keyPrefix: 'api-gateway:', + }, + + services: { + search: process.env.SEARCH_SERVICE_URL || 'http://localhost:3021', + stt: process.env.STT_SERVICE_URL || 'http://localhost:3020', + tts: process.env.TTS_SERVICE_URL || 'http://localhost:3022', + }, + + auth: { + url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001', + }, + + apiKey: { + prefixLive: process.env.API_KEY_PREFIX_LIVE || 'sk_live_', + prefixTest: process.env.API_KEY_PREFIX_TEST || 'sk_test_', + }, + + defaults: { + rateLimit: parseInt(process.env.DEFAULT_RATE_LIMIT || '10', 10), + monthlyCredits: parseInt(process.env.DEFAULT_MONTHLY_CREDITS || '100', 10), + }, +}); diff --git a/services/mana-api-gateway/src/config/pricing.ts b/services/mana-api-gateway/src/config/pricing.ts new file mode 100644 index 000000000..419dc30ef --- /dev/null +++ b/services/mana-api-gateway/src/config/pricing.ts @@ -0,0 +1,41 @@ +export const PRICING_TIERS = { + free: { + name: 'Free', + rateLimit: 10, // 10 requests/minute + monthlyCredits: 100, + endpoints: ['search'] as const, // Only search + features: [] as const, + price: 0, + }, + pro: { + name: 'Pro', + rateLimit: 100, // 100 requests/minute + monthlyCredits: 5000, + endpoints: ['search', 'stt', 'tts'] as const, + features: ['priority_support'] as const, + price: 1900, // 19 EUR in cents + }, + enterprise: { + name: 'Enterprise', + rateLimit: 1000, // 1000 requests/minute + monthlyCredits: 50000, + endpoints: ['search', 'stt', 'tts'] as const, + features: ['priority_support', 'sla', 'dedicated_support'] as const, + price: 9900, // 99 EUR in cents + }, +} as const; + +export type PricingTier = keyof typeof PRICING_TIERS; + +// Credit costs per operation +export const CREDIT_COSTS = { + search: 1, // 1 credit per search + stt: { + perMinute: 10, // 10 credits per minute of audio + }, + tts: { + per1000Chars: 1, // 1 credit per 1000 characters + }, +} as const; + +export type Endpoint = 'search' | 'stt' | 'tts'; diff --git a/services/mana-api-gateway/src/credits/credits.module.ts b/services/mana-api-gateway/src/credits/credits.module.ts new file mode 100644 index 000000000..bb26d3817 --- /dev/null +++ b/services/mana-api-gateway/src/credits/credits.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CreditsService } from './credits.service'; + +@Module({ + providers: [CreditsService], + exports: [CreditsService], +}) +export class CreditsModule {} diff --git a/services/mana-api-gateway/src/credits/credits.service.ts b/services/mana-api-gateway/src/credits/credits.service.ts new file mode 100644 index 000000000..9cfe190da --- /dev/null +++ b/services/mana-api-gateway/src/credits/credits.service.ts @@ -0,0 +1,92 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface DeductCreditsOptions { + appId: string; + description: string; + apiKeyId?: string; + metadata?: Record; +} + +export interface CreditBalance { + balance: number; + freeCreditsRemaining: number; + dailyFreeCredits: number; +} + +@Injectable() +export class CreditsService { + private readonly authUrl: string; + + constructor(private readonly configService: ConfigService) { + this.authUrl = this.configService.get('auth.url') || 'http://localhost:3001'; + } + + /** + * Deduct credits from a user's account + */ + async deduct(userId: string, amount: number, options: DeductCreditsOptions): Promise { + const response = await fetch(`${this.authUrl}/api/v1/credits/consume`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + amount, + appId: options.appId, + description: options.description, + metadata: { + ...options.metadata, + apiKeyId: options.apiKeyId, + source: 'api-gateway', + }, + }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error(`Failed to deduct credits for user ${userId}:`, error); + // Don't throw - credit deduction failure shouldn't fail the request + // The API key credits are already tracked + } + } + + /** + * Get a user's credit balance + */ + async getBalance(userId: string): Promise { + try { + const response = await fetch(`${this.authUrl}/api/v1/credits/balance/${userId}`); + + if (!response.ok) { + return null; + } + + return response.json(); + } catch (error) { + console.error(`Failed to get credit balance for user ${userId}:`, error); + return null; + } + } + + /** + * Add credits to a user's account (for testing/admin) + */ + async addCredits(userId: string, amount: number, reason: string): Promise { + const response = await fetch(`${this.authUrl}/api/v1/credits/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId, + amount, + appId: 'api-gateway', + description: reason, + type: 'bonus', + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new HttpException(`Failed to add credits: ${error}`, HttpStatus.BAD_GATEWAY); + } + } +} diff --git a/services/mana-api-gateway/src/db/connection.ts b/services/mana-api-gateway/src/db/connection.ts new file mode 100644 index 000000000..e84b0fa08 --- /dev/null +++ b/services/mana-api-gateway/src/db/connection.ts @@ -0,0 +1,33 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let connection: ReturnType | null = null; +let db: ReturnType | null = null; + +export function getConnection(databaseUrl: string) { + if (!connection) { + connection = postgres(databaseUrl, { + max: 10, + idle_timeout: 20, + connect_timeout: 10, + }); + } + return connection; +} + +export function getDb(databaseUrl: string) { + if (!db) { + const conn = getConnection(databaseUrl); + db = drizzle(conn, { schema }); + } + return db; +} + +export async function closeConnection() { + if (connection) { + await connection.end(); + connection = null; + db = null; + } +} diff --git a/services/mana-api-gateway/src/db/database.module.ts b/services/mana-api-gateway/src/db/database.module.ts new file mode 100644 index 000000000..ffc4b6b06 --- /dev/null +++ b/services/mana-api-gateway/src/db/database.module.ts @@ -0,0 +1,24 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { getDb } from './connection'; + +export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; + +@Global() +@Module({ + providers: [ + { + provide: DATABASE_CONNECTION, + useFactory: (configService: ConfigService) => { + const databaseUrl = configService.get('database.url'); + if (!databaseUrl) { + throw new Error('DATABASE_URL is not configured'); + } + return getDb(databaseUrl); + }, + inject: [ConfigService], + }, + ], + exports: [DATABASE_CONNECTION], +}) +export class DatabaseModule {} diff --git a/services/mana-api-gateway/src/db/migrate.ts b/services/mana-api-gateway/src/db/migrate.ts new file mode 100644 index 000000000..5d69c9b19 --- /dev/null +++ b/services/mana-api-gateway/src/db/migrate.ts @@ -0,0 +1,25 @@ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; + +async function main() { + const databaseUrl = + process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/manacore'; + + console.log('Connecting to database...'); + const connection = postgres(databaseUrl, { max: 1 }); + const db = drizzle(connection); + + console.log('Running migrations...'); + await migrate(db, { migrationsFolder: './src/db/migrations' }); + + console.log('Migrations complete!'); + await connection.end(); + process.exit(0); +} + +main().catch((err) => { + console.error('Migration failed:', err); + process.exit(1); +}); diff --git a/services/mana-api-gateway/src/db/schema/api-keys.schema.ts b/services/mana-api-gateway/src/db/schema/api-keys.schema.ts new file mode 100644 index 000000000..3ab247adc --- /dev/null +++ b/services/mana-api-gateway/src/db/schema/api-keys.schema.ts @@ -0,0 +1,51 @@ +import { pgSchema, uuid, text, integer, boolean, timestamp, index } from 'drizzle-orm/pg-core'; + +export const apiGatewaySchema = pgSchema('api_gateway'); + +export const apiKeys = apiGatewaySchema.table( + 'api_keys', + { + id: uuid('id').defaultRandom().primaryKey(), + + // Key identifiers + key: text('key').notNull().unique(), // sk_live_xxx or sk_test_xxx + keyHash: text('key_hash').notNull(), // SHA256 hash for lookup + keyPrefix: text('key_prefix').notNull(), // sk_live_ or sk_test_ + + // Owner (can be user or organization) + userId: text('user_id'), // B2C owner + organizationId: text('organization_id'), // B2B owner + + // Metadata + name: text('name').notNull(), // "Production API Key" + description: text('description'), + + // Tier & Limits + tier: text('tier').notNull().default('free'), // free, pro, enterprise + rateLimit: integer('rate_limit').notNull().default(10), // requests/minute + monthlyCredits: integer('monthly_credits').notNull().default(100), + creditsUsed: integer('credits_used').notNull().default(0), + creditsResetAt: timestamp('credits_reset_at', { withTimezone: true }), + + // Permissions + allowedEndpoints: text('allowed_endpoints'), // JSON array: ["search", "tts"] + allowedIps: text('allowed_ips'), // JSON array or null for any + + // Status + active: boolean('active').notNull().default(true), + expiresAt: timestamp('expires_at', { withTimezone: true }), + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + keyHashIdx: index('api_keys_key_hash_idx').on(table.keyHash), + userIdIdx: index('api_keys_user_id_idx').on(table.userId), + organizationIdIdx: index('api_keys_organization_id_idx').on(table.organizationId), + }) +); + +export type ApiKey = typeof apiKeys.$inferSelect; +export type NewApiKey = typeof apiKeys.$inferInsert; diff --git a/services/mana-api-gateway/src/db/schema/index.ts b/services/mana-api-gateway/src/db/schema/index.ts new file mode 100644 index 000000000..6988544a9 --- /dev/null +++ b/services/mana-api-gateway/src/db/schema/index.ts @@ -0,0 +1,2 @@ +export * from './api-keys.schema'; +export * from './usage.schema'; diff --git a/services/mana-api-gateway/src/db/schema/usage.schema.ts b/services/mana-api-gateway/src/db/schema/usage.schema.ts new file mode 100644 index 000000000..2d2bdf353 --- /dev/null +++ b/services/mana-api-gateway/src/db/schema/usage.schema.ts @@ -0,0 +1,70 @@ +import { uuid, text, integer, timestamp, jsonb, index, unique, date } from 'drizzle-orm/pg-core'; +import { apiGatewaySchema, apiKeys } from './api-keys.schema'; + +// Detailed usage log +export const apiUsage = apiGatewaySchema.table( + 'api_usage', + { + id: uuid('id').defaultRandom().primaryKey(), + + // Key reference + apiKeyId: uuid('api_key_id') + .references(() => apiKeys.id, { onDelete: 'cascade' }) + .notNull(), + + // Request details + endpoint: text('endpoint').notNull(), // search, stt, tts + method: text('method').notNull(), // POST, GET + path: text('path').notNull(), // /v1/search + + // Metrics + requestSize: integer('request_size'), // bytes + responseSize: integer('response_size'), // bytes + latencyMs: integer('latency_ms'), // milliseconds + statusCode: integer('status_code'), // 200, 400, 500... + + // Credit calculation + creditsUsed: integer('credits_used').notNull().default(0), + creditReason: text('credit_reason'), // "1000 characters TTS" + + // Metadata + metadata: jsonb('metadata'), // additional context (user agent, etc.) + + // Timestamp + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + apiKeyIdIdx: index('api_usage_api_key_id_idx').on(table.apiKeyId), + createdAtIdx: index('api_usage_created_at_idx').on(table.createdAt), + endpointIdx: index('api_usage_endpoint_idx').on(table.endpoint), + }) +); + +// Aggregated daily usage (for dashboard/billing) +export const apiUsageDaily = apiGatewaySchema.table( + 'api_usage_daily', + { + id: uuid('id').defaultRandom().primaryKey(), + apiKeyId: uuid('api_key_id') + .references(() => apiKeys.id, { onDelete: 'cascade' }) + .notNull(), + date: date('date').notNull(), + endpoint: text('endpoint').notNull(), + + // Aggregates + requestCount: integer('request_count').notNull().default(0), + creditsUsed: integer('credits_used').notNull().default(0), + totalLatencyMs: integer('total_latency_ms').notNull().default(0), + errorCount: integer('error_count').notNull().default(0), + }, + (table) => ({ + // Unique constraint for upsert + uniqueDaily: unique('api_usage_daily_unique').on(table.apiKeyId, table.date, table.endpoint), + dateIdx: index('api_usage_daily_date_idx').on(table.date), + }) +); + +export type ApiUsage = typeof apiUsage.$inferSelect; +export type NewApiUsage = typeof apiUsage.$inferInsert; +export type ApiUsageDaily = typeof apiUsageDaily.$inferSelect; +export type NewApiUsageDaily = typeof apiUsageDaily.$inferInsert; diff --git a/services/mana-api-gateway/src/guards/api-key.guard.ts b/services/mana-api-gateway/src/guards/api-key.guard.ts new file mode 100644 index 000000000..6be87f6ef --- /dev/null +++ b/services/mana-api-gateway/src/guards/api-key.guard.ts @@ -0,0 +1,91 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, + ForbiddenException, +} from '@nestjs/common'; +import { ApiKeysService, ApiKeyData } from '../api-keys/api-keys.service'; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private readonly apiKeyService: ApiKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const apiKey = this.extractApiKey(request); + + if (!apiKey) { + throw new UnauthorizedException('API key required. Use X-API-Key header.'); + } + + // Validate key + const keyData = await this.apiKeyService.validateKey(apiKey); + + if (!keyData) { + throw new UnauthorizedException('Invalid API key'); + } + + if (!keyData.active) { + throw new UnauthorizedException('API key is disabled'); + } + + if (keyData.expiresAt && new Date(keyData.expiresAt) < new Date()) { + throw new UnauthorizedException('API key has expired'); + } + + // Check endpoint permission + const endpoint = this.extractEndpoint(request.path); + if (!this.hasEndpointPermission(keyData, endpoint)) { + throw new ForbiddenException( + `Endpoint '${endpoint}' not allowed for this API key. Upgrade your plan to access this endpoint.` + ); + } + + // Check IP restriction + if (!this.hasIpPermission(keyData, request)) { + throw new ForbiddenException('Request from this IP address is not allowed'); + } + + // Attach key data to request for later use + request.apiKey = keyData; + + return true; + } + + private extractApiKey(request: any): string | undefined { + return request.headers['x-api-key']; + } + + private extractEndpoint(path: string): string { + // /v1/search -> search, /v1/stt/transcribe -> stt + const match = path.match(/\/v1\/(\w+)/); + return match ? match[1] : 'unknown'; + } + + private hasEndpointPermission(keyData: ApiKeyData, endpoint: string): boolean { + if (!keyData.allowedEndpoints) return true; // No restrictions + try { + const allowed = JSON.parse(keyData.allowedEndpoints) as string[]; + return allowed.includes(endpoint); + } catch { + return true; + } + } + + private hasIpPermission(keyData: ApiKeyData, request: any): boolean { + if (!keyData.allowedIps) return true; // No restrictions + + try { + const allowedIps = JSON.parse(keyData.allowedIps) as string[]; + const clientIp = + request.headers['x-forwarded-for']?.split(',')[0]?.trim() || + request.connection?.remoteAddress || + request.ip; + + return allowedIps.includes(clientIp); + } catch { + return true; + } + } +} diff --git a/services/mana-api-gateway/src/guards/credits.guard.ts b/services/mana-api-gateway/src/guards/credits.guard.ts new file mode 100644 index 000000000..acf485b8f --- /dev/null +++ b/services/mana-api-gateway/src/guards/credits.guard.ts @@ -0,0 +1,63 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { ApiKeysService, ApiKeyData } from '../api-keys/api-keys.service'; +import { CREDIT_COSTS } from '../config/pricing'; + +@Injectable() +export class CreditsGuard implements CanActivate { + constructor(private readonly apiKeyService: ApiKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const apiKey = request.apiKey as ApiKeyData; + + if (!apiKey) { + return true; // Let ApiKeyGuard handle missing key + } + + const endpoint = this.extractEndpoint(request.path); + const estimatedCredits = this.estimateCredits(endpoint, request); + + const hasCredits = await this.apiKeyService.hasEnoughCredits(apiKey.id, estimatedCredits); + + if (!hasCredits) { + throw new HttpException( + { + statusCode: HttpStatus.PAYMENT_REQUIRED, + message: 'Insufficient credits. Please upgrade your plan or wait for monthly reset.', + creditsRequired: estimatedCredits, + creditsUsed: apiKey.creditsUsed, + monthlyLimit: apiKey.monthlyCredits, + }, + HttpStatus.PAYMENT_REQUIRED + ); + } + + return true; + } + + private extractEndpoint(path: string): string { + const match = path.match(/\/v1\/(\w+)/); + return match ? match[1] : 'unknown'; + } + + private estimateCredits(endpoint: string, request: any): number { + switch (endpoint) { + case 'search': + return CREDIT_COSTS.search; + case 'tts': + const text = request.body?.text || ''; + return Math.max(1, Math.ceil(text.length / 1000) * CREDIT_COSTS.tts.per1000Chars); + case 'stt': + // Estimate based on file size or default to 1 minute + return CREDIT_COSTS.stt.perMinute; + default: + return 0; + } + } +} diff --git a/services/mana-api-gateway/src/guards/index.ts b/services/mana-api-gateway/src/guards/index.ts new file mode 100644 index 000000000..2279e9341 --- /dev/null +++ b/services/mana-api-gateway/src/guards/index.ts @@ -0,0 +1,3 @@ +export * from './api-key.guard'; +export * from './rate-limit.guard'; +export * from './credits.guard'; diff --git a/services/mana-api-gateway/src/guards/rate-limit.guard.ts b/services/mana-api-gateway/src/guards/rate-limit.guard.ts new file mode 100644 index 000000000..6bc4c3990 --- /dev/null +++ b/services/mana-api-gateway/src/guards/rate-limit.guard.ts @@ -0,0 +1,70 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Inject, +} from '@nestjs/common'; +import Redis from 'ioredis'; +import { ApiKeyData } from '../api-keys/api-keys.service'; + +export const REDIS_CLIENT = 'REDIS_CLIENT'; + +@Injectable() +export class RateLimitGuard implements CanActivate { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const apiKey = request.apiKey as ApiKeyData; + + if (!apiKey) { + return true; // Let ApiKeyGuard handle missing key + } + + const key = `ratelimit:${apiKey.id}`; + const limit = apiKey.rateLimit; + const window = 60; // 60 seconds + + // Sliding window rate limiting using sorted set + const now = Date.now(); + const windowStart = now - window * 1000; + + // Remove old entries + await this.redis.zremrangebyscore(key, 0, windowStart); + + // Count current requests + const count = await this.redis.zcard(key); + + if (count >= limit) { + // Get the oldest entry to calculate retry-after + const oldestEntries = await this.redis.zrange(key, 0, 0, 'WITHSCORES'); + const oldestTimestamp = oldestEntries.length > 1 ? parseInt(oldestEntries[1], 10) : now; + const retryAfter = Math.ceil((oldestTimestamp + window * 1000 - now) / 1000); + + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + retryAfter, + limit, + remaining: 0, + }, + HttpStatus.TOO_MANY_REQUESTS + ); + } + + // Add current request + await this.redis.zadd(key, now, `${now}`); + await this.redis.expire(key, window); + + // Add rate limit headers to response + const response = context.switchToHttp().getResponse(); + response.setHeader('X-RateLimit-Limit', limit); + response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count - 1)); + response.setHeader('X-RateLimit-Reset', Math.ceil(now / 1000) + window); + + return true; + } +} diff --git a/services/mana-api-gateway/src/health/health.controller.ts b/services/mana-api-gateway/src/health/health.controller.ts new file mode 100644 index 000000000..064010928 --- /dev/null +++ b/services/mana-api-gateway/src/health/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check() { + return { + status: 'ok', + service: 'api-gateway', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/mana-api-gateway/src/health/health.module.ts b/services/mana-api-gateway/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/services/mana-api-gateway/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/services/mana-api-gateway/src/main.ts b/services/mana-api-gateway/src/main.ts new file mode 100644 index 000000000..0516e5a4a --- /dev/null +++ b/services/mana-api-gateway/src/main.ts @@ -0,0 +1,36 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port') || 3030; + const corsOrigins = configService.get('cors.origins') || []; + + // Enable CORS + app.enableCors({ + origin: corsOrigins, + credentials: true, + }); + + // Global validation pipe + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }) + ); + + // Global exception filter + app.useGlobalFilters(new HttpExceptionFilter()); + + await app.listen(port); + console.log(`API Gateway running on port ${port}`); +} + +bootstrap(); diff --git a/services/mana-api-gateway/src/metrics/metrics.controller.ts b/services/mana-api-gateway/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..d2e9c04ba --- /dev/null +++ b/services/mana-api-gateway/src/metrics/metrics.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { MetricsService } from './metrics.service'; + +@Controller('metrics') +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get() + async getMetrics(@Res() res: Response) { + const metrics = await this.metricsService.getMetrics(); + res.setHeader('Content-Type', this.metricsService.getContentType()); + res.send(metrics); + } +} diff --git a/services/mana-api-gateway/src/metrics/metrics.module.ts b/services/mana-api-gateway/src/metrics/metrics.module.ts new file mode 100644 index 000000000..d52db9f12 --- /dev/null +++ b/services/mana-api-gateway/src/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Global() +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/services/mana-api-gateway/src/metrics/metrics.service.ts b/services/mana-api-gateway/src/metrics/metrics.service.ts new file mode 100644 index 000000000..7cd98aee9 --- /dev/null +++ b/services/mana-api-gateway/src/metrics/metrics.service.ts @@ -0,0 +1,82 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import * as client from 'prom-client'; + +@Injectable() +export class MetricsService implements OnModuleInit { + private readonly register: client.Registry; + + // Counters + public readonly requestsTotal: client.Counter; + public readonly creditsUsedTotal: client.Counter; + public readonly errorsTotal: client.Counter; + + // Histograms + public readonly requestDuration: client.Histogram; + + // Gauges + public readonly activeApiKeys: client.Gauge; + public readonly rateLimitExceeded: client.Counter; + + constructor() { + this.register = new client.Registry(); + + // Add default metrics + client.collectDefaultMetrics({ register: this.register }); + + // Custom metrics + this.requestsTotal = new client.Counter({ + name: 'api_gateway_requests_total', + help: 'Total number of API requests', + labelNames: ['endpoint', 'method', 'status', 'tier'], + registers: [this.register], + }); + + this.creditsUsedTotal = new client.Counter({ + name: 'api_gateway_credits_used_total', + help: 'Total credits consumed', + labelNames: ['endpoint', 'tier'], + registers: [this.register], + }); + + this.errorsTotal = new client.Counter({ + name: 'api_gateway_errors_total', + help: 'Total number of errors', + labelNames: ['endpoint', 'error_type'], + registers: [this.register], + }); + + this.requestDuration = new client.Histogram({ + name: 'api_gateway_request_duration_seconds', + help: 'Request duration in seconds', + labelNames: ['endpoint', 'method'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10], + registers: [this.register], + }); + + this.activeApiKeys = new client.Gauge({ + name: 'api_gateway_active_api_keys', + help: 'Number of active API keys', + labelNames: ['tier'], + registers: [this.register], + }); + + this.rateLimitExceeded = new client.Counter({ + name: 'api_gateway_rate_limit_exceeded_total', + help: 'Total number of rate limit exceeded events', + labelNames: ['tier'], + registers: [this.register], + }); + } + + onModuleInit() { + // Initial setup if needed + } + + async getMetrics(): Promise { + return this.register.metrics(); + } + + getContentType(): string { + return this.register.contentType; + } +} diff --git a/services/mana-api-gateway/src/proxy/proxy.controller.ts b/services/mana-api-gateway/src/proxy/proxy.controller.ts new file mode 100644 index 000000000..4121d03c3 --- /dev/null +++ b/services/mana-api-gateway/src/proxy/proxy.controller.ts @@ -0,0 +1,103 @@ +import { + Controller, + Post, + Get, + Body, + UseGuards, + UseInterceptors, + UploadedFile, + Res, + Query, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { ApiKeyGuard } from '../guards/api-key.guard'; +import { RateLimitGuard } from '../guards/rate-limit.guard'; +import { CreditsGuard } from '../guards/credits.guard'; +import { UsageTrackingInterceptor } from '../common/interceptors/usage-tracking.interceptor'; +import { ApiKeyParam } from '../common/decorators/api-key.decorator'; +import { ApiKeyData } from '../api-keys/api-keys.service'; +import { + SearchProxyService, + SearchRequestDto, + ExtractRequestDto, + BulkExtractRequestDto, +} from './services/search-proxy.service'; +import { SttProxyService, TranscribeRequestDto } from './services/stt-proxy.service'; +import { TtsProxyService, SynthesizeRequestDto } from './services/tts-proxy.service'; + +@Controller('v1') +@UseGuards(ApiKeyGuard, RateLimitGuard, CreditsGuard) +@UseInterceptors(UsageTrackingInterceptor) +export class ProxyController { + constructor( + private readonly searchProxy: SearchProxyService, + private readonly sttProxy: SttProxyService, + private readonly ttsProxy: TtsProxyService + ) {} + + // === SEARCH === + + @Post('search') + async search(@Body() body: SearchRequestDto, @ApiKeyParam() apiKey: ApiKeyData) { + return this.searchProxy.search(body); + } + + @Get('search/engines') + async getEngines() { + return this.searchProxy.getEngines(); + } + + @Post('extract') + async extract(@Body() body: ExtractRequestDto) { + return this.searchProxy.extract(body); + } + + @Post('extract/bulk') + async bulkExtract(@Body() body: BulkExtractRequestDto) { + return this.searchProxy.bulkExtract(body); + } + + // === STT === + + @Post('stt/transcribe') + @UseInterceptors(FileInterceptor('file')) + async transcribe(@UploadedFile() file: Express.Multer.File, @Body() body: TranscribeRequestDto) { + return this.sttProxy.transcribe(file, body); + } + + @Get('stt/models') + async getSttModels() { + return this.sttProxy.getModels(); + } + + @Get('stt/languages') + async getSttLanguages() { + return this.sttProxy.getLanguages(); + } + + // === TTS === + + @Post('tts/synthesize') + async synthesize(@Body() body: SynthesizeRequestDto, @Res() res: Response) { + const audio = await this.ttsProxy.synthesize(body); + + const format = body.format || 'mp3'; + const contentType = + format === 'wav' ? 'audio/wav' : format === 'ogg' ? 'audio/ogg' : 'audio/mpeg'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', audio.length); + res.send(audio); + } + + @Get('tts/voices') + async getTtsVoices() { + return this.ttsProxy.getVoices(); + } + + @Get('tts/languages') + async getTtsLanguages() { + return this.ttsProxy.getLanguages(); + } +} diff --git a/services/mana-api-gateway/src/proxy/proxy.module.ts b/services/mana-api-gateway/src/proxy/proxy.module.ts new file mode 100644 index 000000000..0c53a77dc --- /dev/null +++ b/services/mana-api-gateway/src/proxy/proxy.module.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { ProxyController } from './proxy.controller'; +import { SearchProxyService, SttProxyService, TtsProxyService } from './services'; +import { ApiKeysModule } from '../api-keys/api-keys.module'; +import { UsageModule } from '../usage/usage.module'; +import { CreditsModule } from '../credits/credits.module'; +import { ApiKeyGuard } from '../guards/api-key.guard'; +import { RateLimitGuard, REDIS_CLIENT } from '../guards/rate-limit.guard'; +import { CreditsGuard } from '../guards/credits.guard'; +import { UsageTrackingInterceptor } from '../common/interceptors/usage-tracking.interceptor'; + +@Module({ + imports: [ + MulterModule.register({ + storage: memoryStorage(), + limits: { + fileSize: 100 * 1024 * 1024, // 100MB max file size + }, + }), + ApiKeysModule, + UsageModule, + CreditsModule, + ], + controllers: [ProxyController], + providers: [ + SearchProxyService, + SttProxyService, + TtsProxyService, + ApiKeyGuard, + RateLimitGuard, + CreditsGuard, + UsageTrackingInterceptor, + { + provide: REDIS_CLIENT, + useFactory: (configService: ConfigService) => { + const host = configService.get('redis.host') || 'localhost'; + const port = configService.get('redis.port') || 6379; + const password = configService.get('redis.password'); + + return new Redis({ + host, + port, + password: password || undefined, + keyPrefix: configService.get('redis.keyPrefix') || 'api-gateway:', + }); + }, + inject: [ConfigService], + }, + ], + exports: [REDIS_CLIENT], +}) +export class ProxyModule {} diff --git a/services/mana-api-gateway/src/proxy/services/index.ts b/services/mana-api-gateway/src/proxy/services/index.ts new file mode 100644 index 000000000..de4b49657 --- /dev/null +++ b/services/mana-api-gateway/src/proxy/services/index.ts @@ -0,0 +1,3 @@ +export * from './search-proxy.service'; +export * from './stt-proxy.service'; +export * from './tts-proxy.service'; diff --git a/services/mana-api-gateway/src/proxy/services/search-proxy.service.ts b/services/mana-api-gateway/src/proxy/services/search-proxy.service.ts new file mode 100644 index 000000000..d4f52afe2 --- /dev/null +++ b/services/mana-api-gateway/src/proxy/services/search-proxy.service.ts @@ -0,0 +1,102 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface SearchRequestDto { + query: string; + options?: { + categories?: string[]; + engines?: string[]; + language?: string; + limit?: number; + }; +} + +export interface ExtractRequestDto { + url: string; + options?: { + includeMarkdown?: boolean; + maxLength?: number; + }; +} + +export interface BulkExtractRequestDto { + urls: string[]; + options?: { + includeMarkdown?: boolean; + maxLength?: number; + }; + concurrency?: number; +} + +@Injectable() +export class SearchProxyService { + private readonly searchUrl: string; + + constructor(private readonly configService: ConfigService) { + this.searchUrl = this.configService.get('services.search') || 'http://localhost:3021'; + } + + async search(body: SearchRequestDto): Promise { + const response = await fetch(`${this.searchUrl}/api/v1/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new HttpException( + `Search service error: ${error}`, + response.status || HttpStatus.BAD_GATEWAY + ); + } + + return response.json(); + } + + async extract(body: ExtractRequestDto): Promise { + const response = await fetch(`${this.searchUrl}/api/v1/extract`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new HttpException( + `Extract service error: ${error}`, + response.status || HttpStatus.BAD_GATEWAY + ); + } + + return response.json(); + } + + async bulkExtract(body: BulkExtractRequestDto): Promise { + const response = await fetch(`${this.searchUrl}/api/v1/extract/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new HttpException( + `Bulk extract service error: ${error}`, + response.status || HttpStatus.BAD_GATEWAY + ); + } + + return response.json(); + } + + async getEngines(): Promise { + const response = await fetch(`${this.searchUrl}/api/v1/search/engines`); + + if (!response.ok) { + throw new HttpException('Failed to get search engines', HttpStatus.BAD_GATEWAY); + } + + return response.json(); + } +} diff --git a/services/mana-api-gateway/src/proxy/services/stt-proxy.service.ts b/services/mana-api-gateway/src/proxy/services/stt-proxy.service.ts new file mode 100644 index 000000000..502ff43c2 --- /dev/null +++ b/services/mana-api-gateway/src/proxy/services/stt-proxy.service.ts @@ -0,0 +1,64 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface TranscribeRequestDto { + language?: string; + model?: string; +} + +@Injectable() +export class SttProxyService { + private readonly sttUrl: string; + + constructor(private readonly configService: ConfigService) { + this.sttUrl = this.configService.get('services.stt') || 'http://localhost:3020'; + } + + async transcribe(file: Express.Multer.File, options: TranscribeRequestDto): Promise { + const formData = new FormData(); + const uint8Array = new Uint8Array(file.buffer); + formData.append('file', new Blob([uint8Array], { type: file.mimetype }), file.originalname); + + if (options.language) { + formData.append('language', options.language); + } + if (options.model) { + formData.append('model', options.model); + } + + const response = await fetch(`${this.sttUrl}/api/v1/transcribe`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.text(); + throw new HttpException( + `STT service error: ${error}`, + response.status || HttpStatus.BAD_GATEWAY + ); + } + + return response.json(); + } + + async getModels(): Promise { + const response = await fetch(`${this.sttUrl}/api/v1/models`); + + if (!response.ok) { + throw new HttpException('Failed to get STT models', HttpStatus.BAD_GATEWAY); + } + + return response.json(); + } + + async getLanguages(): Promise { + const response = await fetch(`${this.sttUrl}/api/v1/languages`); + + if (!response.ok) { + throw new HttpException('Failed to get STT languages', HttpStatus.BAD_GATEWAY); + } + + return response.json(); + } +} diff --git a/services/mana-api-gateway/src/proxy/services/tts-proxy.service.ts b/services/mana-api-gateway/src/proxy/services/tts-proxy.service.ts new file mode 100644 index 000000000..b4e23bf48 --- /dev/null +++ b/services/mana-api-gateway/src/proxy/services/tts-proxy.service.ts @@ -0,0 +1,58 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface SynthesizeRequestDto { + text: string; + voice?: string; + language?: string; + speed?: number; + format?: 'mp3' | 'wav' | 'ogg'; +} + +@Injectable() +export class TtsProxyService { + private readonly ttsUrl: string; + + constructor(private readonly configService: ConfigService) { + this.ttsUrl = this.configService.get('services.tts') || 'http://localhost:3022'; + } + + async synthesize(body: SynthesizeRequestDto): Promise { + const response = await fetch(`${this.ttsUrl}/api/v1/synthesize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.text(); + throw new HttpException( + `TTS service error: ${error}`, + response.status || HttpStatus.BAD_GATEWAY + ); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } + + async getVoices(): Promise { + const response = await fetch(`${this.ttsUrl}/api/v1/voices`); + + if (!response.ok) { + throw new HttpException('Failed to get TTS voices', HttpStatus.BAD_GATEWAY); + } + + return response.json(); + } + + async getLanguages(): Promise { + const response = await fetch(`${this.ttsUrl}/api/v1/languages`); + + if (!response.ok) { + throw new HttpException('Failed to get TTS languages', HttpStatus.BAD_GATEWAY); + } + + return response.json(); + } +} diff --git a/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts b/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts new file mode 100644 index 000000000..6fea48760 --- /dev/null +++ b/services/mana-api-gateway/src/usage/dto/usage-query.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsString, IsDateString, IsInt, Min, Max } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class UsageQueryDto { + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + @Max(365) + days?: number = 30; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsString() + endpoint?: string; +} diff --git a/services/mana-api-gateway/src/usage/usage.module.ts b/services/mana-api-gateway/src/usage/usage.module.ts new file mode 100644 index 000000000..992ea5821 --- /dev/null +++ b/services/mana-api-gateway/src/usage/usage.module.ts @@ -0,0 +1,10 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { UsageService } from './usage.service'; +import { ApiKeysModule } from '../api-keys/api-keys.module'; + +@Module({ + imports: [forwardRef(() => ApiKeysModule)], + providers: [UsageService], + exports: [UsageService], +}) +export class UsageModule {} diff --git a/services/mana-api-gateway/src/usage/usage.service.ts b/services/mana-api-gateway/src/usage/usage.service.ts new file mode 100644 index 000000000..37448d4fb --- /dev/null +++ b/services/mana-api-gateway/src/usage/usage.service.ts @@ -0,0 +1,187 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, sql, gte, and, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { apiUsage, apiUsageDaily, NewApiUsage } from '../db/schema'; + +export interface TrackUsageParams { + apiKeyId: string; + endpoint: string; + method: string; + path: string; + latencyMs: number; + statusCode: number; + creditsUsed: number; + requestSize?: number; + responseSize?: number; + creditReason?: string; + metadata?: Record; +} + +export interface UsageSummary { + totalRequests: number; + totalCreditsUsed: number; + avgLatencyMs: number; + errorCount: number; + byEndpoint: Record< + string, + { + requests: number; + credits: number; + avgLatencyMs: number; + errors: number; + } + >; +} + +@Injectable() +export class UsageService { + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: ReturnType + ) {} + + /** + * Track a single API usage event + */ + async track(params: TrackUsageParams): Promise { + const usage: NewApiUsage = { + apiKeyId: params.apiKeyId, + endpoint: params.endpoint, + method: params.method, + path: params.path, + latencyMs: params.latencyMs, + statusCode: params.statusCode, + creditsUsed: params.creditsUsed, + requestSize: params.requestSize, + responseSize: params.responseSize, + creditReason: params.creditReason, + metadata: params.metadata, + }; + + await this.db.insert(apiUsage).values(usage); + + // Also update daily aggregates + await this.updateDailyAggregate(params); + } + + /** + * Update daily usage aggregate + */ + private async updateDailyAggregate(params: TrackUsageParams): Promise { + const today = new Date().toISOString().split('T')[0]; + const isError = params.statusCode >= 400; + + // Upsert daily aggregate + await this.db + .insert(apiUsageDaily) + .values({ + apiKeyId: params.apiKeyId, + date: today, + endpoint: params.endpoint, + requestCount: 1, + creditsUsed: params.creditsUsed, + totalLatencyMs: params.latencyMs, + errorCount: isError ? 1 : 0, + }) + .onConflictDoUpdate({ + target: [apiUsageDaily.apiKeyId, apiUsageDaily.date, apiUsageDaily.endpoint], + set: { + requestCount: sql`${apiUsageDaily.requestCount} + 1`, + creditsUsed: sql`${apiUsageDaily.creditsUsed} + ${params.creditsUsed}`, + totalLatencyMs: sql`${apiUsageDaily.totalLatencyMs} + ${params.latencyMs}`, + errorCount: isError ? sql`${apiUsageDaily.errorCount} + 1` : apiUsageDaily.errorCount, + }, + }); + } + + /** + * Get daily usage for an API key + */ + async getDailyUsage(apiKeyId: string, days: number = 30) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const usage = await this.db + .select() + .from(apiUsageDaily) + .where( + and( + eq(apiUsageDaily.apiKeyId, apiKeyId), + gte(apiUsageDaily.date, startDate.toISOString().split('T')[0]) + ) + ) + .orderBy(desc(apiUsageDaily.date)); + + return usage; + } + + /** + * Get usage summary for an API key + */ + async getUsageSummary(apiKeyId: string, days: number = 30): Promise { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const dailyUsage = await this.getDailyUsage(apiKeyId, days); + + const summary: UsageSummary = { + totalRequests: 0, + totalCreditsUsed: 0, + avgLatencyMs: 0, + errorCount: 0, + byEndpoint: {}, + }; + + let totalLatency = 0; + + for (const day of dailyUsage) { + summary.totalRequests += day.requestCount; + summary.totalCreditsUsed += day.creditsUsed; + totalLatency += day.totalLatencyMs; + summary.errorCount += day.errorCount; + + if (!summary.byEndpoint[day.endpoint]) { + summary.byEndpoint[day.endpoint] = { + requests: 0, + credits: 0, + avgLatencyMs: 0, + errors: 0, + }; + } + + const ep = summary.byEndpoint[day.endpoint]; + ep.requests += day.requestCount; + ep.credits += day.creditsUsed; + ep.avgLatencyMs += day.totalLatencyMs; + ep.errors += day.errorCount; + } + + if (summary.totalRequests > 0) { + summary.avgLatencyMs = Math.round(totalLatency / summary.totalRequests); + } + + // Calculate average latency per endpoint + for (const endpoint of Object.keys(summary.byEndpoint)) { + const ep = summary.byEndpoint[endpoint]; + if (ep.requests > 0) { + ep.avgLatencyMs = Math.round(ep.avgLatencyMs / ep.requests); + } + } + + return summary; + } + + /** + * Get recent usage logs for an API key + */ + async getRecentLogs(apiKeyId: string, limit: number = 100) { + const logs = await this.db + .select() + .from(apiUsage) + .where(eq(apiUsage.apiKeyId, apiKeyId)) + .orderBy(desc(apiUsage.createdAt)) + .limit(limit); + + return logs; + } +} diff --git a/services/mana-api-gateway/tsconfig.build.json b/services/mana-api-gateway/tsconfig.build.json new file mode 100644 index 000000000..045c9529c --- /dev/null +++ b/services/mana-api-gateway/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/services/mana-api-gateway/tsconfig.json b/services/mana-api-gateway/tsconfig.json new file mode 100644 index 000000000..f02c2417e --- /dev/null +++ b/services/mana-api-gateway/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}