feat: add mana-api-gateway for monetizing core services

Implement custom NestJS API Gateway for mana-search, mana-stt, and mana-tts:

- API Key management with CRUD operations and key regeneration
- Redis-based sliding window rate limiting
- Credit-based billing with tier support (free, pro, enterprise)
- Usage tracking with daily aggregates
- Proxy services to backend microservices
- Prometheus metrics endpoint
- JWT auth for management API, API key auth for public API

Database schema uses separate `api_gateway` schema in shared manacore DB.
This commit is contained in:
Till-JS 2026-01-29 17:30:21 +01:00
parent fbd315eac0
commit 6f1b2654f1
48 changed files with 2507 additions and 0 deletions

View file

@ -0,0 +1,22 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { ApiKeyData } from '../../api-keys/api-keys.service';
/**
* Parameter decorator to extract the validated API key data from the request.
* Must be used with ApiKeyGuard.
*
* @example
* ```typescript
* @Post('search')
* @UseGuards(ApiKeyGuard)
* search(@ApiKeyData() apiKey: ApiKeyData) {
* return { keyId: apiKey.id };
* }
* ```
*/
export const ApiKeyParam = createParamDecorator(
(data: unknown, ctx: ExecutionContext): ApiKeyData => {
const request = ctx.switchToHttp().getRequest();
return request.apiKey;
}
);

View file

@ -0,0 +1,37 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<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

@ -0,0 +1,113 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { ApiKeyData } from '../../api-keys/api-keys.service';
import { ApiKeysService } from '../../api-keys/api-keys.service';
import { UsageService } from '../../usage/usage.service';
import { CreditsService } from '../../credits/credits.service';
import { CREDIT_COSTS } from '../../config/pricing';
@Injectable()
export class UsageTrackingInterceptor implements NestInterceptor {
constructor(
private readonly usageService: UsageService,
private readonly creditsService: CreditsService,
private readonly apiKeysService: ApiKeysService
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<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;
}
}
}