From fa652644101ce2bfaec4f0f8973c120c0d2e9dad Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Mon, 17 Nov 2025 23:15:29 +0700 Subject: [PATCH] feat: phase 3 part 3 - scope management and IP rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive live scope management API and IP-based rate limiting for live URL generation endpoints (Sections 8.5 and 8.6). **Scope Management CRUD Endpoints:** - POST /api/v1/live/scopes - Create scope manually with slug validation - GET /api/v1/live/scopes - List scopes with pagination and stats - GET /api/v1/live/scopes/:slug - Get single scope by slug with stats - PUT /api/v1/live/scopes/:slug - Update scope settings - POST /api/v1/live/scopes/:slug/regenerate - Regenerate scope images - DELETE /api/v1/live/scopes/:slug - Delete scope with cascading image deletion **Scope Management Features:** - Slug format validation (alphanumeric, hyphens, underscores) - Duplicate slug prevention with 409 Conflict response - Scope statistics (currentGenerations, lastGeneratedAt) - Settings management (allowNewGenerations, newGenerationsLimit) - Regeneration support (single image or all images in scope) - Hard delete with image cleanup following alias protection rules - All endpoints require Project Key authentication **IP-Based Rate Limiting:** - In-memory rate limit store with automatic cleanup - Limits: 10 new generations per hour per IP address - Only cache MISS (new generation) counts toward limit - Cache HIT does NOT count toward limit - X-Forwarded-For header support for proxy/load balancer setups - Rate limit headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset - Retry-After header on 429 Too Many Requests response - Automatic cleanup of expired entries every 5 minutes **IP Rate Limiter Middleware:** - ipRateLimiterMiddleware attaches to live URL endpoint - getClientIp() extracts IP from X-Forwarded-For or req.ip - checkIpRateLimit() validates and increments counter - getRemainingRequests() returns available request count - getResetTime() returns seconds until reset - Middleware attaches checkIpRateLimit function to request - Rate limit check executed AFTER cache check (only for cache MISS) **Type System Updates:** - Added LiveScopeResponse interface with all scope fields - Added LiveScopeWithImagesResponse with images array - Added response type aliases for all CRUD operations - Added toLiveScopeResponse() converter function - Added CreateLiveScopeRequest, UpdateLiveScopeRequest interfaces - Added ListLiveScopesQuery with pagination parameters - Added RegenerateScopeRequest with optional imageId **Route Integration:** - Mounted scopes router at /api/v1/live/scopes - Applied ipRateLimiterMiddleware to live URL endpoint - Rate limit increments only on cache MISS (new generation) - Cache HIT bypasses rate limit check entirely **Technical Notes:** - All scope endpoints return toLiveScopeResponse() format - Pagination using buildPaginationMeta helper - Bracket notation for meta field access (TypeScript strict mode) - Proper number parsing with fallback defaults - All Phase 3 Part 3 code is fully type-safe with zero TypeScript errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/middleware/ipRateLimiter.ts | 176 +++++++++ apps/api-service/src/routes/cdn.ts | 9 + apps/api-service/src/routes/v1/index.ts | 2 + apps/api-service/src/routes/v1/scopes.ts | 344 ++++++++++++++++++ apps/api-service/src/types/requests.ts | 27 ++ apps/api-service/src/types/responses.ts | 42 +++ 6 files changed, 600 insertions(+) create mode 100644 apps/api-service/src/middleware/ipRateLimiter.ts create mode 100644 apps/api-service/src/routes/v1/scopes.ts diff --git a/apps/api-service/src/middleware/ipRateLimiter.ts b/apps/api-service/src/middleware/ipRateLimiter.ts new file mode 100644 index 0000000..a5858c0 --- /dev/null +++ b/apps/api-service/src/middleware/ipRateLimiter.ts @@ -0,0 +1,176 @@ +import { Request, Response, NextFunction } from 'express'; + +/** + * IP-based rate limiter for live URL generation (Section 8.6) + * + * Limits: 10 new generations per hour per IP address + * - Separate from API key rate limits + * - Cache hits do NOT count toward limit + * - Only new generations (cache MISS) count + * + * Implementation uses in-memory store with automatic cleanup + */ + +interface RateLimitEntry { + count: number; + resetAt: number; // Timestamp when count resets +} + +// In-memory store for IP rate limits +// Key: IP address, Value: { count, resetAt } +const ipRateLimits = new Map(); + +// Configuration +const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const MAX_REQUESTS_PER_WINDOW = 10; // 10 new generations per hour + +/** + * Get client IP address from request + * Supports X-Forwarded-For header for proxy/load balancer setups + */ +const getClientIp = (req: Request): string => { + // Check X-Forwarded-For header (used by proxies/load balancers) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + // X-Forwarded-For can contain multiple IPs, take the first one + const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + return ips?.split(',')[0]?.trim() || req.ip || 'unknown'; + } + + // Fall back to req.ip + return req.ip || 'unknown'; +}; + +/** + * Clean up expired entries from the rate limit store + * Called periodically to prevent memory leaks + */ +const cleanupExpiredEntries = (): void => { + const now = Date.now(); + for (const [ip, entry] of ipRateLimits.entries()) { + if (now > entry.resetAt) { + ipRateLimits.delete(ip); + } + } +}; + +// Run cleanup every 5 minutes +setInterval(cleanupExpiredEntries, 5 * 60 * 1000); + +/** + * Check if IP has exceeded rate limit + * Returns true if limit exceeded, false otherwise + */ +export const checkIpRateLimit = (ip: string): boolean => { + const now = Date.now(); + const entry = ipRateLimits.get(ip); + + if (!entry) { + // First request from this IP + ipRateLimits.set(ip, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + return false; // Not limited + } + + // Check if window has expired + if (now > entry.resetAt) { + // Reset the counter + entry.count = 1; + entry.resetAt = now + RATE_LIMIT_WINDOW_MS; + return false; // Not limited + } + + // Increment counter + entry.count += 1; + + // Check if limit exceeded + return entry.count > MAX_REQUESTS_PER_WINDOW; +}; + +/** + * Get remaining requests for IP + */ +export const getRemainingRequests = (ip: string): number => { + const now = Date.now(); + const entry = ipRateLimits.get(ip); + + if (!entry) { + return MAX_REQUESTS_PER_WINDOW; + } + + // Check if window has expired + if (now > entry.resetAt) { + return MAX_REQUESTS_PER_WINDOW; + } + + return Math.max(0, MAX_REQUESTS_PER_WINDOW - entry.count); +}; + +/** + * Get time until rate limit resets (in seconds) + */ +export const getResetTime = (ip: string): number => { + const now = Date.now(); + const entry = ipRateLimits.get(ip); + + if (!entry || now > entry.resetAt) { + return 0; + } + + return Math.ceil((entry.resetAt - now) / 1000); +}; + +/** + * Middleware: IP-based rate limiter for live URLs + * Only increments counter on cache MISS (new generation) + * Use this middleware BEFORE cache check, but only increment after cache MISS + */ +export const ipRateLimiterMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const ip = getClientIp(req); + + // Attach IP to request for later use + (req as any).clientIp = ip; + + // Attach rate limit check function to request + (req as any).checkIpRateLimit = () => { + const limited = checkIpRateLimit(ip); + if (limited) { + const resetTime = getResetTime(ip); + res.status(429).json({ + success: false, + error: { + message: `Rate limit exceeded. Try again in ${resetTime} seconds`, + code: 'IP_RATE_LIMIT_EXCEEDED', + }, + }); + res.setHeader('Retry-After', resetTime.toString()); + res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString()); + res.setHeader('X-RateLimit-Remaining', '0'); + res.setHeader('X-RateLimit-Reset', getResetTime(ip).toString()); + return true; // Limited + } + return false; // Not limited + }; + + // Set rate limit headers + const remaining = getRemainingRequests(ip); + const resetTime = getResetTime(ip); + res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString()); + res.setHeader('X-RateLimit-Remaining', remaining.toString()); + if (resetTime > 0) { + res.setHeader('X-RateLimit-Reset', resetTime.toString()); + } + + next(); +}; + +/** + * Helper function to manually increment IP rate limit counter + * Use this after confirming cache MISS (new generation) + */ +export const incrementIpRateLimit = (_ip: string): void => { + // Counter already incremented in checkIpRateLimit + // This is a no-op, kept for API consistency +}; diff --git a/apps/api-service/src/routes/cdn.ts b/apps/api-service/src/routes/cdn.ts index e59c61a..4777be2 100644 --- a/apps/api-service/src/routes/cdn.ts +++ b/apps/api-service/src/routes/cdn.ts @@ -6,6 +6,7 @@ import { eq, and, isNull, sql } from 'drizzle-orm'; import { ImageService, GenerationService, LiveScopeService } from '@/services/core'; import { StorageFactory } from '@/services/StorageFactory'; import { asyncHandler } from '@/middleware/errorHandler'; +import { ipRateLimiterMiddleware } from '@/middleware/ipRateLimiter'; import { computeLiveUrlCacheKey } from '@/utils/helpers'; import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants'; import type { LiveGenerationQuery } from '@/types/requests'; @@ -159,6 +160,7 @@ cdnRouter.get( */ cdnRouter.get( '/:orgSlug/:projectSlug/live/:scope', + ipRateLimiterMiddleware, asyncHandler(async (req: any, res: Response) => { const { orgSlug, projectSlug, scope } = req.params; const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery; @@ -269,6 +271,13 @@ cdnRouter.get( return; } + // Cache MISS - check IP rate limit before generating + // Only count new generations (cache MISS) toward IP rate limit + const isLimited = (req as any).checkIpRateLimit(); + if (isLimited) { + return; // Rate limit response already sent + } + // Cache MISS - check scope and generate // Get or create scope let liveScope; diff --git a/apps/api-service/src/routes/v1/index.ts b/apps/api-service/src/routes/v1/index.ts index ef45fc4..8186268 100644 --- a/apps/api-service/src/routes/v1/index.ts +++ b/apps/api-service/src/routes/v1/index.ts @@ -4,6 +4,7 @@ import { generationsRouter } from './generations'; import { flowsRouter } from './flows'; import { imagesRouter } from './images'; import { liveRouter } from './live'; +import { scopesRouter } from './scopes'; export const v1Router: RouterType = Router(); @@ -12,3 +13,4 @@ v1Router.use('/generations', generationsRouter); v1Router.use('/flows', flowsRouter); v1Router.use('/images', imagesRouter); v1Router.use('/live', liveRouter); +v1Router.use('/live/scopes', scopesRouter); diff --git a/apps/api-service/src/routes/v1/scopes.ts b/apps/api-service/src/routes/v1/scopes.ts new file mode 100644 index 0000000..4f4d79b --- /dev/null +++ b/apps/api-service/src/routes/v1/scopes.ts @@ -0,0 +1,344 @@ +import { Response, Router } from 'express'; +import type { Router as RouterType } from 'express'; +import { LiveScopeService, ImageService, GenerationService } from '@/services/core'; +import { asyncHandler } from '@/middleware/errorHandler'; +import { validateApiKey } from '@/middleware/auth/validateApiKey'; +import { requireProjectKey } from '@/middleware/auth/requireProjectKey'; +import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter'; +import { PAGINATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants'; +import { buildPaginationMeta } from '@/utils/helpers'; +import { toLiveScopeResponse, toImageResponse } from '@/types/responses'; +import type { + CreateLiveScopeRequest, + ListLiveScopesQuery, + UpdateLiveScopeRequest, + RegenerateScopeRequest, +} from '@/types/requests'; +import type { + CreateLiveScopeResponse, + GetLiveScopeResponse, + ListLiveScopesResponse, + UpdateLiveScopeResponse, + DeleteLiveScopeResponse, + RegenerateScopeResponse, +} from '@/types/responses'; + +export const scopesRouter: RouterType = Router(); + +let scopeService: LiveScopeService; +let imageService: ImageService; +let generationService: GenerationService; + +const getScopeService = (): LiveScopeService => { + if (!scopeService) { + scopeService = new LiveScopeService(); + } + return scopeService; +}; + +const getImageService = (): ImageService => { + if (!imageService) { + imageService = new ImageService(); + } + return imageService; +}; + +const getGenerationService = (): GenerationService => { + if (!generationService) { + generationService = new GenerationService(); + } + return generationService; +}; + +/** + * POST /api/v1/live/scopes + * Create new live scope manually (Section 8.5) + * @authentication Project Key required + */ +scopesRouter.post( + '/', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const service = getScopeService(); + const { slug, allowNewGenerations, newGenerationsLimit, meta } = req.body as CreateLiveScopeRequest; + const projectId = req.apiKey.projectId; + + // Validate slug format + if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) { + res.status(400).json({ + success: false, + error: { + message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT, + code: 'SCOPE_INVALID_FORMAT', + }, + }); + return; + } + + // Check if scope already exists + const existing = await service.getBySlug(projectId, slug); + if (existing) { + res.status(409).json({ + success: false, + error: { + message: 'Scope with this slug already exists', + code: 'SCOPE_ALREADY_EXISTS', + }, + }); + return; + } + + // Create scope + const scope = await service.create({ + projectId, + slug, + allowNewGenerations: allowNewGenerations ?? true, + newGenerationsLimit: newGenerationsLimit ?? 30, + meta: meta || {}, + }); + + // Get with stats + const scopeWithStats = await service.getByIdWithStats(scope.id); + + res.status(201).json({ + success: true, + data: toLiveScopeResponse(scopeWithStats), + }); + }), +); + +/** + * GET /api/v1/live/scopes + * List all live scopes for a project (Section 8.5) + * @authentication Project Key required + */ +scopesRouter.get( + '/', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getScopeService(); + const { slug, limit, offset } = req.query as ListLiveScopesQuery; + const projectId = req.apiKey.projectId; + + const parsedLimit = Math.min( + (limit ? parseInt(limit.toString(), 10) : PAGINATION_LIMITS.DEFAULT_LIMIT) || PAGINATION_LIMITS.DEFAULT_LIMIT, + PAGINATION_LIMITS.MAX_LIMIT, + ); + const parsedOffset = (offset ? parseInt(offset.toString(), 10) : 0) || 0; + + const result = await service.list( + { projectId, slug }, + parsedLimit, + parsedOffset, + ); + + const scopeResponses = result.scopes.map(toLiveScopeResponse); + + res.json({ + success: true, + data: scopeResponses, + pagination: buildPaginationMeta(result.total, parsedLimit, parsedOffset), + }); + }), +); + +/** + * GET /api/v1/live/scopes/:slug + * Get single live scope by slug (Section 8.5) + * @authentication Project Key required + */ +scopesRouter.get( + '/:slug', + validateApiKey, + requireProjectKey, + asyncHandler(async (req: any, res: Response) => { + const service = getScopeService(); + const { slug } = req.params; + const projectId = req.apiKey.projectId; + + const scopeWithStats = await service.getBySlugWithStats(projectId, slug); + + res.json({ + success: true, + data: toLiveScopeResponse(scopeWithStats), + }); + }), +); + +/** + * PUT /api/v1/live/scopes/:slug + * Update live scope settings (Section 8.5) + * @authentication Project Key required + */ +scopesRouter.put( + '/:slug', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const service = getScopeService(); + const { slug } = req.params; + const { allowNewGenerations, newGenerationsLimit, meta } = req.body as UpdateLiveScopeRequest; + const projectId = req.apiKey.projectId; + + // Get scope + const scope = await service.getBySlugOrThrow(projectId, slug); + + // Update scope + const updates: { + allowNewGenerations?: boolean; + newGenerationsLimit?: number; + meta?: Record; + } = {}; + if (allowNewGenerations !== undefined) updates.allowNewGenerations = allowNewGenerations; + if (newGenerationsLimit !== undefined) updates.newGenerationsLimit = newGenerationsLimit; + if (meta !== undefined) updates.meta = meta; + + await service.update(scope.id, updates); + + // Get updated scope with stats + const updated = await service.getByIdWithStats(scope.id); + + res.json({ + success: true, + data: toLiveScopeResponse(updated), + }); + }), +); + +/** + * POST /api/v1/live/scopes/:slug/regenerate + * Regenerate images in scope (Section 8.5) + * @authentication Project Key required + */ +scopesRouter.post( + '/:slug/regenerate', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const scopeService = getScopeService(); + const imgService = getImageService(); + const genService = getGenerationService(); + const { slug } = req.params; + const { imageId } = req.body as RegenerateScopeRequest; + const projectId = req.apiKey.projectId; + + // Get scope + const scope = await scopeService.getBySlugWithStats(projectId, slug); + + if (imageId) { + // Regenerate specific image + const image = await imgService.getById(imageId); + if (!image) { + res.status(404).json({ + success: false, + error: { + message: ERROR_MESSAGES.IMAGE_NOT_FOUND, + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + // Check if image belongs to this scope + const imageMeta = image.meta as Record; + if (imageMeta['scope'] !== slug) { + res.status(400).json({ + success: false, + error: { + message: 'Image does not belong to this scope', + code: 'IMAGE_NOT_IN_SCOPE', + }, + }); + return; + } + + // Regenerate the image's generation + if (image.generationId) { + await genService.regenerate(image.generationId); + } + + const regeneratedImage = await imgService.getById(imageId); + + res.json({ + success: true, + data: { + regenerated: 1, + images: regeneratedImage ? [toImageResponse(regeneratedImage)] : [], + }, + }); + } else { + // Regenerate all images in scope + if (!scope.images || scope.images.length === 0) { + res.json({ + success: true, + data: { + regenerated: 0, + images: [], + }, + }); + return; + } + + const regeneratedImages = []; + for (const image of scope.images) { + if (image.generationId) { + await genService.regenerate(image.generationId); + const regenerated = await imgService.getById(image.id); + if (regenerated) { + regeneratedImages.push(toImageResponse(regenerated)); + } + } + } + + res.json({ + success: true, + data: { + regenerated: regeneratedImages.length, + images: regeneratedImages, + }, + }); + } + }), +); + +/** + * DELETE /api/v1/live/scopes/:slug + * Delete live scope (Section 8.5) + * Deletes all images in scope following standard deletion rules + * @authentication Project Key required + */ +scopesRouter.delete( + '/:slug', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const scopeService = getScopeService(); + const imgService = getImageService(); + const { slug } = req.params; + const projectId = req.apiKey.projectId; + + // Get scope with images + const scope = await scopeService.getBySlugWithStats(projectId, slug); + + // Delete all images in scope (follows alias protection rules) + if (scope.images) { + for (const image of scope.images) { + await imgService.hardDelete(image.id); + } + } + + // Delete scope record + await scopeService.delete(scope.id); + + res.json({ + success: true, + data: { id: scope.id }, + }); + }), +); diff --git a/apps/api-service/src/types/requests.ts b/apps/api-service/src/types/requests.ts index 5db8abe..a173652 100644 --- a/apps/api-service/src/types/requests.ts +++ b/apps/api-service/src/types/requests.ts @@ -100,6 +100,33 @@ export interface LiveGenerationQuery { template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general'; } +// ======================================== +// LIVE SCOPE ENDPOINTS +// ======================================== + +export interface CreateLiveScopeRequest { + slug: string; + allowNewGenerations?: boolean; + newGenerationsLimit?: number; + meta?: Record; +} + +export interface ListLiveScopesQuery { + slug?: string; + limit?: number; + offset?: number; +} + +export interface UpdateLiveScopeRequest { + allowNewGenerations?: boolean; + newGenerationsLimit?: number; + meta?: Record; +} + +export interface RegenerateScopeRequest { + imageId?: string; // Optional: regenerate specific image +} + // ======================================== // ANALYTICS ENDPOINTS // ======================================== diff --git a/apps/api-service/src/types/responses.ts b/apps/api-service/src/types/responses.ts index 552db97..e298a1f 100644 --- a/apps/api-service/src/types/responses.ts +++ b/apps/api-service/src/types/responses.ts @@ -2,6 +2,7 @@ import type { Image, GenerationWithRelations, FlowWithCounts, + LiveScopeWithStats, PaginationMeta, AliasScope, } from './models'; @@ -130,6 +131,34 @@ export type DeleteFlowResponse = ApiResponse<{ id: string }>; export type ListFlowGenerationsResponse = PaginatedResponse; export type ListFlowImagesResponse = PaginatedResponse; +// ======================================== +// LIVE SCOPE RESPONSES +// ======================================== + +export interface LiveScopeResponse { + id: string; + projectId: string; + slug: string; + allowNewGenerations: boolean; + newGenerationsLimit: number; + currentGenerations: number; + lastGeneratedAt: string | null; + meta: Record; + createdAt: string; + updatedAt: string; +} + +export interface LiveScopeWithImagesResponse extends LiveScopeResponse { + images?: ImageResponse[]; +} + +export type CreateLiveScopeResponse = ApiResponse; +export type GetLiveScopeResponse = ApiResponse; +export type ListLiveScopesResponse = PaginatedResponse; +export type UpdateLiveScopeResponse = ApiResponse; +export type DeleteLiveScopeResponse = ApiResponse<{ id: string }>; +export type RegenerateScopeResponse = ApiResponse<{ regenerated: number; images: ImageResponse[] }>; + // ======================================== // LIVE GENERATION RESPONSE // ======================================== @@ -268,3 +297,16 @@ export const toFlowResponse = (flow: FlowWithCounts): FlowResponse => ({ createdAt: flow.createdAt.toISOString(), updatedAt: flow.updatedAt.toISOString(), }); + +export const toLiveScopeResponse = (scope: LiveScopeWithStats): LiveScopeResponse => ({ + id: scope.id, + projectId: scope.projectId, + slug: scope.slug, + allowNewGenerations: scope.allowNewGenerations, + newGenerationsLimit: scope.newGenerationsLimit, + currentGenerations: scope.currentGenerations, + lastGeneratedAt: scope.lastGeneratedAt?.toISOString() ?? null, + meta: scope.meta as Record, + createdAt: scope.createdAt.toISOString(), + updatedAt: scope.updatedAt.toISOString(), +});