mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 13:46:41 +02:00
✨ 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:
parent
fbd315eac0
commit
6f1b2654f1
48 changed files with 2507 additions and 0 deletions
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { CreateApiKeyDto, UpdateApiKeyDto } from './dto';
|
||||
import { UsageService } from '../usage/usage.service';
|
||||
|
||||
@Controller('api-keys')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ApiKeysController {
|
||||
constructor(
|
||||
private readonly apiKeyService: ApiKeysService,
|
||||
private readonly usageService: UsageService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateApiKeyDto) {
|
||||
const result = await this.apiKeyService.create(user.userId, dto);
|
||||
return {
|
||||
message: 'API key created successfully. Save your key - it will not be shown again.',
|
||||
key: result.key,
|
||||
apiKey: result.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: CurrentUserData) {
|
||||
const keys = await this.apiKeyService.listByUser(user.userId);
|
||||
return { apiKeys: keys };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async get(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const key = await this.apiKeyService.getByIdAndUser(id, user.userId);
|
||||
return { apiKey: key };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateApiKeyDto
|
||||
) {
|
||||
const key = await this.apiKeyService.update(id, user.userId, dto);
|
||||
return { apiKey: key };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
await this.apiKeyService.delete(id, user.userId);
|
||||
return { message: 'API key deleted successfully' };
|
||||
}
|
||||
|
||||
@Post(':id/regenerate')
|
||||
async regenerate(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
const result = await this.apiKeyService.regenerate(id, user.userId);
|
||||
return {
|
||||
message: 'API key regenerated successfully. Save your new key - it will not be shown again.',
|
||||
key: result.key,
|
||||
apiKey: result.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id/usage')
|
||||
async getUsage(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Query('days') days?: string
|
||||
) {
|
||||
// Verify ownership
|
||||
await this.apiKeyService.getByIdAndUser(id, user.userId);
|
||||
|
||||
const daysNum = parseInt(days || '30', 10);
|
||||
const usage = await this.usageService.getDailyUsage(id, daysNum);
|
||||
|
||||
return { usage };
|
||||
}
|
||||
|
||||
@Get(':id/usage/summary')
|
||||
async getUsageSummary(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
// Verify ownership
|
||||
await this.apiKeyService.getByIdAndUser(id, user.userId);
|
||||
|
||||
const summary = await this.usageService.getUsageSummary(id);
|
||||
|
||||
return { summary };
|
||||
}
|
||||
}
|
||||
12
services/mana-api-gateway/src/api-keys/api-keys.module.ts
Normal file
12
services/mana-api-gateway/src/api-keys/api-keys.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { UsageModule } from '../usage/usage.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => UsageModule)],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
277
services/mana-api-gateway/src/api-keys/api-keys.service.ts
Normal file
277
services/mana-api-gateway/src/api-keys/api-keys.service.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import * as crypto from 'crypto';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import { apiKeys, ApiKey, NewApiKey } from '../db/schema';
|
||||
import { CreateApiKeyDto, UpdateApiKeyDto } from './dto';
|
||||
import { PRICING_TIERS, PricingTier } from '../config/pricing';
|
||||
|
||||
export interface ApiKeyData {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
organizationId: string | null;
|
||||
name: string;
|
||||
tier: string;
|
||||
rateLimit: number;
|
||||
monthlyCredits: number;
|
||||
creditsUsed: number;
|
||||
allowedEndpoints: string | null;
|
||||
allowedIps: string | null;
|
||||
active: boolean;
|
||||
expiresAt: Date | null;
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeysService {
|
||||
private readonly keyPrefixLive: string;
|
||||
private readonly keyPrefixTest: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private readonly db: ReturnType<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { IsString, IsOptional, IsEnum, IsArray, IsDateString } from 'class-validator';
|
||||
import { PricingTier } from '../../config/pricing';
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(['free', 'pro', 'enterprise'])
|
||||
tier?: PricingTier;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
allowedEndpoints?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
allowedIps?: string[];
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
expiresAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
isTest?: boolean;
|
||||
}
|
||||
2
services/mana-api-gateway/src/api-keys/dto/index.ts
Normal file
2
services/mana-api-gateway/src/api-keys/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-api-key.dto';
|
||||
export * from './update-api-key.dto';
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { IsString, IsOptional, IsBoolean, IsArray, IsDateString } from 'class-validator';
|
||||
|
||||
export class UpdateApiKeyDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
allowedEndpoints?: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
allowedIps?: string[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
active?: boolean;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
expiresAt?: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue