feat(mana-search): rewrite search service from NestJS to Go

Replaces the NestJS mana-search service with a Go implementation for
lower resource usage and faster startup. All 7 API endpoints are 1:1
compatible (search, extract, bulk extract, engines, health, metrics,
cache clear). Uses go-readability for content extraction and
html-to-markdown for Markdown conversion. Redis cache with graceful
degradation, Prometheus metrics, and structured JSON logging.

Binary: 22 MB vs ~200+ MB node_modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 21:58:40 +01:00
parent c67ed0df14
commit 4b0f5a29fd
74 changed files with 1607 additions and 3594 deletions

View file

@ -1,37 +0,0 @@
# 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
# Admin Access (comma-separated user IDs)
ADMIN_USER_IDS=

View file

@ -1,31 +0,0 @@
# 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/

View file

@ -1,287 +0,0 @@
# 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 |
### Admin API (with JWT + Admin Role)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/admin/api-keys` | List all API keys (paginated) |
| GET | `/admin/api-keys/:id` | Get any API key details |
| PATCH | `/admin/api-keys/:id` | Update any key (tier, credits, limits) |
| DELETE | `/admin/api-keys/:id` | Delete any API key |
| GET | `/admin/usage/summary` | System-wide usage stats |
| GET | `/admin/usage/top-users` | Top users by usage |
### System
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/health` | Health check |
| GET | `/metrics` | Prometheus metrics |
| GET | `/docs` | Swagger/OpenAPI documentation |
## 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 |
| `ADMIN_USER_IDS` | - | Comma-separated admin user IDs |
## 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.

View file

@ -1,49 +0,0 @@
# syntax=docker/dockerfile:1
# ================================
# Build Stage (Monorepo-aware)
# ================================
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 pnpm-workspace.yaml ./
COPY services/mana-api-gateway/package.json ./services/mana-api-gateway/
COPY packages/shared-nestjs-auth/package.json ./packages/shared-nestjs-auth/
COPY packages/shared-drizzle-config/package.json ./packages/shared-drizzle-config/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --filter @manacore/api-gateway...
# Build the application
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/services/mana-api-gateway/node_modules ./services/mana-api-gateway/node_modules
COPY --from=deps /app/packages/shared-nestjs-auth/node_modules ./packages/shared-nestjs-auth/node_modules 2>/dev/null || true
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY services/mana-api-gateway ./services/mana-api-gateway
WORKDIR /app/services/mana-api-gateway
RUN pnpm build
# ================================
# Production Stage
# ================================
FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nestjs
USER nestjs
WORKDIR /app
# Copy built application
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-api-gateway/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-api-gateway/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-api-gateway/package.json ./
EXPOSE 3030
CMD ["node", "dist/main.js"]

View file

@ -1,6 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'manacore',
schemaFilter: ['api_gateway'],
});

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,61 +0,0 @@
{
"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",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^11.2.5",
"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"
}
}

View file

@ -1,118 +0,0 @@
import { Controller, Get, Patch, Delete, Param, Query, Body, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AdminGuard } from '../guards/admin.guard';
import { AdminService } from './admin.service';
import { AdminUpdateKeyDto } from './dto/admin-update-key.dto';
@ApiTags('Admin')
@ApiBearerAuth('jwt')
@Controller('admin')
@UseGuards(JwtAuthGuard, AdminGuard)
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@Get('api-keys')
@ApiOperation({
summary: 'List all API keys',
description: 'Returns all API keys in the system. Requires admin role.',
})
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'userId', required: false, type: String })
@ApiQuery({ name: 'tier', required: false, enum: ['free', 'pro', 'enterprise'] })
@ApiQuery({ name: 'active', required: false, type: Boolean })
@ApiResponse({ status: 200, description: 'List of all API keys' })
@ApiResponse({ status: 403, description: 'Forbidden - admin role required' })
async listAllKeys(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('userId') userId?: string,
@Query('tier') tier?: string,
@Query('active') active?: string
) {
const pageNum = parseInt(page || '1', 10);
const limitNum = parseInt(limit || '50', 10);
const isActive = active === undefined ? undefined : active === 'true';
const result = await this.adminService.listAllKeys({
page: pageNum,
limit: limitNum,
userId,
tier,
active: isActive,
});
return result;
}
@Get('api-keys/:id')
@ApiOperation({
summary: 'Get API key details (admin)',
description: 'Returns full details of any API key including usage stats.',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'API key details with usage' })
@ApiResponse({ status: 404, description: 'API key not found' })
async getKey(@Param('id') id: string) {
return this.adminService.getKeyDetails(id);
}
@Patch('api-keys/:id')
@ApiOperation({
summary: 'Update API key (admin)',
description: 'Update any API key including tier, credits, rate limits.',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'API key updated' })
@ApiResponse({ status: 404, description: 'API key not found' })
async updateKey(@Param('id') id: string, @Body() dto: AdminUpdateKeyDto) {
return this.adminService.updateKey(id, dto);
}
@Delete('api-keys/:id')
@ApiOperation({
summary: 'Delete API key (admin)',
description: 'Permanently delete any API key.',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'API key deleted' })
@ApiResponse({ status: 404, description: 'API key not found' })
async deleteKey(@Param('id') id: string) {
await this.adminService.deleteKey(id);
return { message: 'API key deleted successfully' };
}
@Get('usage/summary')
@ApiOperation({
summary: 'Get system-wide usage summary',
description: 'Returns aggregated usage stats for all API keys.',
})
@ApiQuery({ name: 'days', required: false, type: Number })
@ApiResponse({ status: 200, description: 'System usage summary' })
async getSystemUsage(@Query('days') days?: string) {
const daysNum = parseInt(days || '30', 10);
return this.adminService.getSystemUsage(daysNum);
}
@Get('usage/top-users')
@ApiOperation({
summary: 'Get top users by usage',
description: 'Returns users with highest API usage.',
})
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'days', required: false, type: Number })
@ApiResponse({ status: 200, description: 'Top users by usage' })
async getTopUsers(@Query('limit') limit?: string, @Query('days') days?: string) {
const limitNum = parseInt(limit || '10', 10);
const daysNum = parseInt(days || '30', 10);
return this.adminService.getTopUsers(limitNum, daysNum);
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View file

@ -1,264 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, sql, desc, and, gte } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { apiKeys, apiUsage, apiUsageDaily } from '../db/schema';
import { PRICING_TIERS, PricingTier } from '../config/pricing';
import { AdminUpdateKeyDto } from './dto/admin-update-key.dto';
interface ListKeysOptions {
page: number;
limit: number;
userId?: string;
tier?: string;
active?: boolean;
}
@Injectable()
export class AdminService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: ReturnType<typeof import('../db/connection').getDb>
) {}
async listAllKeys(options: ListKeysOptions) {
const { page, limit, userId, tier, active } = options;
const offset = (page - 1) * limit;
// Build conditions
const conditions = [];
if (userId) {
conditions.push(eq(apiKeys.userId, userId));
}
if (tier) {
conditions.push(eq(apiKeys.tier, tier));
}
if (active !== undefined) {
conditions.push(eq(apiKeys.active, active));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [keys, countResult] = await Promise.all([
this.db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
userId: apiKeys.userId,
organizationId: apiKeys.organizationId,
tier: apiKeys.tier,
rateLimit: apiKeys.rateLimit,
monthlyCredits: apiKeys.monthlyCredits,
creditsUsed: apiKeys.creditsUsed,
active: apiKeys.active,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(whereClause)
.orderBy(desc(apiKeys.createdAt))
.limit(limit)
.offset(offset),
this.db
.select({ count: sql<number>`count(*)` })
.from(apiKeys)
.where(whereClause),
]);
const total = Number(countResult[0]?.count || 0);
return {
apiKeys: keys,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
}
async getKeyDetails(id: string) {
const key = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1);
if (!key.length) {
throw new NotFoundException('API key not found');
}
// Get recent usage
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentUsage = await this.db
.select({
endpoint: apiUsageDaily.endpoint,
requestCount: sql<number>`sum(${apiUsageDaily.requestCount})`,
creditsUsed: sql<number>`sum(${apiUsageDaily.creditsUsed})`,
})
.from(apiUsageDaily)
.where(
and(
eq(apiUsageDaily.apiKeyId, id),
gte(apiUsageDaily.date, thirtyDaysAgo.toISOString().split('T')[0])
)
)
.groupBy(apiUsageDaily.endpoint);
return {
apiKey: {
...key[0],
keyHash: undefined, // Don't expose hash
},
usage: {
last30Days: recentUsage,
},
};
}
async updateKey(id: string, dto: AdminUpdateKeyDto) {
// Verify key exists
const existing = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1);
if (!existing.length) {
throw new NotFoundException('API key not found');
}
// Build update object
const updates: Record<string, unknown> = {
updatedAt: new Date(),
};
if (dto.name !== undefined) updates.name = dto.name;
if (dto.description !== undefined) updates.description = dto.description;
if (dto.active !== undefined) updates.active = dto.active;
if (dto.expiresAt !== undefined) updates.expiresAt = new Date(dto.expiresAt);
if (dto.rateLimit !== undefined) updates.rateLimit = dto.rateLimit;
if (dto.monthlyCredits !== undefined) updates.monthlyCredits = dto.monthlyCredits;
if (dto.allowedEndpoints !== undefined) {
updates.allowedEndpoints = JSON.stringify(dto.allowedEndpoints);
}
if (dto.allowedIps !== undefined) {
updates.allowedIps = JSON.stringify(dto.allowedIps);
}
if (dto.resetCredits) {
updates.creditsUsed = 0;
}
// If tier is changed, apply tier defaults
if (dto.tier !== undefined) {
const tierConfig = PRICING_TIERS[dto.tier as PricingTier];
updates.tier = dto.tier;
// Only apply tier defaults if not explicitly set
if (dto.rateLimit === undefined) updates.rateLimit = tierConfig.rateLimit;
if (dto.monthlyCredits === undefined) updates.monthlyCredits = tierConfig.monthlyCredits;
}
const [updated] = await this.db
.update(apiKeys)
.set(updates)
.where(eq(apiKeys.id, id))
.returning();
return {
apiKey: {
...updated,
keyHash: undefined,
},
};
}
async deleteKey(id: string) {
const existing = await this.db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1);
if (!existing.length) {
throw new NotFoundException('API key not found');
}
// Delete usage data first (foreign key constraint)
await this.db.delete(apiUsageDaily).where(eq(apiUsageDaily.apiKeyId, id));
await this.db.delete(apiUsage).where(eq(apiUsage.apiKeyId, id));
// Delete the key
await this.db.delete(apiKeys).where(eq(apiKeys.id, id));
}
async getSystemUsage(days: number) {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const dailyStats = await this.db
.select({
date: apiUsageDaily.date,
requestCount: sql<number>`sum(${apiUsageDaily.requestCount})`,
creditsUsed: sql<number>`sum(${apiUsageDaily.creditsUsed})`,
errorCount: sql<number>`sum(${apiUsageDaily.errorCount})`,
})
.from(apiUsageDaily)
.where(gte(apiUsageDaily.date, startDate.toISOString().split('T')[0]))
.groupBy(apiUsageDaily.date)
.orderBy(apiUsageDaily.date);
const endpointStats = await this.db
.select({
endpoint: apiUsageDaily.endpoint,
requestCount: sql<number>`sum(${apiUsageDaily.requestCount})`,
creditsUsed: sql<number>`sum(${apiUsageDaily.creditsUsed})`,
})
.from(apiUsageDaily)
.where(gte(apiUsageDaily.date, startDate.toISOString().split('T')[0]))
.groupBy(apiUsageDaily.endpoint);
const tierStats = await this.db
.select({
tier: apiKeys.tier,
keyCount: sql<number>`count(*)`,
activeCount: sql<number>`sum(case when ${apiKeys.active} then 1 else 0 end)`,
})
.from(apiKeys)
.groupBy(apiKeys.tier);
return {
period: {
start: startDate.toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
days,
},
daily: dailyStats,
byEndpoint: endpointStats,
byTier: tierStats,
};
}
async getTopUsers(limit: number, days: number) {
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
const topUsers = await this.db
.select({
apiKeyId: apiUsageDaily.apiKeyId,
keyName: apiKeys.name,
userId: apiKeys.userId,
tier: apiKeys.tier,
requestCount: sql<number>`sum(${apiUsageDaily.requestCount})`,
creditsUsed: sql<number>`sum(${apiUsageDaily.creditsUsed})`,
})
.from(apiUsageDaily)
.innerJoin(apiKeys, eq(apiUsageDaily.apiKeyId, apiKeys.id))
.where(gte(apiUsageDaily.date, startDate.toISOString().split('T')[0]))
.groupBy(apiUsageDaily.apiKeyId, apiKeys.name, apiKeys.userId, apiKeys.tier)
.orderBy(desc(sql`sum(${apiUsageDaily.requestCount})`))
.limit(limit);
return {
period: {
start: startDate.toISOString().split('T')[0],
end: new Date().toISOString().split('T')[0],
days,
},
topUsers,
};
}
}

View file

@ -1,100 +0,0 @@
import {
IsString,
IsOptional,
IsBoolean,
IsArray,
IsDateString,
IsInt,
IsEnum,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class AdminUpdateKeyDto {
@ApiPropertyOptional({
description: 'Update the display name',
example: 'Updated API Key Name',
})
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({
description: 'Update the description',
example: 'Updated description',
})
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description: 'Change the pricing tier',
enum: ['free', 'pro', 'enterprise'],
})
@IsString()
@IsOptional()
@IsEnum(['free', 'pro', 'enterprise'])
tier?: 'free' | 'pro' | 'enterprise';
@ApiPropertyOptional({
description: 'Custom rate limit (requests per minute)',
example: 100,
})
@IsInt()
@IsOptional()
@Min(1)
rateLimit?: number;
@ApiPropertyOptional({
description: 'Custom monthly credits limit',
example: 5000,
})
@IsInt()
@IsOptional()
@Min(0)
monthlyCredits?: number;
@ApiPropertyOptional({
description: 'Reset credits used to 0',
example: true,
})
@IsBoolean()
@IsOptional()
resetCredits?: boolean;
@ApiPropertyOptional({
description: 'Update allowed endpoints',
example: ['search', 'stt', 'tts'],
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
allowedEndpoints?: string[];
@ApiPropertyOptional({
description: 'Update IP whitelist',
example: ['192.168.1.0/24'],
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
allowedIps?: string[];
@ApiPropertyOptional({
description: 'Enable or disable the API key',
example: true,
})
@IsBoolean()
@IsOptional()
active?: boolean;
@ApiPropertyOptional({
description: 'Update expiration date (ISO 8601)',
example: '2025-12-31T23:59:59Z',
})
@IsDateString()
@IsOptional()
expiresAt?: string;
}

View file

@ -1,160 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
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';
@ApiTags('API Keys')
@ApiBearerAuth('jwt')
@Controller('api-keys')
@UseGuards(JwtAuthGuard)
export class ApiKeysController {
constructor(
private readonly apiKeyService: ApiKeysService,
private readonly usageService: UsageService
) {}
@Post()
@ApiOperation({
summary: 'Create API key',
description: 'Creates a new API key. The full key is only returned once - save it securely.',
})
@ApiResponse({ status: 201, description: 'API key created successfully' })
@ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing JWT' })
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()
@ApiOperation({
summary: 'List API keys',
description: 'Returns all API keys for the authenticated user (without full key values)',
})
@ApiResponse({ status: 200, description: 'List of API keys' })
async list(@CurrentUser() user: CurrentUserData) {
const keys = await this.apiKeyService.listByUser(user.userId);
return { apiKeys: keys };
}
@Get(':id')
@ApiOperation({
summary: 'Get API key details',
description: 'Returns details for a specific API key',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'API key details' })
@ApiResponse({ status: 404, description: 'API key not found' })
async get(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const key = await this.apiKeyService.getByIdAndUser(id, user.userId);
return { apiKey: key };
}
@Patch(':id')
@ApiOperation({
summary: 'Update API key',
description: 'Updates name, description, allowed endpoints, IP whitelist, or active status',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'API key updated successfully' })
@ApiResponse({ status: 404, description: 'API key not found' })
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')
@ApiOperation({
summary: 'Delete API key',
description: 'Permanently deletes an API key. This action cannot be undone.',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'API key deleted successfully' })
@ApiResponse({ status: 404, description: 'API key not found' })
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')
@ApiOperation({
summary: 'Regenerate API key',
description:
'Generates a new key value for an existing API key. The old key immediately stops working.',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: 'New key generated successfully' })
@ApiResponse({ status: 404, description: 'API key not found' })
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')
@ApiOperation({
summary: 'Get daily usage',
description: 'Returns daily usage statistics for an API key',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiQuery({ name: 'days', required: false, description: 'Number of days (default: 30)' })
@ApiResponse({ status: 200, description: 'Daily usage statistics' })
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')
@ApiOperation({
summary: 'Get usage summary',
description: 'Returns aggregated usage summary for an API key',
})
@ApiParam({ name: 'id', description: 'API key ID (UUID)' })
@ApiResponse({ status: 200, description: '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 };
}
}

View file

@ -1,12 +0,0 @@
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 {}

View file

@ -1,277 +0,0 @@
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<typeof import('../db/connection').getDb>,
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<ApiKey[]> {
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<ApiKey> {
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<ApiKeyData | null> {
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<ApiKey> {
// Verify ownership
await this.getByIdAndUser(id, userId);
const updates: Partial<NewApiKey> = {
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<void> {
// 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<void> {
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<boolean> {
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);
}
}

View file

@ -1,66 +0,0 @@
import { IsString, IsOptional, IsEnum, IsArray, IsDateString, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PricingTier } from '../../config/pricing';
export class CreateApiKeyDto {
@ApiProperty({
description: 'Display name for the API key',
example: 'Production API Key',
})
@IsString()
name: string;
@ApiPropertyOptional({
description: 'Optional description for the API key',
example: 'Used for production web application',
})
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description: 'Pricing tier (determines rate limits and credits)',
enum: ['free', 'pro', 'enterprise'],
default: 'free',
})
@IsString()
@IsOptional()
@IsEnum(['free', 'pro', 'enterprise'])
tier?: PricingTier;
@ApiPropertyOptional({
description: 'List of allowed endpoints (null = all endpoints allowed)',
example: ['search', 'tts'],
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
allowedEndpoints?: string[];
@ApiPropertyOptional({
description: 'IP whitelist for this key (null = all IPs allowed)',
example: ['192.168.1.0/24', '10.0.0.1'],
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
allowedIps?: string[];
@ApiPropertyOptional({
description: 'Expiration date for the API key (ISO 8601)',
example: '2025-12-31T23:59:59Z',
})
@IsDateString()
@IsOptional()
expiresAt?: string;
@ApiPropertyOptional({
description: 'Create a test key (sk_test_ prefix) instead of live key',
default: false,
})
@IsBoolean()
@IsOptional()
isTest?: boolean;
}

View file

@ -1,2 +0,0 @@
export * from './create-api-key.dto';
export * from './update-api-key.dto';

View file

@ -1,56 +0,0 @@
import { IsString, IsOptional, IsBoolean, IsArray, IsDateString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateApiKeyDto {
@ApiPropertyOptional({
description: 'Update the display name',
example: 'Updated API Key Name',
})
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({
description: 'Update the description',
example: 'Updated description for this key',
})
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({
description: 'Update allowed endpoints',
example: ['search', 'stt', 'tts'],
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
allowedEndpoints?: string[];
@ApiPropertyOptional({
description: 'Update IP whitelist',
example: ['192.168.1.0/24'],
type: [String],
})
@IsArray()
@IsString({ each: true })
@IsOptional()
allowedIps?: string[];
@ApiPropertyOptional({
description: 'Enable or disable the API key',
example: true,
})
@IsBoolean()
@IsOptional()
active?: boolean;
@ApiPropertyOptional({
description: 'Update expiration date (ISO 8601)',
example: '2025-12-31T23:59:59Z',
})
@IsDateString()
@IsOptional()
expiresAt?: string;
}

View file

@ -1,31 +0,0 @@
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';
import { SchedulerModule } from './scheduler/scheduler.module';
import { AdminModule } from './admin/admin.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
DatabaseModule,
HealthModule,
ApiKeysModule,
UsageModule,
ProxyModule,
CreditsModule,
MetricsModule,
SchedulerModule,
AdminModule,
],
})
export class AppModule {}

View file

@ -1,22 +0,0 @@
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;
}
);

View file

@ -1,37 +0,0 @@
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<Response>();
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 }),
});
}
}

View file

@ -1,113 +0,0 @@
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<any> {
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;
}
}
}

View file

@ -1,47 +0,0 @@
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),
},
admin: {
// Comma-separated list of user IDs that have admin access
userIds: process.env.ADMIN_USER_IDS || '',
},
});

View file

@ -1,41 +0,0 @@
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';

View file

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { CreditsService } from './credits.service';
@Module({
providers: [CreditsService],
exports: [CreditsService],
})
export class CreditsModule {}

View file

@ -1,92 +0,0 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface DeductCreditsOptions {
appId: string;
description: string;
apiKeyId?: string;
metadata?: Record<string, any>;
}
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<void> {
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<CreditBalance | null> {
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<void> {
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);
}
}
}

View file

@ -1,33 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | 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;
}
}

View file

@ -1,24 +0,0 @@
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<string>('database.url');
if (!databaseUrl) {
throw new Error('DATABASE_URL is not configured');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -1,25 +0,0 @@
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);
});

View file

@ -1,51 +0,0 @@
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;

View file

@ -1,2 +0,0 @@
export * from './api-keys.schema';
export * from './usage.schema';

View file

@ -1,70 +0,0 @@
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;

View file

@ -1,37 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AdminGuard implements CanActivate {
private readonly adminUserIds: string[];
constructor(private readonly configService: ConfigService) {
// Admin user IDs from environment variable (comma-separated)
const adminIds = this.configService.get<string>('admin.userIds') || '';
this.adminUserIds = adminIds
.split(',')
.map((id) => id.trim())
.filter(Boolean);
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user || !user.userId) {
throw new ForbiddenException('User not authenticated');
}
// Check if user has admin role
if (user.role === 'admin') {
return true;
}
// Check if user ID is in the admin list
if (this.adminUserIds.includes(user.userId)) {
return true;
}
throw new ForbiddenException('Admin access required');
}
}

View file

@ -1,91 +0,0 @@
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<boolean> {
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;
}
}
}

View file

@ -1,63 +0,0 @@
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<boolean> {
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;
}
}
}

View file

@ -1,3 +0,0 @@
export * from './api-key.guard';
export * from './rate-limit.guard';
export * from './credits.guard';

View file

@ -1,70 +0,0 @@
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<boolean> {
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;
}
}

View file

@ -1,31 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('System')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({
summary: 'Health check',
description: 'Returns service health status. No authentication required.',
})
@ApiResponse({
status: 200,
description: 'Service is healthy',
schema: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
service: { type: 'string', example: 'api-gateway' },
timestamp: { type: 'string', example: '2025-01-29T10:30:00.000Z' },
},
},
})
check() {
return {
status: 'ok',
service: 'api-gateway',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -1,78 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
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<number>('port') || 3030;
const corsOrigins = configService.get<string[]>('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());
// Swagger/OpenAPI documentation
const config = new DocumentBuilder()
.setTitle('ManaCore API Gateway')
.setDescription(
'API Gateway for ManaCore services (Search, STT, TTS). ' +
'Use X-API-Key header for public endpoints (/v1/*) and Bearer JWT for management endpoints (/api-keys/*).'
)
.setVersion('1.0')
.addApiKey(
{
type: 'apiKey',
name: 'X-API-Key',
in: 'header',
description: 'API Key for accessing public endpoints',
},
'api-key'
)
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token from mana-core-auth for management endpoints',
},
'jwt'
)
.addTag('Search', 'Web search and content extraction')
.addTag('STT', 'Speech-to-Text transcription')
.addTag('TTS', 'Text-to-Speech synthesis')
.addTag('API Keys', 'API key management (requires JWT authentication)')
.addTag('System', 'Health checks and metrics')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
});
await app.listen(port);
console.log(`API Gateway running on port ${port}`);
console.log(`Swagger docs available at http://localhost:${port}/docs`);
}
bootstrap();

View file

@ -1,26 +0,0 @@
import { Controller, Get, Res } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiProduces } from '@nestjs/swagger';
import { Response } from 'express';
import { MetricsService } from './metrics.service';
@ApiTags('System')
@Controller('metrics')
export class MetricsController {
constructor(private readonly metricsService: MetricsService) {}
@Get()
@ApiOperation({
summary: 'Prometheus metrics',
description: 'Returns Prometheus-formatted metrics for monitoring. No authentication required.',
})
@ApiProduces('text/plain')
@ApiResponse({
status: 200,
description: 'Prometheus metrics in text format',
})
async getMetrics(@Res() res: Response) {
const metrics = await this.metricsService.getMetrics();
res.setHeader('Content-Type', this.metricsService.getContentType());
res.send(metrics);
}
}

View file

@ -1,11 +0,0 @@
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 {}

View file

@ -1,82 +0,0 @@
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<string>;
public readonly creditsUsedTotal: client.Counter<string>;
public readonly errorsTotal: client.Counter<string>;
// Histograms
public readonly requestDuration: client.Histogram<string>;
// Gauges
public readonly activeApiKeys: client.Gauge<string>;
public readonly rateLimitExceeded: client.Counter<string>;
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<string> {
return this.register.metrics();
}
getContentType(): string {
return this.register.contentType;
}
}

View file

@ -1,275 +0,0 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
UseInterceptors,
UploadedFile,
Res,
Query,
} from '@nestjs/common';
import {
ApiTags,
ApiSecurity,
ApiOperation,
ApiResponse,
ApiConsumes,
ApiBody,
ApiHeader,
} from '@nestjs/swagger';
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';
@ApiSecurity('api-key')
@ApiHeader({
name: 'X-API-Key',
description: 'Your API key (sk_live_xxx or sk_test_xxx)',
required: true,
})
@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')
@ApiTags('Search')
@ApiOperation({
summary: 'Web search',
description: 'Search the web using multiple search engines. Costs 1 credit per request.',
})
@ApiBody({
schema: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', example: 'quantum computing' },
options: {
type: 'object',
properties: {
categories: {
type: 'array',
items: { type: 'string' },
example: ['general', 'science'],
},
engines: { type: 'array', items: { type: 'string' }, example: ['google', 'bing'] },
language: { type: 'string', example: 'en' },
limit: { type: 'number', example: 10 },
},
},
},
},
})
@ApiResponse({ status: 200, description: 'Search results' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
@ApiResponse({ status: 402, description: 'Insufficient credits' })
async search(@Body() body: SearchRequestDto, @ApiKeyParam() apiKey: ApiKeyData) {
return this.searchProxy.search(body);
}
@Get('search/engines')
@ApiTags('Search')
@ApiOperation({
summary: 'Get available search engines',
description: 'Returns a list of available search engines and categories. Free endpoint.',
})
@ApiResponse({ status: 200, description: 'List of search engines' })
async getEngines() {
return this.searchProxy.getEngines();
}
@Post('extract')
@ApiTags('Search')
@ApiOperation({
summary: 'Extract content from URL',
description: 'Extracts main content from a webpage. Costs 1 credit per request.',
})
@ApiBody({
schema: {
type: 'object',
required: ['url'],
properties: {
url: { type: 'string', example: 'https://example.com/article' },
options: {
type: 'object',
properties: {
includeMarkdown: { type: 'boolean', example: true },
maxLength: { type: 'number', example: 5000 },
},
},
},
},
})
@ApiResponse({ status: 200, description: 'Extracted content' })
async extract(@Body() body: ExtractRequestDto) {
return this.searchProxy.extract(body);
}
@Post('extract/bulk')
@ApiTags('Search')
@ApiOperation({
summary: 'Bulk extract content',
description: 'Extracts content from multiple URLs (max 20). Costs 1 credit per URL.',
})
@ApiBody({
schema: {
type: 'object',
required: ['urls'],
properties: {
urls: {
type: 'array',
items: { type: 'string' },
example: ['https://example.com/article1', 'https://example.com/article2'],
},
options: {
type: 'object',
properties: {
includeMarkdown: { type: 'boolean', example: true },
maxLength: { type: 'number', example: 5000 },
},
},
concurrency: { type: 'number', example: 5 },
},
},
})
@ApiResponse({ status: 200, description: 'Extracted content from all URLs' })
async bulkExtract(@Body() body: BulkExtractRequestDto) {
return this.searchProxy.bulkExtract(body);
}
// === STT ===
@Post('stt/transcribe')
@ApiTags('STT')
@ApiOperation({
summary: 'Transcribe audio to text',
description:
'Converts audio file to text. Costs 10 credits per minute of audio. Supports WAV, MP3, OGG, FLAC.',
})
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: { type: 'string', format: 'binary', description: 'Audio file' },
language: { type: 'string', example: 'en', description: 'Language code (optional)' },
model: { type: 'string', example: 'base', description: 'Model name (optional)' },
},
},
})
@ApiResponse({ status: 200, description: 'Transcription result' })
@ApiResponse({ status: 400, description: 'Invalid audio file' })
@UseInterceptors(FileInterceptor('file'))
async transcribe(@UploadedFile() file: Express.Multer.File, @Body() body: TranscribeRequestDto) {
return this.sttProxy.transcribe(file, body);
}
@Get('stt/models')
@ApiTags('STT')
@ApiOperation({
summary: 'Get available STT models',
description: 'Returns a list of available speech-to-text models. Free endpoint.',
})
@ApiResponse({ status: 200, description: 'List of STT models' })
async getSttModels() {
return this.sttProxy.getModels();
}
@Get('stt/languages')
@ApiTags('STT')
@ApiOperation({
summary: 'Get supported languages',
description: 'Returns a list of languages supported by STT. Free endpoint.',
})
@ApiResponse({ status: 200, description: 'List of supported languages' })
async getSttLanguages() {
return this.sttProxy.getLanguages();
}
// === TTS ===
@Post('tts/synthesize')
@ApiTags('TTS')
@ApiOperation({
summary: 'Synthesize text to speech',
description:
'Converts text to audio. Costs 1 credit per 1000 characters. Returns audio file (MP3, WAV, or OGG).',
})
@ApiBody({
schema: {
type: 'object',
required: ['text'],
properties: {
text: { type: 'string', example: 'Hello, world! This is a test.' },
voice: { type: 'string', example: 'en-US-1', description: 'Voice ID' },
language: { type: 'string', example: 'en-US', description: 'Language code' },
speed: { type: 'number', example: 1.0, minimum: 0.5, maximum: 2.0 },
format: { type: 'string', enum: ['mp3', 'wav', 'ogg'], default: 'mp3' },
},
},
})
@ApiResponse({
status: 200,
description: 'Audio file',
content: {
'audio/mpeg': { schema: { type: 'string', format: 'binary' } },
'audio/wav': { schema: { type: 'string', format: 'binary' } },
'audio/ogg': { schema: { type: 'string', format: 'binary' } },
},
})
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')
@ApiTags('TTS')
@ApiOperation({
summary: 'Get available voices',
description: 'Returns a list of available TTS voices. Free endpoint.',
})
@ApiResponse({ status: 200, description: 'List of available voices' })
async getTtsVoices() {
return this.ttsProxy.getVoices();
}
@Get('tts/languages')
@ApiTags('TTS')
@ApiOperation({
summary: 'Get supported TTS languages',
description: 'Returns a list of languages supported by TTS. Free endpoint.',
})
@ApiResponse({ status: 200, description: 'List of supported languages' })
async getTtsLanguages() {
return this.ttsProxy.getLanguages();
}
}

View file

@ -1,56 +0,0 @@
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<string>('redis.host') || 'localhost';
const port = configService.get<number>('redis.port') || 6379;
const password = configService.get<string>('redis.password');
return new Redis({
host,
port,
password: password || undefined,
keyPrefix: configService.get<string>('redis.keyPrefix') || 'api-gateway:',
});
},
inject: [ConfigService],
},
],
exports: [REDIS_CLIENT],
})
export class ProxyModule {}

View file

@ -1,3 +0,0 @@
export * from './search-proxy.service';
export * from './stt-proxy.service';
export * from './tts-proxy.service';

View file

@ -1,102 +0,0 @@
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<any> {
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<any> {
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<any> {
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<any> {
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();
}
}

View file

@ -1,64 +0,0 @@
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<any> {
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<any> {
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<any> {
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();
}
}

View file

@ -1,58 +0,0 @@
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<Buffer> {
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<any> {
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<any> {
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();
}
}

View file

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SchedulerService } from './scheduler.service';
@Module({
imports: [ScheduleModule.forRoot()],
providers: [SchedulerService],
})
export class SchedulerModule {}

View file

@ -1,72 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { apiKeys } from '../db/schema';
@Injectable()
export class SchedulerService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: ReturnType<typeof import('../db/connection').getDb>
) {}
/**
* Reset monthly credits on the 1st of each month at 00:00 UTC
*/
@Cron('0 0 1 * *')
async resetMonthlyCredits() {
console.log('[Scheduler] Running monthly credit reset...');
try {
const result = await this.db
.update(apiKeys)
.set({
creditsUsed: 0,
creditsResetAt: this.getNextMonthReset(),
updatedAt: new Date(),
})
.returning({ id: apiKeys.id });
console.log(`[Scheduler] Reset credits for ${result.length} API keys`);
} catch (error) {
console.error('[Scheduler] Failed to reset monthly credits:', error);
}
}
/**
* Clean up old usage logs (older than 90 days) - runs weekly
*/
@Cron(CronExpression.EVERY_WEEK)
async cleanupOldUsageLogs() {
console.log('[Scheduler] Cleaning up old usage logs...');
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 90);
await this.db.execute(
sql`DELETE FROM api_gateway.api_usage WHERE created_at < ${cutoffDate.toISOString()}`
);
console.log('[Scheduler] Cleaned up usage logs older than 90 days');
} catch (error) {
console.error('[Scheduler] Failed to cleanup usage logs:', error);
}
}
/**
* Aggregate daily usage stats - runs at 1:00 AM UTC
*/
@Cron('0 1 * * *')
async aggregateDailyUsage() {
console.log('[Scheduler] Daily usage aggregation completed (handled by interceptor)');
// Note: Daily aggregation is already handled in real-time by UsageTrackingInterceptor
// This cron is a placeholder for any additional daily processing
}
private getNextMonthReset(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 1, 1);
}
}

View file

@ -1,42 +0,0 @@
import { IsOptional, IsString, IsDateString, IsInt, Min, Max } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UsageQueryDto {
@ApiPropertyOptional({
description: 'Number of days to query (1-365)',
minimum: 1,
maximum: 365,
default: 30,
})
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsInt()
@Min(1)
@Max(365)
days?: number = 30;
@ApiPropertyOptional({
description: 'Start date for custom range (ISO 8601)',
example: '2025-01-01',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'End date for custom range (ISO 8601)',
example: '2025-01-31',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Filter by endpoint',
example: 'search',
})
@IsOptional()
@IsString()
endpoint?: string;
}

View file

@ -1,10 +0,0 @@
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 {}

View file

@ -1,187 +0,0 @@
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<string, any>;
}
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<typeof import('../db/connection').getDb>
) {}
/**
* Track a single API usage event
*/
async track(params: TrackUsageParams): Promise<void> {
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<void> {
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<UsageSummary> {
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;
}
}

View file

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View file

@ -1,25 +0,0 @@
{
"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"]
}